资讯动态

SpringBoot+RabbitMQ+Redis构建高并发秒杀系统:超卖问题与异步架构实战复盘

发布时间:2026/10/14 23:14:23 来源:尧图企业网站定制
第一次把压测报告扔到群里的时候库存 10 件QPS 冲到 5000订单表里落进去 215 条。群里安静了几秒然后炸了。超卖这个事没做过的觉得不就是扣库存嘛做过的才知道它是秒杀系统最核心的一道坎。那次事故之后我基于 SpringBootRabbitMQRedis 完整重构了一版秒杀系统Redis 扛住热点数据读取与库存预扣RabbitMQ 做异步下单削峰SpringBoot 负责组装业务流程同时把超卖、缓存穿透、消息丢失这几类问题挨个解决掉。这篇就是整个方案从设计到落地、从压测到踩坑的全过程复盘。内容偏实战适合正在做秒杀/抢购/限量活动系统的开发者参考也适合想搞懂异步架构到底怎么落地的同学。1. 一次压测事故超卖到底是怎么发生的先聊聊最开始那版为什么错得这么离谱。1.1 数据库行锁在高并发下并没有想象中可靠第一版实现几乎是所有人的直觉写法用户点秒杀按钮请求进来直接执行一条 SQL 扣库存然后插入订单。UPDATE stock SET sold sold 1, stock stock - 1 WHERE item_id #{itemId}; INSERT INTO order_record(...) VALUES (...);单独看这条 SQL 没问题stock stock - 1配合数据库的行锁同一时间只有一个事务能改这一行库存数据。问题在于秒杀场景下 5000 个并发请求同时进来这行库存数据就是全系统的单点锁。数据库连接池默认几十个连接5000 个请求排队等这一行数据的锁。等锁的时间越来越长下游的响应时间跟着暴涨连接池很快被占满其它业务全部被拖死。更要命的是如果某个事务在锁等待期间超时回滚库存可能少扣也可能没扣最终落库的订单数就会跟初始库存对不上。我后来用并行压测专门验证过500 个并发、库存 200 件直接走数据库扣减最终订单 183 单。不是超卖是少卖——大量请求在锁等待阶段超时失败用户看到的全是系统繁忙。1.2 加 JVM 锁为什么还是超卖第一版出问题之后我的第一反应是加锁。直接在扣库存方法上加了synchronized本地压测 100 并发超卖问题确实消失了。但部署到多实例环境之后超卖又回来了。原因今天看很简单synchronized锁的是单个 JVM 进程内的对象如果秒杀服务部署了 3 个节点每个节点各有一个自己的锁3 个节点同时放行 3 个请求去扣同一件商品的库存超卖照样出现。集群规模越大超卖量越大。这是分布式环境下的互斥问题不是 JVM 锁能管的。后来换成了 Redis 分布式锁用SETNX抢锁扣库存、写订单都放到锁里执行。1000 并发压测确实没有超卖但 QPS 上不去——所有请求串行排队抢锁抢到锁的做完整套数据库操作才释放性能还不如数据库乐观锁直接更新来得快。分布式锁适合低频关键操作不适合秒杀这种高频热点操作。1.3 乐观锁方案的实际表现再后来尝试了乐观锁思路SQL 改成条件更新UPDATE stock SET stock stock - 1 WHERE item_id #{itemId} AND stock 0;stock 0这个条件保证了库存不会扣成负数即使极端情况下两个事务同时读到stock 1最终也只有一条UPDATE能执行成功。这个方案在单体应用里完全没有超卖问题但并发一高数据库行锁排队依旧存在连接池被占满只是时间问题。我把几个方案的数据摆在一起对比了一下心里大概有了数方案是否解决超卖500 并发下的压测表现主要瓶颈裸 SQL 扣库存否少卖 17%大量超时锁等待 连接池耗尽JVM 锁仅单节点有效单机 0 超卖集群超卖锁不跨进程Redis 分布式锁是0 超卖但 QPS 极低串行化太严重数据库乐观锁是0 超卖响应时间开始恶化数据库行锁集中核心思路也在这轮对比中明确了数据库扣减这条链路无论如何优化最终都要落在同一行库存数据上锁竞争。要做的是把大量直接打数据库的请求拦截在更靠前的位置让数据库只处理真正秒杀成功的少量请求。这就是后面要讲的 Redis 预扣减 MQ 异步落库的雏形。2. 选型逻辑为什么是 SpringBootRabbitMQRedis很多项目选型是别人用什么我就用什么但秒杀系统选型必须回到业务特征上来推导。2.1 秒杀访问模型的两个核心矛盾秒杀流量和普通业务流量最大的区别在于瞬间性。普通接口 QPS 可能是几百秒杀开场那一秒可能冲到几千甚至几万但持续几十秒后就回落到正常值。这种流量模型带来两个矛盾第一个是读的放大。用户点开商品详情页、反复刷新确认库存一个真实下单用户背后通常有 5~10 次查询请求。秒杀页面上的剩余库存数、商品名称、倒计时这些数据都是典型的读多写少热点。这些查询如果全部穿透到数据库数据库瞬间就垮了。第二个是写的集中。真正点秒杀按钮的请求集中在开场后 1~3 秒内而且每个请求都涉及到一次库存扣减和一次订单插入。数据库的行锁是有限的连接池是有限的磁盘 IO 是有限的。让所有写请求直接怼到数据库就是在跟硬件的物理极限对抗。2.2 Redis 承担的角色流量挡板与数据缓冲Redis 在这套架构里的定位是挡在数据库前面的流量挡板。库存预热到 Redis 之后判断还有没有库存就不再需要访问数据库。Redis 单实例能支撑十万级 QPS热点 key 扛几千并发毫无压力比数据库至少高两个数量级。但 Redis 不能单独解决超卖关键是扣减动作要保证原子性。这也是后面为什么要用 Lua 脚本而不是 GETSET 两步操作的原因。另外 Redis 还负责缓存商品详情等热点数据把读放大问题也一并消化掉。Redis 的一个显著好处是数据结构丰富。库存用 String 类型存字符串数字订单幂等判断用 String 的 SETNX用户是否已抢购过用 Set 或 String 标记。这些操作都能配合 Lua 脚本在一个原子块里完成不用为了先查后改这种非原子操作费心思。2.3 RabbitMQ 承担的角色削峰填谷与异步解耦Redis 已经确认了有资格下单的请求但确认之后也不能直接写数据库。5000 个请求有资格下单数据库同时处理 5000 条插入照样扛不住。这里就要 RabbitMQ 出场了。请求确认有库存之后把创建订单这件事封装成消息丢到 MQ 里接口立刻返回秒杀排队中。后端消费者按照数据库能承受的速度比如每秒几百条从队列里拉消息慢慢落库。这样就把秒杀瞬间的流量高峰拉平了数据库看到的是一波平稳的写入流量而不是脉冲式冲击。这种做法的本质是把用户可感知的响应时间和系统真实的处理时间拆开。用户不需要等服务端把订单真正创建完才得到结果只需要知道系统收到我的请求了后续结果通过轮询或通知拿到。用户侧感知的耗时是毫秒级系统侧的处理可以慢慢完成。2.4 SpringBoot 的定位组装与业务编排选定 Redis 和 MQ 之后SpringBoot 几乎是顺理成章的骨架。它本身不直接解决高性能问题但提供了成熟的自动配置和 starter 机制把 Redis、MQ、数据库连接池这些组件粘合在一起。第二版落地时RedisTemplate 和 RabbitTemplate 通过配置类注入即可使用业务代码只需要关注编排逻辑先调 Lua 脚本扣库存成功就发 MQ失败就直接返回已抢光。选型时要注意一个容易忽略的问题SpringBoot 版本与中间件客户端的兼容性。我用的是 Spring Boot 2.x 系列对应 spring-boot-starter-data-redis 和 spring-boot-starter-amqpRedis 客户端选择 Lettuce 而非 Jedis——Lettuce 底层基于 Netty在高并发下的连接复用能力比 Jedis 好不少。这套组合的分工边界很清晰SpringBoot业务编排、接口暴露、配置管理Redis热点缓存、库存预扣、幂等标记RabbitMQ异步下单、流量削峰、消息可靠投递三个组件各管一段互不越界。后面每一层都有单独的设计细节和坑要处理。3. Redis 侧的库存设计与缓存防线这层是整个秒杀系统的命门。库存数据在 Redis 里扣减也在 Redis 里所有并发流量都打到这里任何一个细节出错下游全部被带崩。3.1 库存预热把库存从数据库搬到 Redis秒杀开始前需要通过一个后台接口把数据库里的库存数据同步到 Redis。我在模拟项目 X 里提供的是一个SeckillStockPreheatService扫描当天有秒杀活动的商品逐条写入 Redispublic void preheat(Long seckillActivityId) { ListSeckillItem items selectItemsByActivity(seckillActivityId); for (SeckillItem item : items) { String stockKey seckill:stock: item.getItemId(); stringRedisTemplate.opsForValue().set(stockKey, String.valueOf(item.getStockCount())); // 设置活动结束时间作为 key 的过期时间防止活动结束后残留脏数据 stringRedisTemplate.expire(stockKey, Duration.between(LocalTime.now(), item.getEndTime())); } }预热这个动作看似简单有一个细节必须想清楚这里是初始化而不是覆盖。如果秒杀开始后 Redis 里库存已经被扣了一部分此时误触发了预热接口把库存重新 set 成初始值就等于给所有用户发了一次库存重置福利后果比超卖还严重。所以预热接口要加活动状态校验只有活动处于未开始状态才允许执行活动已经开始或已经结束都直接拒绝。3.2 Lua 脚本扣库存必须原子完成库存扣减如果拆成读取 - 判断 - 扣减三步任何一个环节并发穿插都可能出错。Redis 是单线程模型但单线程只保证单个命令原子不保证命令序列原子。两个请求同时读到库存为 1然后各自执行扣减最终库存变成 -1这就是超卖。正确做法是把整个扣减逻辑写进一个 Lua 脚本让 Redis 以原子方式执行整个脚本-- KEYS[1]: seckill:stock:{itemId} -- KEYS[2]: seckill:user:{userId}:{itemId} 用于幂等标记 -- ARGV[1]: 购买数量 if redis.call(exists, KEYS[2]) 1 then return -1 -- 该用户已经秒杀过直接拒绝 end local stock tonumber(redis.call(get, KEYS[1])) if stock nil then return -2 -- 库存 key 不存在活动未预热或已结束 end if stock tonumber(ARGV[1]) then return 0 -- 库存不足 end redis.call(decrby, KEYS[1], ARGV[1]) redis.call(set, KEYS[2], 1) redis.call(expire, KEYS[2], 86400) return 1 -- 扣减成功这段脚本把检查用户是否已抢过检查库存是否充足扣减库存记录用户已秒杀四个动作合并为一个原子操作。脚本整体执行期间Redis 不会插入任何其他命令从根上消灭了并发窗口。SpringBoot 侧执行脚本用DefaultRedisScriptResource private StringRedisTemplate stringRedisTemplate; private static final DefaultRedisScriptLong SECKILL_SCRIPT new DefaultRedisScript( loadScript(lua/seckill.lua), Long.class ); public SeckillResult trySeckill(Long userId, Long itemId) { ListString keys Arrays.asList( seckill:stock: itemId, seckill:user: userId : itemId ); Long result stringRedisTemplate.execute(SECKILL_SCRIPT, keys, String.valueOf(1)); // result: 1 成功0 库存不足-1 重复秒杀-2 活动未开始或已结束 ... }这里需要提醒的是Lua 脚本里不要使用redis.call(time)或redis.call(randomkey)这类非确定性命令。Redis 主从复制模式下这类命令在不同节点上的执行结果可能不一致会导致主从数据错乱。我在早期版本里想在脚本里加个时间戳字段做记录用了redis.call(time)压测时主从切换后出现了库存数据不一致排查了很久才发现是这个问题。3.3 热点 key 设计与 TTL 优化秒杀系统的缓存 key 主要分三类seckill:stock:{itemId}库存数热点最高读写频繁seckill:item:{itemId}商品详情 JSON读多写少seckill:user:{userId}:{itemId}用户秒杀标记防止重复秒杀这三类 key 的过期时间设计完全不同。库存 key 应该跟随活动结束时间过期不能设置固定短 TTL否则活动没结束库存 key 就没了所有请求都会走活动未开始分支。商品详情 key 可以设置几分钟级别 TTL秒杀商品的价格、名称在活动中一般不变短 TTL 是为了配合后台修改能尽快生效。用户秒杀标记 key 的 TTL 设长一些至少覆盖整个活动周期防止用户活动期间通过换账号或清缓存的方式反复秒杀。给 key 加前缀是我强烈建议的做法。我们早期 key 直接叫stock:123后来数据显示 Redis 里竞品活动、内部测试、其他业务模块的 key 混在一起排查问题时根本分不清。统一前缀seckill:之后redis-cli --scan --pattern seckill:*一条命令就能看清所有秒杀相关的缓存数据。3.4 缓存穿透、击穿、雪崩的三层防护热点缓存如果处理不好Redis 挡不住流量压力就会传导到数据库。这三个缓存杀手在秒杀场景里碰到的概率极高值得单独说说。缓存穿透用户恶意请求一个不存在的商品 IDRedis 里查不到请求直接打到数据库。秒杀活动页如果接口地址被遍历大量无效请求会直接穿透 Redis。处理方案是布隆过滤器 空值缓存。我在项目里用的是空值缓存——查不到商品时在 Redis 写一个seckill:item:{itemId} nullTTL 设为 60 秒后续同样 ID 的请求在 60 秒内直接返回商品不存在不再打到数据库。缓存击穿某个热点商品详情 key 在过期瞬间大量并发请求同时发现缓存不命中全部冲进数据库。秒杀场景最典型的就是那个被万人围观的爆款商品。常见解法是互斥锁缓存不命中时先尝试获取分布式锁只有拿到锁的请求才允许查数据库并回填缓存其他请求短暂 sleep 后重试读缓存。还有一种改动更小的方案是逻辑过期——缓存不设置 TTL而是把过期时间放在 value 里查询时发现逻辑过期就返回旧数据同时在后台异步更新。两种方案取舍在于互斥锁实现简单但会短暂阻塞请求逻辑过期不阻塞但要求接受短时间的脏读。缓存雪崩大量 key 在同一时间过期比如活动开始时统一写入的一批商品详情 key 同时失效瞬间所有请求都落到数据库。解法很简单也很实用TTL 加随机偏移量。long baseTtl 300; // 5分钟 long randomTtl baseTtl ThreadLocalRandom.current().nextLong(0, 120); stringRedisTemplate.expire(key, Duration.ofSeconds(randomTtl));三条防线建立之后我又做了一轮针对性的缓存攻击压测场景一是不存在商品 ID 的遍历请求场景二是热点商品详情 key 手动过期瞬间的并发请求场景三是批量 key 同时到期的极端情况。数据库的查询 QPS 从重构前的每秒 3000 降到了每秒 200 以下Redis 挡掉了绝大多数无效流量。4. RabbitMQ 侧的异步下单链路Redis 把谁能买的问题解决了接下来要解决怎么把订单稳定准确地落库的问题。这个环节用 RabbitMQ 做异步化核心是三件事削峰、可靠、幂等。4.1 为什么不能扣完库存就直接写库第一版系统在 Lua 脚本扣减库存成功之后直接在 Controller 里同步执行创建订单、扣减数据库库存接口要等全部数据库操作完成才返回。结果压测时一个很明显的问题接口响应时间从 5ms 一路飙升到 3 秒数据库连接池被打满连接等待超时成片出现。原因不复杂数据库单条 INSERT 在高并发下本来就是瓶颈再加上 MySQL 的行锁竞争、事务提交刷盘5000 个请求同时写库数据库物理处理能力直接被耗尽。接口慢还是其次连接池被占满之后其他非秒杀业务也会跟着不可用这等于一次秒杀把整个服务拖垮。异步化的改造方向是Controller 里只做两件事——执行 Lua 脚本扣库存扣减成功就发一条 MQ 消息然后立刻返回排队中。真正的订单创建逻辑放到 MQ 消费者里慢慢执行。接口的耗时从数据库操作时长的量级降到了 MQ 发送时长的量级压测从 3 秒直接降到十几毫秒。4.2 消息模型设计谁发消息、发什么消息我先说一个容易踩的坑不要把整套订单对象序列化后扔进 MQ。订单对象里可能包含订单明细列表、收货地址、用户对象等一堆嵌套数据序列化成本高消费者反序列化时只要有一个字段对不上就消费失败而且消息体过大还会拖慢 MQ 的吞吐。我最终选择的方案是发最小必要信息。消息体只包含{ userId: 1024, itemId: 99, seckillActivityId: 20250101, orderToken: uuid生成的一次性标识 }消费者拿到消息后根据userId和itemId回查 Redis 里的秒杀标记确认该用户确实在 Redis 层扣减成功再自行组装订单明细、计算价格、插入数据库。这样做还有一个好处消费者具备自校验能力就算 MQ 消息被伪造或重复投递也能通过 Redis 标记拦截。队列和交换机我用的是如下拓扑直连交换机seckill.order.exchange路由键seckill.order.create下单队列seckill.order.queue死信交换机seckill.order.dlx.exchange死信队列seckill.order.dlq.queue正常消息消费成功就手动 ACK消费失败且重试达到最大次数就投递到死信队列由专门的后台任务分析失败原因。死信机制后面在踩坑章节会详细展开。4.3 消息可靠投递确认机制与手动 ACKRabbitMQ 的可靠性是异步下单方案必须守住的一条线。消息如果丢失用户在前端看到排队中后端却永远不创建订单这个体验等于直接丢失一个订单。消息投递链路有几个环节都可能丢消息生产者发送到交换机失败交换机路由到队列失败消费者处理成功之前进程崩溃对应的方案分别是生产者端开启publisher-confirm机制发送消息后回调确认是否送达交换机。没有收到确认回调的消息记录到本地重试表由定时任务重新发送。spring: rabbitmq: publisher-confirm-type: correlated publisher-returns: true消费者端使用手动 ACK消息处理成功才调用basicAck处理失败且需要重试则调用basicNack并设置requeuefalse或稍后重新投递。关键点在于不要用自动 ACK 模式。自动 ACK 下消费者刚收到消息还没开始处理连接异常断开RabbitMQ 以为消息已经送达并从队列中移除这条订单就永久丢了。我遇到的更隐蔽的问题是消费成功但业务处理一半崩溃。比如订单表插入成功但用户秒杀标记更新失败此时如果 ACK 了这条消息不会再投递但用户侧的标记没写对当用户再次疯狂点秒杀按钮时会发现又扣了一次库存。解决思路是按照最终一致性来设计创建订单和写用户标记放在同一个本地事务里要么全部成功要么全部回滚都成功才 ACK。4.4 消费端幂等防止消息重复投递MQ 的投递语义是 at-least-once也就是消息至少送达一次可能重复送达。消费者如果不对重复消息做处理一条订单消息被投递两次数据库里就会插入两条订单记录这本质上是另一种形式的超卖。幂等处理我采用了 Redis 标记 数据库唯一索引双保险第一道防线是 Redis 的seckill:user:{userId}:{itemId}标记。消费者处理消息前先查询这个标记是否还在如果在说明 Redis 层已经标记成功但这一步只能说明用户确实秒杀成功过无法区分当前这次消费是否重复。第二道防线是关键——数据库订单表给(user_id, item_id, seckill_activity_id)建唯一索引ALTER TABLE seckill_order ADD UNIQUE KEY uk_user_item_activity (user_id, item_id, seckill_activity_id);消费者执行 INSERT 时如果触发DuplicateKeyException说明这条订单已经存在直接视为消息已消费执行 ACK。这样即便消息被重投 10 次数据库也永远只会有一条订单记录。这两道防线缺一不可。只靠 Redis 标记Redis 和数据库之间总有时差窗口只靠数据库唯一索引消息要打到数据库才能发现重复浪费了数据库资源。双保险的设计让重复消息在 Redis 层就能拦截大半漏网之鱼在数据库层被最终兜住。4.5 消费端并发与数据库写入速率控制异步下单消费者必须关注两个指标消费速率和数据库写入速率。如果消费者并发开得太大每秒从 MQ 拉几万条消息然后全部并发写库数据库的压力跟同步写没什么区别削峰就失败了。我在项目里给消费者做了速率控制单队列的消费者并发数设置为3每个消费者通过ThreadPoolExecutor控制内部线程数整体落库速率限制在每秒 500 条以内。同时消费者内部每次批量从 MQ 拉取消息攒到一定数量后用 JDBCrewriteBatchedStatements批量插入订单单条 INSERT 变成批量 INSERT数据库写入吞吐提升非常明显。这里要提一个批量插入被很多人忽略的细节MySQL 的批量 INSERT 必须开启rewriteBatchedStatementstrue才能真正走批量执行路径否则 JDBC 驱动会逐条发送 SQL性能提升聊胜于无。我在压测中发现这个参数前后批量插入耗时相差近 5 倍排查了半天才找到原因。5. 链路串起来之后的完整流程与压测数据Redis 和 MQ 的设计都到位之后整个秒杀流程就变成了一条完整的流水线。这一章把完整时序和数据表现整理出来。5.1 从用户点击到订单落库的完整流程用户点击秒杀按钮后请求经过的完整链路如下用户请求进入秒杀接口校验基础参数商品 ID、活动 ID、用户身份Controller 调用 Lua 脚本原子执行检查用户是否已秒杀过如果是直接返回重复秒杀检查库存 key 是否存在如果不存在返回活动不存在或未开始检查库存是否充足不充足返回已抢光扣减库存、标记用户已秒杀返回扣减成功扣减成功后将最小消息体发送到 RabbitMQ 交换机接口立即返回秒杀排队中请稍后查看结果RabbitMQ 消费者从队列拉取消息做幂等校验后创建订单订单落库成功更新用户抢购状态用户通过轮询接口查询秒杀结果拿到恭喜抢购成功或很遗憾库存不足这个流程最大的特点是把耗时最大的环节订单落库从用户请求链路上剥离出去。用户感知的响应时间只包含 Lua 脚本执行 MQ 消息发送两部分通常在 10~20 毫秒以内。后端订单创建的耗时完全不对用户暴露用户只需要等几秒再查结果即可。5.2 压测数据对比同步方案与异步方案我针对秒杀接口做了三轮压测每轮用同一台服务节点、同等配置压测工具统一使用命令行模式模拟 2000 个并发用户同时点击秒杀按钮。三组数据对比指标同步写库方案乐观锁 同步响应Redis 预扣 MQ 异步接口平均响应时间958ms642ms12ms接口最大响应时间3421ms1780ms86ms数据库峰值 QPS42113560520超卖数量11 单0 单0 单成功下单链路完成率82.5%91.3%99.7%数据库峰值 QPS 是这轮对比里最有价值的数字。同步方案下数据库被 4000 QPS 猛捶异步方案下数据库只需要稳定承接 520 QPS 的写入这个量级对普通配置的 MySQL 完全没有压力。削峰的效果一目了然。5.3 第二轮压测暴露的新瓶颈Redis 热点 key 倾斜异步方案第一版压测结果看起来很好但把并发提升到 5000 时出现了一个新问题某个爆款商品的库存 key 所在分片的 CPU 使用率飙到 95%其他分片却只有 20%。这就是热点 key 倾斜。原因在于我的库存 key 是单 key 设计所有并发流量都打在同一个 Redis key 上。Redis 虽然是单线程但单个 key 的读写操作还是会被集中调度到同一个分片上。解决思路有两个方向热 key 拆分或本地缓存。热 key 拆分操作成本较低将库存拆成 N 份比如 5000 件库存拆成 10 个 key每个 key 存 500 件Lua 脚本随机选择一个子 key 扣减-- 扣减时先随机选一个子 key local subKey KEYS[1] .. :s .. math.random(0, 9)这个方案的代价是单件商品的库存分布到了多个 key 上判断总库存是否充足变得不那么精确——极端情况下可能出现部分子 key 有库存、部分已耗尽但总体仍有剩余却无法下单的假象。适合秒杀这类库存精确度要求没那么高的场景但不适合库存精确扣减的场景。我后来实际采用的是本地缓存方案把爆款商品的是否有库存状态通过定时任务同步到 JVM 本地缓存接口处理时先查本地缓存如果显示已抢光直接返回失败不再访问 Redis。本地缓存不影响库存扣减的准确性只是充当一道前置挡板降低 Redis 热 key 的压力。压测数据显示此改动让 Redis 分片 CPU 使用率从 95% 降到了 60% 左右。6. 上线前后踩过的坑与完整排查链路每个技术方案在落地过程中都会暴露问题。挑几个我印象最深、排查耗时最长的问题把完整排查链路写出来比直接给结论更有价值。6.1 Redis 宕机后库存漂移降级策略设计某个周五下午秒杀活动正在运行Redis 所在节点因内存碎片触发重启预热在 Redis 里的库存数据全部丢失。恢复之后我做的第一件事是重启服务结果 Redis 里库存 key 不存在所有请求都返回活动不存在用户全部秒杀失败。排查链路是先检查 Redis 连接是否正常确认连接正常后查询seckill:stock:*发现 key 全部消失定位到 Redis 重启导致未持久化的数据丢失。再检查持久化配置发现部署时只开启了 RDB 快照且快照周期设置为 5 分钟一次秒杀期间库存数据一直在变重启前最后一次快照已经是几分钟前的状态恢复之后库存数与数据库已扣减数对不上。修复方案分两步进行。第一步启动一个恢复程序以数据库实时库存为基础重新生成 Redis 库存 key。第二步修改 Redis 持久化配置秒杀场景下开启 AOF 持久化appendfsync everysec保证最多丢失一秒的数据。这两步做完之后还有一个隐患Redis 重启到恢复完成的这段时间内秒杀接口是直接拒绝还是降级我最终选择了降级增加一个开关Redis 不可用时自动切换到数据库乐观锁方案用性能换可用性保证用户请求不失败。虽然数据库扛不住那么大流量但短时间的降级能避免用户投诉活动莫名消失等 Redis 恢复后再切回主方案。6.2 消息消费失败导致的重复投递循环某次压测中我观察到 RabbitMQ 的队列长度一直居高不下打开管理台看到大量消息在ready和unacknowledged状态之间反复横跳。查看日志发现消费者一直在抛异常异常信息是数据库唯一键冲突。这个现象看起来矛盾唯一键冲突明明说明订单已经存在消费者应该按已消费处理并 ACK但我的代码里却把唯一键冲突当作系统异常来处理触发了basicNack并设置requeuetrue。消息被退回队列头部再次投递给消费者再次冲突周而复始形成死循环。修复方式很简单却很关键在消费逻辑中显式捕获DuplicateKeyException并把它当作业务成功来处理执行 ACK。这个坑的教训是消费者代码里的异常分类要明确哪些异常该重试哪些异常该确认要靠业务背后的数据约束来判断不能一刀切地 catch 后重试。6.3 核心链路的超时配置引发的连锁问题项目上线后遇到过一个诡异的现象秒杀活动刚开始的 30 秒内一切正常30 秒后突然出现大量系统繁忙错误且数据库连接池告警。排查链路比较长我按时间线梳理了完整过程。初始状态Redis 层 Lua 脚本扣减成功 - RabbitMQ 消息发送中 - 接口等待 MQ 确认回调 - 用户收到响应。排查发现生产环境开启了 publisher-confirm 回调但发送消息时没有指定合适的correlationData。RabbitMQ 的 confirm 回调是异步的当并发量升高时回调处理线程池被占满确认响应处理不及时接口侧因为用了同步等待确认结果的方式大量线程阻塞在等待确认状态线程池耗尽后新请求直接拒绝。解决思路是把同步等待改成异步通知接口发送消息后不等待 confirm 回调直接返回排队中。消息是否投递成功由单独的后台任务定期比对 MQ 消息发送记录和 confirm 回调记录发现未确认的消息重新发送。整个过程用户无感知接口响应时间进一步缩短。这也是异步架构的一个核心收益把强一致性的发送必须成功要求放宽为发送最终成功用后台补偿替代同步阻塞等待。6.4 数据库最终一致性校验兜底对账任务异步链路跑了一段时间后我意识到一个问题消息可能丢失Redis 可能丢数据唯一索引可能漏建所有保障措施单独看都没问题但系统作为一个整体总会有意想不到的边角漏洞。我需要一个兜底机制来校验最终结果。于是设计了每日对账任务每天凌晨扫描秒杀活动数据比对三个数据源的记录——Redis 中的扣减记录、RabbitMQ 的消息发送记录、数据库中的订单记录。任何一条记录在三个数据源中出现不一致就触发告警并输出差异详情由人工或自动补偿任务处理。这个对账任务上线第二周就发现了一次库存漂移数据库订单量为 85 单Redis 扣减记录却有 88 条多出的 3 条是压测过程中手动 restart 消费者进程导致 Redis 标记写入成功但没有实际落库。如果没有对账任务这个问题会在用户侧表现为抢购成功但订单不存在到那时候再排查成本就高多了。对账这类兜底机制在秒杀系统里不是可选项而是必选项。异步链路越长中间环节越多数据不一致的概率越高兜底对账就是给这条异步链路装上最后一道安全锁。7. 最后补几个实战层面的经验总结整条链路从最初裸 SQL 扣库存到最终全异步化重构我个人的体会是秒杀系统的技术选型和方案设计本质上是把并发问题转化为顺序问题再转化为最终一致性问题的过程。Redis 用单线程和 Lua 脚本把并发扣减变成了原子操作MQ 用队列把并发写入变成了顺序消费对账任务把短期的数据不一致用长期的最终一致来兜底。每一步转换都在降低系统某个维度的复杂度。有几个具体经验值得记下来第一秒杀系统不要一开始就堆高可用组件。先解决超卖和数据一致性再加缓存再上异步化每一步都有可验证的压测数据支撑出了问题能快速定位到引入的组件。第二异步链路中的每个环节都要有可观测性。我后来在消息发送、消费开始、消费成功、消息失败这几个节点都加了结构化日志配合消息 ID 全链路追踪。排查问题时能直接按消息 ID 查到这条消息从发送到落库的完整轨迹否则异步问题排查就像大海捞针。第三压测一定要覆盖故障场景不能只测正常流程。Redis 宕机、MQ 断连、数据库连接池耗尽这三个故障场景压一遍系统能不能优雅降级、能不能恢复答案立刻见分晓。任何只在正常场景下成立的设计放到生产环境都可能被打穿。最后再分享一个小技巧秒杀接口的参数里建议增加一个前端生成的请求唯一编号。这个编号在 Redis 层、MQ 消息体里、订单表里都带一份既能用来做整套链路的追踪也能在前端重复提交时作为幂等依据。用户疯狂连点时同一个编号的请求在 Redis 层就会被拦截后端根本感知不到重复压力。这个技巧实现成本极低但对用户体验和系统压力的改善都很明显。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑