
“离门最近的宝藏房”这个题目放在技术视角下其实是一次典型的短时高并发资源申请。用户同时提交申请系统要保证不超卖、不重复、不丢单还要让体验顺畅。表面看是选房问题本质是库存一致性和异步削峰的工程问题。这篇文章会从业务建模、Redis Lua 脚本、消息队列落库、压测验证和排错几个层面拆解一个可落地的选房申请系统设计方案。如果你准备做宿舍选房、人才公寓申请、活动报名抢位或者只是想搞清楚“短时抢单”类业务的后端怎么写这篇文章可以提供一个完整思路和可以直接复制修改的 Java 版本代码。1. 先看清业务本质这不仅仅是一个“抢”字很多团队遇到“申请挑战离门最近的宝藏房”这类需求第一反应是做一个 POST 接口请求进来就 UPDATE 数据库把房间 owner 改掉。这个方案在低并发下没问题但真实场景不是这样的。选房批次一开大量用户同时提交数据库行锁会迅速堆积接口响应变慢部分请求直接超时最后触发重试很容易出现“一个房间被两个人同时申请成功”的事故。拆开看这个业务包含三个关键点第一个是库存约束。一个房间同一时刻只能被一个用户申请成功判断必须在并发下保持原子性。第二个是幂等约束。同一个用户对同一个批次只能申请一次网络重试不能导致重复下单。第三个是响应模型。请求到达后如果全部同步写库系统会变成 IO 密集型瓶颈更稳妥的做法是快速校验返回“排队中”由后台异步落库。也就是说“离门近的房源”只是流量诱因真正的技术难点是高并发写请求如何快速校验、原子扣减、异步落库并保证数据最终一致。这篇文章给出的方案是基于 Spring Boot Redis RabbitMQ 的实现。Redis Lua 脚本负责原子扣减和用户去重消息队列负责削峰数据库负责最终落账。这个组合可以横向扩展也容易维护。2. 系统总体架构与核心环节拆解整体请求链路可以划分为四个环节用户请求 - Nginx/网关限流 - 抢房服务(Redis Lua 原子扣减) - RabbitMQ 异步落库核心环节说明环节职责关键技术点接入层限流、参数校验、用户身份识别网关限流拦截重复请求抢房服务校验申请资格扣减 Redis 库存Redis Lua 脚本原子操作消息队列缓冲请求削峰填谷RabbitMQ 消费者异步处理数据库申请记录落库房间状态最终更新唯一索引兜底防重这里要特别说明为什么要引入 Redis 和消息队列而不是直接操作数据库。数据库 UPDATE 语句本身有行锁短时间大量请求同时改同一个房间记录会出现锁等待TPS 上不去。Redis 是单线程模型Lua 脚本可以保证多条命令原子执行扣减库存的读写操作没有竞态问题吞吐能力远高于数据库。消息队列在这里不是必须的但加入后有两个明显好处第一抢房服务只需要处理 Redis 操作和消息发送单次请求耗时很短第二数据库写入压力被平摊到消费者线程池避免高并发瞬间打爆数据库连接池。如果你现在的团队没有引入 MQ也可以用本地线程池 异步任务替代但系统重启时队列里的任务会丢失生产环境还是推荐独立 MQ。3. 环境准备与项目初始化本文示例代码基于以下技术栈版本以你本地实际环境为准思路是通用的JDK 17Spring Boot 3.xRedis 6.x 或以上RabbitMQ 3.xMaven 3.8建议先启动本地的 Redis 和 RabbitMQ确保端口可以访问。如果还没有安装可以用 Docker 快速启动# 启动 Redis docker run -d --name redis -p 6379:6379 redis # 启动 RabbitMQ包含管理控制台 docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management项目结构按简单分层组织src/main/java/com/example/roomapply ├── controller │ └── RoomApplyController.java ├── service │ └── RoomApplyService.java ├── mq │ ├── ApplyConsumer.java │ └── RabbitConfig.java ├── entity │ ├── Room.java │ └── RoomApplication.java └── common ├── Result.java └── ApplyRequest.javaSpring Boot 项目的核心依赖配置dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-amqp/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency配置文件src/main/resources/application.ymlserver: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/room_apply?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 rabbitmq: host: localhost port: 5672 username: guest password: guest如果你使用的是 Spring Boot 2.xMyBatis 版本和 Redis 配置会略有区别但核心逻辑不变。先跑通一个最小项目再根据实际版本调整依赖。4. 数据库表设计与 Redis 数据结构设计4.1 数据库表第一张是房间表保存房间基础信息、状态和位置特征CREATE TABLE room ( id BIGINT NOT NULL AUTO_INCREMENT, room_no VARCHAR(32) NOT NULL COMMENT 房间号, building_id BIGINT NOT NULL COMMENT 楼栋ID, floor INT NOT NULL COMMENT 楼层, is_near_door TINYINT NOT NULL DEFAULT 0 COMMENT 是否离门或出口更近1-是0-否, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-可申请1-已占用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_building_floor (building_id, floor) ) ENGINE InnoDB COMMENT 房间表;第二张是申请记录表保存每次申请结果CREATE TABLE room_application ( id BIGINT NOT NULL AUTO_INCREMENT, application_no VARCHAR(64) NOT NULL COMMENT 申请单号, user_id BIGINT NOT NULL COMMENT 申请用户ID, batch_id BIGINT NOT NULL COMMENT 批次ID, room_id BIGINT NOT NULL COMMENT 房间ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-排队中1-成功2-失败3-取消, apply_time DATETIME NOT NULL COMMENT 申请时间, finish_time DATETIME DEFAULT NULL COMMENT 处理完成时间, PRIMARY KEY (id), UNIQUE KEY uk_user_batch (user_id, batch_id), UNIQUE KEY uk_application_no (application_no) ) ENGINE InnoDB COMMENT 房间申请记录表;唯一索引uk_user_batch是数据库层面的最后一道防线。即使 Redis 和消息队列都出了问题同一用户同一批次也不会出现两条成功记录。4.2 Redis 数据结构Redis 里只需要维护两个核心 key房间库存 keyroom:stock:{roomId}用户申请标记 keyuser:apply:{userId}:{batchId}库存 key 在批次开放前通过任务初始化默认值为房间的可申请数量。选房场景下一个房间可申请数量就是 1意味着只能被一个人申请成功。用户申请标记 key 用于幂等判断设置一个合理的过期时间比如 24 小时防止 Redis 内存被长时间占用。注意这个 key 的过期时间要覆盖整个批次周期否则后续请求可能绕过幂等校验。5. 核心代码实现从 Lua 扣库存到 MQ 落库5.1 初始化库存批次开放前需要把房间库存预热到 Redis。这个步骤通常在管理后台或者定时任务里执行Service public class RoomStockService { Autowired private StringRedisTemplate redisTemplate; /** * 将房间可申请数量预热到 Redis */ public void initRoomStock(Long roomId, int stock) { String key room:stock: roomId; redisTemplate.opsForValue().set(key, String.valueOf(stock)); } }这里有一个容易踩坑的点如果你是在某个房间从“不可申请”变成“可申请”时初始化库存要防止旧的 key 残留。更稳妥的做法是直接在初始化时覆盖写入不要用setIfAbsent。setIfAbsent在 key 已存在时会失败很可能把新一批次的库存漏掉。5.2 抢房核心Redis Lua 原子扣减扣库存必须保证原子性。Redis Lua 脚本可以同时完成三件事判断用户是否重复申请、扣减库存、设置用户申请标记。脚本文件apply.lua-- KEYS[1]: room:stock:{roomId} -- KEYS[2]: user:apply:{userId}:{batchId} -- 返回值1-成功0-重复申请-1-库存不足-2-库存未初始化 if redis.call(EXISTS, KEYS[2]) 1 then return 0 end if redis.call(EXISTS, KEYS[1]) 0 then return -2 end local stock redis.call(DECR, KEYS[1]) if stock 0 then redis.call(INCR, KEYS[1]) return -1 end redis.call(SET, KEYS[2], 1) redis.call(EXPIRE, KEYS[2], 86400) return 1脚本里的逻辑顺序是有讲究的。先判断重复申请再判断库存是否初始化最后 DECR 扣减。如果库存不够要把 DECR 的结果加回来否则库存会变成负数下一批请求永远抢不到房间。抢房 ServiceComponent public class RoomApplyService { private static final String APPLY_SUCCESS 1; private static final String APPLY_DUPLICATE 0; private static final String APPLY_SOLD_OUT -1; private static final String STOCK_NOT_READY -2; Autowired private StringRedisTemplate redisTemplate; Autowired private RabbitTemplate rabbitTemplate; private DefaultRedisScriptLong applyScript; PostConstruct public void init() { applyScript new DefaultRedisScript(); applyScript.setScriptText(loadScript()); applyScript.setResultType(Long.class); } private String loadScript() { // 实际项目中从 classpath 读取 apply.lua return if redis.call(EXISTS, KEYS[2]) 1 then return 0 end if redis.call(EXISTS, KEYS[1]) 0 then return -2 end local stock redis.call(DECR, KEYS[1]) if stock 0 then redis.call(INCR, KEYS[1]) return -1 end redis.call(SET, KEYS[2], 1) redis.call(EXPIRE, KEYS[2], 86400) return 1 ; } public ApplyResult submit(ApplyRequest request) { String stockKey room:stock: request.getRoomId(); String userKey user:apply: request.getUserId() : request.getBatchId(); Long result redisTemplate.execute(applyScript, List.of(stockKey, userKey)); switch (String.valueOf(result)) { case APPLY_SUCCESS: // 扣减成功发送消息异步落库 request.setStatus(0); rabbitTemplate.convertAndSend( apply.exchange, apply.success, request ); return ApplyResult.pending(); case APPLY_DUPLICATE: return ApplyResult.duplicate(); case APPLY_SOLD_OUT: return ApplyResult.soldOut(); case STOCK_NOT_READY: return ApplyResult.error(批次或房间未初始化); default: return ApplyResult.error(系统繁忙); } } }这里真正要注意的是redisTemplate.execute的返回值类型。Lua 脚本通过return 0返回的数字是 Long所以DefaultRedisScriptLong的泛型必须写对。如果写成 String反序列化阶段容易出现类型转换异常。抢房 Controller 很简单RestController RequestMapping(/api/apply) public class RoomApplyController { Autowired private RoomApplyService roomApplyService; PostMapping(/submit) public ResultApplyResult submit(RequestBody ApplyRequest request) { // 实际项目中userId 从登录态获取不能信任前端传值 return Result.success(roomApplyService.submit(request)); } }需要特别强调一句生产环境中 userId 必须从网关解析的登录态或 Session 中获取不能由前端直接传入否则用户可以伪造身份帮别人抢房或刷接口。5.3 消息异步落库RabbitMQ 消费者负责把申请记录写入数据库Component public class ApplyConsumer { Autowired private RoomApplicationMapper applicationMapper; Autowired private RoomMapper roomMapper; RabbitListener(queues apply.queue) public void handleApply(ApplyRequest request) { // 1. 幂等校验库中是否已经存在记录 RoomApplication existing applicationMapper.selectByUserIdAndBatchId( request.getUserId(), request.getBatchId() ); if (existing ! null) { // 已经处理过直接返回 return; } // 2. 插入申请记录状态为成功 RoomApplication application new RoomApplication(); application.setApplicationNo(APP System.currentTimeMillis() request.getUserId()); application.setUserId(request.getUserId()); application.setBatchId(request.getBatchId()); application.setRoomId(request.getRoomId()); application.setStatus(1); application.setApplyTime(new Date()); application.setFinishTime(new Date()); applicationMapper.insert(application); // 3. 更新房间为已占用 roomMapper.lockRoom(request.getRoomId()); } }消费者这一步要做幂等不能假设消息只消费一次。RabbitMQ 在消费者处理超时、连接断开等场景下会重新投递消息如果没有幂等处理就会插入多条申请记录。RabbitMQ 的交换机、队列绑定配置Configuration public class RabbitConfig { public static final String APPLY_EXCHANGE apply.exchange; public static final String APPLY_QUEUE apply.queue; public static final String APPLY_ROUTING_KEY apply.success; Bean public TopicExchange applyExchange() { return new TopicExchange(APPLY_EXCHANGE, true, false); } Bean public Queue applyQueue() { return new Queue(APPLY_QUEUE, true); } Bean public Binding applyBinding() { return BindingBuilder.bind(applyQueue()) .to(applyExchange()) .with(APPLY_ROUTING_KEY); } }5.4 查询申请结果前端在收到“排队中”后需要轮询查询最终结果。设计一个简单接口先查 Redis 标记再查数据库保证最终一致性查询GetMapping(/result/{userId}/{batchId}) public ResultApplyResult getResult(PathVariable Long userId, PathVariable Long batchId) { // 先查 Redis 申请标记 String userKey user:apply: userId : batchId; String applyFlag redisTemplate.opsForValue().get(userKey); if (applyFlag null) { return Result.success(ApplyResult.error(没有找到申请记录)); } // 查数据库申请记录 RoomApplication application applicationMapper.selectByUserIdAndBatchId(userId, batchId); if (application null) { return Result.success(ApplyResult.pending()); } return Result.success(ApplyResult.success(application)); }这里的判断逻辑是Redis 有标记但数据库还没有记录说明消息还在队列里排队返回“排队中”数据库已经有记录说明落库完成可以返回最终状态。6. 运行验证与压测思路项目启动后先初始化一个房间库存# 登录 Redis给房间 1001 初始化库存为 1 redis-cli SET room:stock:1001 1然后模拟两个用户对同一个房间发起申请curl -X POST http://localhost:8080/api/apply/submit \ -H Content-Type: application/json \ -d {userId: 10001, roomId: 1001, batchId: 1} curl -X POST http://localhost:8080/api/apply/submit \ -H Content-Type: application/json \ -d {userId: 10002, roomId: 1001, batchId: 1}期望结果第一个请求返回pending状态码是排队中。第二个请求返回soldOut或duplicate取决于用户是否重复。查看 MySQL 表room_application只出现一条成功记录。如果需要模拟高并发建议用压测工具发送并发请求。这里以ab为例ab -n 1000 -c 100 \ -p apply.json \ -T application/json \ http://localhost:8080/api/apply/submit其中apply.json是请求体模板。压测时关注两个指标接口吞吐量Requests per second和平均响应时间Time per request。如果 Redis 和 MQ 正常大多数请求都会快速返回而不是堆积在数据库连接上。压测完成后检查 RabbitMQ 管理控制台apply.queue的待消费消息数量确认消费者的落库速度能跟上请求速度。这里还要提醒一点不要在未验证 Lua 脚本逻辑的情况下直接上生产。先在本地用低并发跑一遍确认库存不超卖、用户不重复再逐步放大压测压力。7. 常见问题与排查思路问题现象可能原因排查方式解决方案所有请求都返回“库存未初始化”Redis 的room:stock:{roomId}key 不存在或已过期执行redis-cli EXISTS room:stock:1001查看 key重新执行初始化任务确认批次开始前预热库存同一用户重复请求两次都成功Redis 用户标记 key 过期或 Lua 脚本没有设置用户 key查看 Redis 中user:apply:*是否写入延长 key 过期时间复查 Lua 脚本顺序库存显示还有余量但申请失败数据库唯一索引或房间状态更新冲突查看 MySQL 报错日志检查是否有重复记录以数据库唯一索引为准修正消费者幂等逻辑MQ 消息积压严重消费者处理速度慢或数据库连接池过小RabbitMQ 控制台查看队列积压数量增加消费者实例调大数据库连接池压测时 Redis 连接异常连接池配置过小查看 Redis 客户端日志调大spring.redis.lettuce.pool配置申请成功但数据库没有记录消息在 MQ 中未消费或消费者异常查看 RabbitMQ 死信队列查看消费者日志检查消费者是否有重试机制补充死信队列最容易踩坑的是 Lua 脚本的返回类型问题和库存 key 未初始化问题。很多团队在联调时才暴露这些问题原因是测试环境没有完整走一遍“初始化 - 申请 - 落库”流程。另一个隐藏问题是 Redis 和数据库的一致性问题。正常情况下Redis 扣减成功意味着申请成功但如果 MQ 消息丢失数据库可能没有记录。生产环境建议开启 RabbitMQ 发布确认和消费者手动 ACK同时定期对账 Redis 标记和数据库记录把不一致的数据捞出来。8. 最佳实践与工程建议8.1 库存预热与批次管理不要把库存 key 的初始化逻辑写在请求接口里。应该由后台任务或管理端在批次开始前完成预热并增加“批次状态”检查。比如批次状态为OPEN时才允许请求避免系统时间不一致导致提前抢跑。8.2 限流与防刷抢房接口天然会被恶意刷。接入层必须做限流一般用网关或 Sentinel 等组件配置令牌桶限制单个 userId 的 QPS。同时建议加上接口签名校验防止请求参数被篡改。8.3 数据库唯一索引不能省Redis 和 MQ 都可能出问题但数据库唯一索引是最终防线。uk_user_batch保证了同一用户同一批次只能有一条记录如果消费者处理了重复消息插入时会直接报错不会产生脏数据。8.4 消费者一定要幂等消费者从队列里取到消息后先查一次数据库是否已有记录再决定是否插入。不能依赖“RabbitMQ 只有一次投递”的假设。网络抖动、消费者阻塞、进程重启都会导致消息重投。8.5 监控与告警需要重点监控 Redis 内存和 key 数量、RabbitMQ 队列积压、消费者线程池活跃度、数据库连接池使用率。任何一个环节成为瓶颈都会直接体现在申请成功率上。8.6 关于真实业务中的公平性“申请挑战离门最近的宝藏房”这类场景最终用户关心的是公平。技术系统可以保证高并发下的一致性和可用性但批次开放时间、用户资格校验、房间分配规则这些业务规则同样重要。建议在系统设计阶段就把抢房规则写清楚比如同一批次一人只能申请一次、申请成功后取消是否释放库存、释放后其他用户能否捡漏。这些规则都会影响 Redis 和数据库的流程设计。8.7 安全边界这里要特别说明本文讲的是通用系统设计不是教大家写脚本去抢真实房源。生产环境中的选房、报名、抢课系统都应该通过官方入口和统一排队机制操作。绕过系统限制调用接口属于违规行为可能导致账号被封禁严重的还会涉及法律风险。技术能力应该用在设计更稳定、更公平的系统上而不是破坏规则。9. 文章总结与后续扩展方向回到最初的问题“离门最近的宝藏房”为什么会瞬间被抢光因为房间的唯一性限制了只能有一个成功者而大量请求在同一时刻涌入系统必须做到高效筛选。本文的落地方案可以概括为一句话用 Redis Lua 保证前置校验和扣库存的原子性用消息队列削峰用数据库唯一索引兜底幂等。这套链路并不复杂但每层都有明确职责单点故障不会直接导致数据错误。你可以继续往这几个方向扩展把 Redis 换成一个分片集群后Lua 脚本的 key 需要设计 hash tag保证同一脚本的 key 落在同一 slot。考虑引入 Redisson 的分布式锁应对更复杂的跨资源事务场景。申请成功后增加“取消申请”流程释放 Redis 库存和房间状态处理这一类的回退逻辑也需要保证原子性。如果业务需要严格按先后顺序分配可以在 Redis 里维护一个申请队列由消费者依次弹出处理实现公平排队。推荐下一步是先用本地环境把这个最小例子跑通然后写一个并发压测脚本观察 Redis、MQ、MySQL 三个节点各自的表现。你会发现瓶颈往往不在代码而在某个组件的默认参数配置。定位到瓶颈后再针对性调优这套系统就可以支撑真实业务了。