行业资讯
📅 2026/8/31 12:32:34
Spring Boot+MySQL+Redis实现“离门最近宝藏房”申请挑战功能
“离门最近的宝藏房”这六个字在用户侧是一个很自然的诉求进入一个寻宝、密室或活动页面后用户希望系统推荐离自己最近的可挑战房间并一键提交申请。但在后端工程里这句话会拆成至少四个问题房间位置如何量化、候选房间如何按距离排序、用户申请如何防止同一房间被重复挑战、以及申请失败后状态如何回滚。这篇文章会以一个最小可运行的 Spring Boot 项目为例把“申请挑战离门最近的宝藏房”这个功能从需求拆解到数据库设计再落到接口实现、并发控制和排查清单。这个需求不算复杂但很典型。它同时涉及地理坐标、排序查询、状态机、分布式锁、数据库唯一约束和事务回滚非常适合作为练手项目。下面先从需求拆解开始再一步步把代码和配置补齐。1. 先拆解“离门最近”和“申请挑战”背后的技术问题1.1 这个需求在业务里到底长什么样假设场景是商场内的一场寻宝挑战活动。系统里有多个房间每个房间有一个门口坐标房门位置用经纬度表示。用户进入小程序后客户端把当前定位坐标上传到后端后端从所有“可申请”的宝藏房中找出距离用户最近的几个展示给用户。用户点击“申请挑战”后系统记录一条申请记录并把房间状态从“可申请”改为“申请中”。管理员审核通过后房间状态变为“已结束”用户取得挑战资格如果管理员拒绝则房间回到“可申请”状态。这里的“门”可以理解为用户当前所在的位置也可以理解为房间入口。为了口径统一后端接口要求前端上传用户当前经纬度后端计算用户坐标与每个房间门口坐标之间的球面距离并按距离升序排列。只要业务口径统一这个模型对“找最近可预约会议室”“找最近可用工位”也一样适用。1.2 拆开看核心问题有四个第一个问题是位置数据怎么存。经纬度不是普通数字它必须明确坐标系。第二个问题是距离怎么算。由于地球是球体不能用平面直角坐标系下的勾股定理简单计算否则纬度越高误差越大。第三个问题是候选房间怎么过滤和排序。系统只应该返回“可申请”的宝藏房不能把已经申请中的房间继续展示给用户。第四个问题是申请动作怎么保证唯一性。两个用户同时申请同一个房间时只能有一个人成功另外一个人必须得到明确提示。这四件事看起来相互独立实际会串成一条完整链路先按位置查出候选房间再展示排序结果用户提交申请后端抢占房间状态最后写入申请记录。任何一环出错用户看到的都是“明明显示可以申请提交却失败了”或者“两个人都申请成功了”。1.3 技术选型和为什么这样选为了让案例可落地这里使用一套常见组合模块选型说明开发框架Spring Boot 2.7.x社区资料多MyBatis-Plus 适配稳定语言Java 8大多数存量项目仍在使用数据库MySQL 8.0存储房间和申请记录支持 utf8mb4缓存与锁Redis 6.x用于申请接口的分布式锁ORMMyBatis-Plus 3.5.x简化单表 CRUD复杂 SQL 仍手写构建工具Maven 3.6统一依赖管理选择这个组合主要是为了教学清晰。如果生产环境已经使用 PostgreSQL可以直接用 PostGIS 的空间函数如果项目是 Python 技术栈也可以把同样的距离公式翻译成对应代码。这里不绑定具体业务或官方产品重点是讲清楚实现原理。2. 环境准备与数据表设计要统一坐标系2.1 开发环境要求在写代码之前先把环境列清楚软件版本建议用途JDK1.8运行 Spring BootMaven3.6管理依赖MySQL8.0数据持久化Redis6.x分布式锁IDEIntelliJ IDEA 或 Eclipse开发调试如果本地没有 MySQL 和 Redis可以用 Docker 快速启动但生产环境必须使用独立的数据库实例和缓存服务。学习阶段可以把所有服务跑在同一台机器上生产环境至少要区分数据库、缓存和应用服务器。2.2 项目结构建议按模块分包避免以后业务扩大后代码堆在一起src/main/java/com/example/treasure ├── TreasureApplication.java ├── controller │ └── RoomApplyController.java ├── service │ ├── RoomQueryService.java │ └── RoomApplyService.java ├── mapper │ ├── RoomMapper.java │ └── ApplyRecordMapper.java ├── entity │ ├── Room.java │ └── ApplyRecord.java ├── util │ └── GeoUtils.java └── config └── RedisConfig.javaresources 目录下还需要application.yml和 MyBatis-Plus 相关的配置。这里先不展开所有文件后面实现到哪个功能再贴哪个代码片段。2.3 数据库表结构房间表和申请记录表房间表room保存房间基础信息和门口坐标。room_type用来区分普通房间和宝藏房status表示当前是否可申请。为了支持将来扩展先加上version字段后续要做乐观锁时可以直接使用。CREATE TABLE room ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT 房间名称, room_type TINYINT NOT NULL DEFAULT 1 COMMENT 1普通 2宝藏, door_longitude DECIMAL(10,6) NOT NULL COMMENT 门口经度, door_latitude DECIMAL(10,6) NOT NULL COMMENT 门口纬度, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可申请 2申请中 3已结束, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status_type (status, room_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT房间表;申请记录表apply_record保存用户每次申请挑战的信息。apply_no是业务流水号方便对接管理员审核系统user_id和room_id上建立唯一索引防止同一个用户反复申请同一个房间。CREATE TABLE apply_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, apply_no VARCHAR(32) NOT NULL COMMENT 申请流水号, user_id VARCHAR(64) NOT NULL COMMENT 用户ID, room_id BIGINT NOT NULL COMMENT 房间ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 0处理中 1通过 2拒绝 3取消, user_longitude DECIMAL(10,6) NOT NULL COMMENT 申请时用户经度, user_latitude DECIMAL(10,6) NOT NULL COMMENT 申请时用户纬度, apply_time DATETIME NOT NULL COMMENT 申请时间, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_room (user_id, room_id), KEY idx_room_status (room_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT挑战申请记录表;这里有一个细节DECIMAL(10,6)提供 6 位小数精度约等于 0.1 米级别对房间定位已经足够。不要用FLOAT或DOUBLE直接存经纬度否则排序和比较时容易出现不可控的精度误差。2.4 坐标系必须统一经纬度坐标常见的标准有 WGS-84、GCJ-02、BD-09。简单理解WGS-84 是 GPS 原生坐标GCJ-02 是国内地图常用的加偏坐标BD-09 是部分地图厂商在 GCJ-02 基础上二次偏移的坐标。如果前端用的高德坐标后端数据库却按 WGS-84 存储计算出来的距离可能偏差几十米甚至几百米。实际项目中接口层必须约定坐标体系。下面示例统一使用 WGS-84 坐标真实项目里建议在接口文档中写清楚“入参坐标类型”并在数据库层面固化同一套坐标系。可以在application.yml里增加一个配置项app.map.coordinate-typewgs84防止团队内部相互误解。注意距离计算不保证坐标体系自动转换。如果前端传入的是 GCJ-02后端必须先将坐标转换到与数据库一致的坐标系再做距离计算。3. 用 Haversine 公式实现“离门最近”的房间推荐3.1 为什么不能直接使用勾股定理很多初学者会把经纬度当作平面坐标用sqrt((lat1-lat2)^2 (lon1-lon2)^2)计算距离这在很小的范围内可能够用但本质上是不严谨的。经度线在不同纬度上的实际距离不同赤道上 1 度经度约 111 公里到了北纬 60 度1 度经度只剩约 55.8 公里。如果用平面距离公式同一段经度差在高纬度会被高估。要计算球面上两点距离可以使用 Haversine 公式。公式的核心思路是通过两点经纬度计算对应的球心角再乘以地球半径得到弧长距离。公式可以写成a sin²(Δφ/2) cos φ1 * cos φ2 * sin²(Δλ/2) c 2 * atan2(√a, √(1-a)) d R * c其中 φ 是纬度λ 是经度R 为地球平均半径约 6371 公里。3.2 Java 实现距离计算工具类把上面的公式翻译成 Java 工具类。这个方法在数据量不大的场景下可以放心调用。package com.example.treasure.util; public class GeoUtils { private static final double EARTH_RADIUS_KM 6371.0; private GeoUtils() { } public static double calculateDistanceKm( double lat1, double lon1, double lat2, double lon2) { double radLat1 Math.toRadians(lat1); double radLat2 Math.toRadians(lat2); double deltaLat Math.toRadians(lat2 - lat1); double deltaLon Math.toRadians(lon2 - lon1); double a Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) Math.cos(radLat1) * Math.cos(radLat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2); double c 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return EARTH_RADIUS_KM * c; } }这个方法返回的是公里数。如果业务需要米返回值乘以 1000。3.3 查询可申请宝藏房并按距离排序查询逻辑分成三步筛选出状态为“可申请”的宝藏房计算每个房间与用户的距离按距离升序返回前 N 条。package com.example.treasure.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.treasure.entity.Room; import com.example.treasure.mapper.RoomMapper; import com.example.treasure.util.GeoUtils; import org.springframework.stereotype.Service; import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; Service public class RoomQueryService { private final RoomMapper roomMapper; public RoomQueryService(RoomMapper roomMapper) { this.roomMapper roomMapper; } public ListRoomVO listNearestRooms(double userLat, double userLon, int limit) { ListRoom rooms roomMapper.selectList( new LambdaQueryWrapperRoom() .eq(Room::getRoomType, 2) .eq(Room::getStatus, 1) ); return rooms.stream() .map(room - toVO(room, userLat, userLon)) .sorted(Comparator.comparingDouble(RoomVO::getDistanceKm)) .limit(limit) .collect(Collectors.toList()); } private RoomVO toVO(Room room, double userLat, double userLon) { double distanceKm GeoUtils.calculateDistanceKm( userLat, userLon, room.getDoorLatitude().doubleValue(), room.getDoorLongitude().doubleValue() ); RoomVO vo new RoomVO(); vo.setRoomId(room.getId()); vo.setName(room.getName()); vo.setDistanceKm(distanceKm); return vo; } }这里RoomVO是展示对象字段至少包含roomId、name、distanceKm。不建议直接把数据库实体返回给前端因为实体里可能包含不该暴露的版本号、创建时间等字段。这个方案适合房间表数据量在万级以内的场景。如果房间数量很大把全表数据拉到内存计算距离会造成不必要的 IO 和 CPU 开销。后面会讨论数据量大时的优化方式。3.4 对外提供最近的宝藏房接口在 Controller 中暴露一个 GET 接口入参为用户经纬度返回按距离排序的房间列表。package com.example.treasure.controller; import com.example.treasure.service.RoomQueryService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; RestController RequestMapping(/api/room) public class RoomApplyController { private final RoomQueryService roomQueryService; public RoomApplyController(RoomQueryService roomQueryService) { this.roomQueryService roomQueryService; } GetMapping(/nearest) public ResultListRoomVO nearest( RequestParam double lat, RequestParam double lon) { return Result.ok(roomQueryService.listNearestRooms(lat, lon, 10)); } }Result是统一响应体实际项目中可以包含code、message、data三个字段。这里的接口先返回候选房间用户从中选择后才进入申请流程。4. 申请挑战功能的并发控制与状态机4.1 申请状态机设计申请不是简单往表里插一条记录还涉及房间状态的流转。可以把状态流转整理成下面的表格角色动作状态变化用户提交申请room: 1可申请 - 2申请中管理员审核通过room: 2申请中 - 3已结束apply_record: 0处理中 - 1通过管理员审核拒绝room: 2申请中 - 1可申请apply_record: 0处理中 - 2拒绝用户或系统取消申请room: 2申请中 - 1可申请apply_record: 0处理中 - 3取消状态机存在的原因是为了防止数据出现“房间申请中但没有任何申请记录”或“申请已通过但房间仍显示可申请”这样的矛盾。所有状态更新必须通过明确入口执行。4.2 高并发下最典型的竞态问题假如房间 5 处于可申请状态用户 A 和用户 B 同时点击申请。两个请求都先把房间状态从数据库查出来发现是 1 可申请接着都执行插入申请记录最后都去更新房间状态。最终房间里会插入两条有效申请而房间状态可能变成申请中也可能因为互相覆盖而变回可申请。这种问题不能靠前端按钮置灰解决必须由后端保证原子性。核心手段有两个数据库条件更新和 Redis 分布式锁。4.3 使用数据库条件更新抢占房间最可靠的方法是让“抢占房间”这一动作成为原子操作。在RoomMapper中增加一个自定义方法package com.example.treasure.mapper; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Update; Mapper public interface RoomMapper { Update(UPDATE room SET status 2 WHERE id #{roomId} AND status 1) int lockRoomForApply(Param(roomId) Long roomId); }这条 SQL 的含义是只有房间当前状态为 1 时才把状态改成 2。如果返回影响行数为 1说明当前请求成功占用了房间如果返回 0说明房间已经被别人申请走或已经结束。为什么这样做能防并发因为数据库会对这一行记录加行锁两个并发 UPDATE 会串行执行后执行的请求看到 status 已经不是 1影响行数就是 0。4.4 申请接口的核心实现申请接口的业务方法建议加上事务。实现顺序是先插入申请记录再抢占房间状态。如果插入记录时发现用户已经申请过同一个房间唯一索引会直接报错如果抢占房间失败整个事务回滚插入的申请记录也会被撤销。package com.example.treasure.service; import com.example.treasure.entity.ApplyRecord; import com.example.treasure.entity.Room; import com.example.treasure.mapper.ApplyRecordMapper; import com.example.treasure.mapper.RoomMapper; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Date; import java.util.UUID; Service public class RoomApplyService { private final RoomMapper roomMapper; private final ApplyRecordMapper applyRecordMapper; public RoomApplyService(RoomMapper roomMapper, ApplyRecordMapper applyRecordMapper) { this.roomMapper roomMapper; this.applyRecordMapper applyRecordMapper; } Transactional(rollbackFor Exception.class) public ApplyResult apply(ApplyRequest request) { ApplyRecord record new ApplyRecord(); record.setApplyNo(UUID.randomUUID().toString().replace(-, )); record.setUserId(request.getUserId()); record.setRoomId(request.getRoomId()); record.setStatus(0); record.setUserLongitude(request.getLongitude()); record.setUserLatitude(request.getLatitude()); record.setApplyTime(new Date()); try { applyRecordMapper.insert(record); } catch (DuplicateKeyException e) { throw new BizException(你已经申请过该房间); } int locked roomMapper.lockRoomForApply(request.getRoomId()); if (locked 0) { throw new BizException(房间刚刚被申请走了请刷新列表); } ApplyResult result new ApplyResult(); result.setApplyNo(record.getApplyNo()); result.setMessage(提交成功等待审核); return result; } }这里有一个容易踩坑的细节申请记录插入成功后如果lockRoomForApply失败不能手动写一条 SQL 把房间状态改回去。正确的做法是让事务整体回滚因为插入申请记录的语句和更新房间状态的语句在同一个事务里回滚后房间状态会回到原来的 1 可申请状态。4.5 Redis 分布式锁作为第一道闸门数据库条件更新已经能保证房间不被重复申请为什么还要 Redis 锁主要目的是减轻数据库压力并且在同一房间被高频点击时可以快速给用户返回“正在申请中”的提示。锁的 key 可以设计为room:apply:%svalue 保存请求身份标识防止误删别人持有的锁。public ApplyResult applyWithRedisLock(ApplyRequest request) { String lockKey room:apply: request.getRoomId(); String token UUID.randomUUID().toString().replace(-, ); Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, token, Duration.ofSeconds(10)); if (!Boolean.TRUE.equals(locked)) { throw new BizException(房间正在申请中请稍后重试); } try { return roomApplyService.apply(request); } finally { releaseLock(lockKey, token); } }释放锁时不能直接delete key要使用 Lua 脚本判断 value 是不是自己写入的避免因为业务耗时过长导致锁过期然后误删其他线程创建的锁。private void releaseLock(String lockKey, String token) { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; stringRedisTemplate.execute( new DefaultRedisScript(script, Long.class), Collections.singletonList(lockKey), token ); }需要特别注意Redis 锁的释放时机要放在事务提交之后。如果roomApplyService.apply是 Spring 管理的TransactionalBean代理方法会在事务提交后才返回锁在 finally 中释放时事务已经提交所以不会出现“锁释放了但数据库数据还没落库”的窗口。如果把 Redis 锁写在事务方法内部则在方法结束前释放锁另一个线程可能立即进入而当前事务还没提交就可能读到旧数据。注意Redis 锁不能替代数据库条件更新。锁可能因为网络问题或过期时间设置不合理而失效数据库状态更新才是最终防线。4.6 审核接口的状态流转管理员审核时也要按状态机处理。审核通过时更新申请记录状态并把房间改为已结束审核拒绝时更新申请记录状态并把房间恢复为可申请。这些操作同样要加事务。Transactional(rollbackFor Exception.class) public void review(String applyNo, int reviewResult) { ApplyRecord record applyRecordMapper.selectOne( new LambdaQueryWrapperApplyRecord() .eq(ApplyRecord::getApplyNo, applyNo) ); if (record null) { throw new BizException(申请不存在); } if (record.getStatus() ! 0) { throw new BizException(申请已处理不能重复审核); } if (reviewResult 1) { applyRecordMapper.updateStatus(record.getId(), 1); roomMapper.finishRoom(record.getRoomId()); } else if (reviewResult 2) { applyRecordMapper.updateStatus(record.getId(), 2); roomMapper.releaseRoom(record.getRoomId()); } }releaseRoom对应 SQL 为“把 status2 的房间恢复为 status1”finishRoom对应“把 status2 的房间改为 status3”。这里的核心是保证申请记录和房间状态在同一个事务里更新避免只改一边。5. 运行验证与常见问题排查5.1 初始化测试数据先准备两条宝藏房数据经纬度稍微拉开差距方便观察排序结果。INSERT INTO room (name, room_type, door_longitude, door_latitude, status) VALUES (宝藏房-东侧, 2, 116.407400, 39.904200, 1); INSERT INTO room (name, room_type, door_longitude, door_latitude, status) VALUES (宝藏房-西侧, 2, 116.407800, 39.904500, 1);如果用户当前位置是东侧房间附近返回结果中“宝藏房-东侧”的距离应该小于“宝藏房-西侧”。5.2 验证距离排序接口启动 Spring Boot 项目后用 curl 调用推荐接口curl http://localhost:8080/api/room/nearest?lat39.904200lon116.407400预期返回一个 JSON 数组第一项是离用户最近的宝藏房{ code: 0, data: [ { roomId: 1, name: 宝藏房-东侧, distanceKm: 0.0 }, { roomId: 2, name: 宝藏房-西侧, distanceKm: 0.0457 } ] }这里的distanceKm是估算值实际数值取决于房间坐标。5.3 验证并发申请可以写一个简单的 Bash 循环模拟 20 个用户同时申请同一个房间for i in $(seq 1 20); do curl -X POST http://localhost:8080/api/room/apply \ -H Content-Type: application/json \ -d {userId:user_$i,roomId:1,longitude:116.407400,latitude:39.904200} done wait执行后查询数据库SELECT user_id, status, apply_time FROM apply_record WHERE room_id 1; SELECT id, name, status FROM room WHERE id 1;预期结果是apply_record中只有一条成功申请记录room的 status 变成 2 申请中。如果有两条以上说明并发控制失效。5.4 高频问题排查表问题现象常见原因检查方式解决方案排序结果距离明显不对前端坐标与数据库坐标体系不一致对比入参经纬度和房间经纬度统一坐标系必要时做坐标转换并发申请后出现多条申请记录缺少唯一索引或没有事务查看SHOW INDEX FROM apply_record添加uk_user_room确认申请方法有事务重复申请提示偶尔失效唯一索引没生效或不同 user_id 视为不同用户检查 insert 异常是否被吞掉捕获DuplicateKeyException并转成业务异常房间一直停在申请中审核失败或事务回滚异常查看apply_record.status和room.status确认审核接口的更新顺序和事务边界Redis 锁释放后房间仍被重复申请事务提交前就释放了锁在日志中打印锁释放和事务提交顺序将锁释放放到事务提交之后并保留数据库兜底5.5 开发阶段最常踩的三个坑第一个坑是计算距离时纬度经度传反。calculateDistanceKm的参数顺序是纬度在前、经度在后很多人从接口拿到lon, lat后直接传错结果距离会变成几千公里。建议在工具方法上增加参数命名并在测试用例中写一组已知坐标做断言。第二个坑是事务方法同类内部调用。比如在同一个类里写了一个apply方法内部直接调用this.review而review上的Transactional不会生效。Spring 的事务通过代理实现同类内部调用不会经过代理。解决办法是拆分到不同 Service或通过AopContext.currentProxy()获取代理对象但更推荐拆分 Service。第三个坑是 Redis 锁没有设置过期时间。如果业务方法抛异常导致 finally 没有执行锁会永久存在。带上过期时间可以防止死锁但过期时间也不能设置太短否则业务没执行完锁就过期了。实际项目中要根据接口耗时量级设置并配合数据库条件更新兜底。6. 生产化改造、最佳实践与扩展方向6.1 学习环境和生产环境的差异学习环境里可以用单机 MySQL、单机 Redis本地日志也足够排查问题。生产环境要额外考虑故障恢复、性能监控和数据一致性。关注点学习环境生产环境数据库本地单机主从复制或云数据库Redis本地单机哨兵或集群模式配置写在 application.yml配置中心或环境变量日志控制台输出统一日志平台关联 traceId并发控制数据库条件更新Redis 锁 数据库条件更新 唯一索引异常处理返回简单错误信息统一异常码、告警、自动补偿6.2 房间数量变大后距离排序怎么做当前实现把符合条件的房间全部查出来在内存里计算距离。房间表在万级以内问题不大一旦到了百万级这种做法会造成严重的查询和计算开销。可以考虑三种扩展方案方案适合场景注意事项MySQL 空间索引百万级以下房间分布固定需要存储 geometry 类型使用ST_Distance_SpherePostgreSQL PostGIS复杂地理查询数据量大距离排序和空间索引更成熟Redis GEO高频周边推荐热数据适合短距离查询但业务字段仍需回数据库如果继续使用 MySQL可以在room表中增加door_location POINT字段并建立空间索引。不过空间索引需要 MySQL 5.7 以上版本使用前要确认数据库版本。ALTER TABLE room ADD door_location POINT NOT NULL; ALTER TABLE room ADD SPATIAL INDEX idx_door_location (door_location);查询时可以使用ST_Distance_Sphere计算球面距离并按距离排序。这样可以把计算下推到数据库避免全表数据加载到应用内存。6.3 申请记录的超时补偿和幂等实际业务中用户提交申请后如果不处理房间会一直处于“申请中”。生产环境需要增加超时机制比如 15 分钟未审核就自动释放房间并取消申请记录。可以用定时任务扫描apply_record中状态为处理中且超过阈值的记录把房间恢复为可申请。同时接口要考虑幂等性。前端提交按钮可能会因为网络超时被用户重复点击最简单的方案是前端生成一个requestId后端在写入申请记录前检查是否已经处理过相同的requestId也可以在 Redis 中保存短时间内的去重标记。6.4 发布前检查清单完成这个功能后发布到生产环境前最好对照以下清单逐项确认前端入参坐标与数据库坐标体系一致。经纬度字段使用 DECIMAL 或 geometry 类型不使用 FLOAT。room表状态变化都有明确入口和事务。apply_record表存在uk_user_room唯一索引。申请接口捕获DuplicateKeyException并转成友好提示。Redis 锁设置了过期时间并且释放时校验持有者。如果开启事务确认没有同类内部调用导致事务失效。距离排序方案与房间数据量匹配必要时使用空间索引。日志中能查到applyNo、roomId、userId便于追踪申请链路。生产环境有定时补偿任务处理超过时限的申请记录。回到标题里的“离门最近”它看起来像一句运营文案但在系统里其实是一组可量化的规则位置坐标、距离函数、过滤条件、排序参数。只要把规则落到数据表、接口和事务里“宝藏房”就是一个普通业务对象申请挑战就是一条带状态流转的写入记录。建议先把最小闭环跑通再按数据量和业务复杂度逐步引入空间索引和补偿机制。