1、立项背景与目标:面向限量票务秒杀场景,活动开始瞬间大量用户同时抢票,直接写数据库会造成行锁竞争和连接池打满,同时容易出现超卖、掉单(库存已扣但订单未生成)和重复下单。项目目标是让抢票热路径不访问 MySQL,通过 Redis 原子扣减保证不超卖,通过异步落库削峰,并用幂等和唯一索引保证不重复下单。
2、功能与核心模块:活动侧支持库存预热(默认只在 key 不存在时初始化,避免售卖中重置库存)和活动详情缓存预热;用户侧提供幂等 Token 获取、抢票、活动详情查询和订单列表查询。后台模块包括 Outbox 消息投递、订单异步落库、失败重试与死信落库、Redis 与数据库库存定时对账。
3、业务流程:用户先调用 token 接口获取一次性幂等 Token(60 秒有效),再携带 Token 调用抢票接口。请求依次经过滑动窗口限流、参数校验、Token 原子校验与删除,然后执行一次 Redis Lua 脚本,在脚本内完成活动状态与售卖时间窗口校验、库存判断与扣减、待发消息写入 Redis 发件箱,返回剩余库存后立即向用户返回抢票成功。随后由后台投递线程把消息发往 RabbitMQ,消费者在事务中写入订单和扣减记录并回写数据库库存,用户可通过订单接口查询最终结果。
1、整体架构与技术栈:采用 Spring Boot 3.2 + MyBatis-Plus + Redis 7 + RabbitMQ + MySQL 5.7 的单体分层架构,核心思路是“同步链路极短、异步兜底”。Redis 承载库存计数、活动元信息、幂等 Token、限流窗口和 Outbox 发件箱;RabbitMQ 负责削峰与异步落库;MyBatis-Plus 负责订单、扣减记录和活动表持久化;Redisson 仅用于低频的缓存重建加锁。
2、负责模块与结果:独立完成抢票主流程、Outbox 投递、消费端重试与死信处理、幂等、缓存组件和限流模块。量化结果:本机 200 并发 1.99 秒完成、0 失败;活动总库存 300、售出 201,Redis 剩余 99 与数据库 remain_stock 99 一致;订单数、唯一 batch_no 数、扣减记录数均为 201;同一条消息并发投递 6 次只落库 1 条;毒消息重试 3 次(间隔 5 秒)后进入死信并落库,队列无空转。
3、难点与解决方案:
(1)超卖:把“读库存、比较、扣减”拆开写会有网络往返窗口,并发请求都读到旧值。改为放进同一个 Lua 脚本,利用 Redis 单线程执行脚本的原子性消除窗口,并把活动状态与时间窗口校验也下沉到脚本,热路径不查库。
(2)掉单:扣库存和发消息分属两个系统,中间崩溃会出现“扣了库存但消息没发”。把待发消息与扣减放在同一个脚本里写入 Redis List;投递线程用 RPOPLPUSH 原子搬到 processing,Publisher Confirm 确认后才删除,空闲时回收崩溃残留。
(3)重试不生效:basicNack(requeue=true) 不会写 x-death,基于它的重试上限永远不触发,消息回队首还会造成空转。改为确认原消息后投递到 5 秒 TTL 延迟队列,用自定义消息头累计次数,超过 3 次转死信并落库 ticket_order_fail。
(4)重复落库:消息是 at-least-once,先查再插在并发下拦不住。订单表 batch_no 建唯一索引兜底,并把落库事务隔离级别降到 READ_COMMITTED,避免 RR 快照看不到刚提交的订单、把幂等命中误判成订单冲突。