Spring Boot+MySQL+Redis实现“离门最近宝藏房”申请挑战功能
2026/8/31 12:32:29 网站建设 项目流程

“离门最近的宝藏房”这六个字,在用户侧是一个很自然的诉求:进入一个寻宝、密室或活动页面后,用户希望系统推荐离自己最近的可挑战房间,并一键提交申请。但在后端工程里,这句话会拆成至少四个问题:房间位置如何量化、候选房间如何按距离排序、用户申请如何防止同一房间被重复挑战、以及申请失败后状态如何回滚。这篇文章会以一个最小可运行的 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 Boot
Maven3.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.java

resources 目录下还需要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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='房间表';

申请记录表apply_record保存用户每次申请挑战的信息。apply_no是业务流水号,方便对接管理员审核系统;user_idroom_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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='挑战申请记录表';

这里有一个细节:DECIMAL(10,6)提供 6 位小数精度,约等于 0.1 米级别,对房间定位已经足够。不要用FLOATDOUBLE直接存经纬度,否则排序和比较时容易出现不可控的精度误差。

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-type=wgs84,防止团队内部相互误解。

注意:距离计算不保证坐标体系自动转换。如果前端传入的是 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 List<RoomVO> listNearestRooms(double userLat, double userLon, int limit) { List<Room> rooms = roomMapper.selectList( new LambdaQueryWrapper<Room>() .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是展示对象,字段至少包含roomIdnamedistanceKm。不建议直接把数据库实体返回给前端,因为实体里可能包含不该暴露的版本号、创建时间等字段。

这个方案适合房间表数据量在万级以内的场景。如果房间数量很大,把全表数据拉到内存计算距离会造成不必要的 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 Result<List<RoomVO>> nearest( @RequestParam double lat, @RequestParam double lon) { return Result.ok(roomQueryService.listNearestRooms(lat, lon, 10)); } }

Result是统一响应体,实际项目中可以包含codemessagedata三个字段。这里的接口先返回候选房间,用户从中选择后才进入申请流程。

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:%s,value 保存请求身份标识,防止误删别人持有的锁。

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 LambdaQueryWrapper<ApplyRecord>() .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 为“把 status=2 的房间恢复为 status=1”,finishRoom对应“把 status=2 的房间改为 status=3”。这里的核心是保证申请记录和房间状态在同一个事务里更新,避免只改一边。

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?lat=39.904200&lon=116.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.statusroom.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_Sphere
PostgreSQL + 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 锁设置了过期时间,并且释放时校验持有者。
  • 如果开启事务,确认没有同类内部调用导致事务失效。
  • 距离排序方案与房间数据量匹配,必要时使用空间索引。
  • 日志中能查到applyNoroomIduserId,便于追踪申请链路。
  • 生产环境有定时补偿任务处理超过时限的申请记录。

回到标题里的“离门最近”,它看起来像一句运营文案,但在系统里其实是一组可量化的规则:位置坐标、距离函数、过滤条件、排序参数。只要把规则落到数据表、接口和事务里,“宝藏房”就是一个普通业务对象,申请挑战就是一条带状态流转的写入记录。建议先把最小闭环跑通,再按数据量和业务复杂度逐步引入空间索引和补偿机制。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询