ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Redis预扣减+RabbitMQ异步解耦,秒杀系统延迟与一致性实战方案

Redis预扣减+RabbitMQ异步解耦,秒杀系统延迟与一致性实战方案 项目标题深度解析Redis 预扣减与 RabbitMQ 异步解耦如何完美平衡延迟与一致性购物节零点一过几十万用户同时点抢购按钮我的请求直接冲进了后端。如果让所有请求都去怼数据库订单表大概率瞬间锁死。这是很多团队第一次意识到“延迟”和“一致性”有多难兼顾的瞬间既要让用户觉得“点下去立刻有反馈”又不能让数据乱账、库存超卖。我当时的方案就是用 Redis 做预扣减、用 RabbitMQ 做异步削峰把一条链路上的矛盾拆给不同组件去扛。这篇文章把整个设计思路、踩坑过程、参数取舍一次讲透适合正在搭建秒杀、抢购、库存敏感业务的后端开发。先说结论Redis 管“手速”RabbitMQ 管“削峰”最终一致性靠“补偿”。没有绝对完美只有把每个环节的不一致窗口缩到足够小再把兜底补偿做好。1. 延迟与一致性的矛盾根源为什么传统的“先查库存再下单”会崩1.1 同步链路下的恶性循环一开始我们的订单接口长这样用户请求进来先查 MySQL 库存库存 0 就减库存、生成订单、返回成功。这套逻辑在低并发下毫无问题问题在于高并发下的行为特征完全不同。假设你有 10 万并发同时进来数据库连接池默认配置可能只有 50 个连接。MySQL 的 InnoDB 在更新同一行库存时会加行锁这意味着其他请求必须等前面的事务提交才能继续。你可以想象一条单车道10 万辆车同时驶入后面的车只能原地等着。结果就是接口延迟从 5ms 直线上升到 3 秒、5 秒最后直接超时报错。用户端表现为“页面一直转圈”然后反复点击重试请求反而把系统打得更死。更糟糕的是用户等待过程中产生的重试请求会进一步堆高数据库的查询压力形成恶性循环。这时候你再去看 MySQL CPU 和连接数基本已经爆了。1.2 缓存能解决读压力但写路径依然是瓶颈有些人会说“那我在 Redis 里缓存库存数量先查 Redis 不就行了”确实这能把“查库存”的读压力从 MySQL 转移到 Redis。库存量作为热数据Redis 单实例 QPS 可以达到 10 万读这块完全不是瓶颈。但有三个问题没解决扣减库存仍然是写操作直接在 MySQL 里 UPDATE热行锁冲突依旧存在从 Redis 查到库存到 MySQL 扣减成功中间存在时间差并发下会出现多个请求都查到“有库存”实际扣减时库存已经没了缓存和数据库的一致性维护本身就是个麻烦事。这两个问题叠加就会陷入“查询很快、扣减超时、数据还是对不上”的尴尬局面。1.3 我的解法思路把“扣减动作”前移到 Redis把“写库动作”异步化真正关键词是“预扣减”。思路是把库存扣减从同步写 MySQL改成先扣 Redis 里的库存缓存同时把“生成订单”的任务投递到 RabbitMQ 队列。用户请求只要 Redis 扣减成功立刻返回“正在排队/下单成功”而真正的订单创建、库存落库、支付状态更新全部交给 MQ 的消费者慢慢处理。这套链路下请求的响应速度不再受 MySQL 负载影响。只要 Redis 扣减成功不管后面 MySQL 有多慢用户端都能在几十毫秒内拿到结果。而大量请求被 MQ 削峰后消费者服务可以按自己的消费能力批量处理数据库压力被压在一个可控的区间。当然这里有个大坑必须想清楚Redis 扣减成功不代表订单一定创建成功。如果消费端异常出现了极端情况库存会被白白扣掉或者出现超卖。后面要说的“一致性与补偿”就是专门解决这个问题的。2. 预扣减架构整体设计一张图看清核心链路2.1 核心组件划分我先画一条逻辑链路不涉及具体代码只讲数据流向用户请求 - 网关/负载均衡 - 订单服务 - Redis预扣减 - 成功则投递MQ - 立即响应 - 失败则返回“库存不足” 消费端MQ队列 - 订单服务异步创建订单写MySQL - 状态确认 - 回调通知链路分三段入口同步段用户请求进来后只做两件事——校验用户身份和调用 Redis 预扣减。这段必须短平快尽最大努力降低延迟。异步解耦段预扣减成功后把订单消息写入 RabbitMQ。消息里携带商品ID、用户ID、数量、幂等键等必要信息。异步消费段消费者从队列拉消息执行真正的业务逻辑——写订单表、扣减数据库库存、更新支付状态。这是“最终一致性”的落地区域。2.2 为什么选 Redis 而不是直接上分布式锁搞同步扣减有些团队习惯用 Redis 分布式锁SETNX来锁库存操作。比如先在 Redis 里抢锁抢到了就去 MySQL 扣库存扣完释放锁。这个方案我也试过问题不少锁粒度不好控制。如果锁整个商品的库存并发量一高后面的全部阻塞。锁生命周期不好管理。一旦业务执行超过锁过期时间锁被自动释放另一个线程就会同时进入超卖风险又出现了。加锁、解锁本身有网络开销高并发下反而引入了额外的延迟。而 Redis 预扣减用的不是锁而是Lua 脚本保证“检查库存扣减库存”两步操作的原子性。原子操作意味着同一时刻只有一个线程能完成扣减判断既没有锁等待也没有锁过期的问题。这是本质区别。我用的 Lua 脚本核心逻辑如下这是简化版便于理解local stock tonumber(redis.call(get, KEYS[1])) local quantity tonumber(ARGV[1]) if stock quantity then redis.call(decrby, KEYS[1], quantity) return 1 end return 0这个脚本在 Redis 内部就是串行执行的。Redis 单线程事件循环保证了不会有两个请求同时检查到库存为 1 都判定“足够”。这就是为什么我强调“预扣减不要自己写 GET DECR要用 Lua 脚本”拆成两步在并发下一定有超卖。2.3 预扣减 vs 真实扣减为什么必须分开很多新手容易把“预扣减”和“真实扣减”混为一谈。它们必须分开的原因与业务状态有关。用户点击“立即购买”到最终“支付成功”中间隔了很长时间。如果用户下单后不支付或者直接关掉页面预扣的库存需要释放。如果下单那一刻就直接把数据库库存扣掉用户不会付钱库存白白占用其他想买的用户又买不到这种矛盾一小时就能累计大量资损。这里要考虑的问题是补偿的时机如何设定。我当时的参数设置是这样Redis 库存键是一个“剩余可用”数量比如商品总库存 100 件初始 REST 100。每次预扣减成功REST 减 1同时有一个独立的超时回补机制。如果订单在 15 分钟内未支付就恢复 REST 加 1。支付成功后异步消费端执行“真实扣减”MySQL 里的库存字段减 1并把订单状态置为 PAID。这样既有“可用库存”的实时感知又有“真实库存”的最终一致性。如果直接用超时时间做回补要小心如果有 Thread A 在 15 分钟边界刚好发起支付而回补任务同时触发就可能出现重复扣减或者恢复错乱。这个边界问题我们后来用 Redis 分布式锁在“回补支付确认”这两个动作上做了互斥才彻底消掉。3. RabbitMQ 异步解耦的核心配置与避坑细节3.1 消息投递可靠性设置预扣减成功之后下一步是把订单消息投递给 RabbitMQ。如果消息投递失败或者投递中途丢失就会出现“用户以为下单成功实际后端没收到任务”的问题。我在实践中把 RabbitMQ 的可靠性配置拆成三档第一档开启 Publisher Confirm 模式。生产者发送消息后等待 Broker 返回 ack。没收到 ack 就重发。这相当于“发出去了且服务器确认收到了”。第二档消息持久化。交换器、队列、消息本身都要设置 durable true。这样即使 RabbitMQ 节点重启消息也不会丢。第三档手动 ACK 模式消费。消费者处理完业务逻辑后再手动发送 basicAck。如果消费者在处理过程中宕机消息会重新回到队列交给其他消费者处理。这三件事全做到消息丢失的概率才降到接近零。我只开 Producer Confirm有时候确实会出现消息已发送但 broker 没落盘就返回 ack 的情况取决于 RabbitMQ 刷盘策略所以消息持久化不能省。下面是生产者配置的一个参考Spring Boot 环境 yml 片段spring: rabbitmq: publisher-confirm-type: correlated publisher-returns: true template: mandatory: true监听器同样需要手动 ACKRabbitListener(queues order.create.queue) public void onMessage(Channel channel, Message message) { try { // 处理订单创建逻辑 channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); } catch (Exception e) { // 处理失败重新入队或进入死信队列 channel.basicReject(message.getMessageProperties().getDeliveryTag(), false); } }注意这里有个容易踩的坑——basicReject 第二个参数 requeue 设为 false 时消息会进入死信队列而不是无限重试。如果没有配置死信队列消息就直接丢了。我建议生产环境一定配置死信交换器DLX把重试多次失败的消息转储方便后续人工排查或者定时补单。3.2 消费端的幂等性设计异步消息最大的坑在于“重复消费”。RabbitMQ 在消费者未 ACK 且宕机的场景下会重新投递消息我们的业务系统为了保证不丢消息也可能做重发。重复消费的后果同一条订单消息被处理两次数据库里出现两条订单库存被扣两次用户被多扣钱。解决方案是“幂等表 唯一索引”。我当时的做法是数据库建一张 order_pending 表唯一键设为 biz_id即消息里的业务幂等键。消费端处理流程收到消息解析出 biz_id尝试 insert order_pendingbiz_id 唯一索引插入成功说明是第一次处理继续执行后续真正的订单创建逻辑插入冲突DuplicateKeyException说明之前已经处理过直接 ACK 丢弃不再执行。这套逻辑用一个数据库主键就能挡住重复不需要额外引入 Redis 锁。后来针对超高并发场景我们把幂等键还同时写进 RedisSETNXDB 作为最终保底。Redis 存在的意义是快速拦截绝大多数重复请求减少无意义的 DB 冲突检查压力。3.3 消费者并发与服务质量参数RabbitMQ 消费者有个非常重要的参数叫prefetchQoS它控制同一时刻消费者能从队列里预取多少条消息。默认不设置时RabbitMQ 会一股脑把队列里的消息全推给消费者内存直接被打满消息积压越处理越多。我当时的配置经验是单个消费者实例 prefetch 50 比较平衡。太高会导致单条消息处理慢时大量消息被提前拉出、堆积在本地太低会导致消费吞吐上不去。同时需要关心队列里的消息积压情况。如果队列积压超过阈值建议在生产者端做熔断预扣减成功后发现投递 MQ 持续失败直接返回“系统繁忙”让用户稍后重试。这比硬扛着接受请求、全部积压在内存里最终 OOM 要靠谱得多。消费者侧的核心配置大概这样spring: rabbitmq: listener: simple: prefetch: 50 concurrency: 10 max-concurrency: 30 acknowledge-mode: manual这里的 concurrency 和 max-concurrency 决定消费者线程池大小。我测试过在普通 4C8G 的实例上10~30 个并发消费者处理简单的订单插入逻辑吞吐能达到每秒 2000~3000 条左右足够应对绝大多数秒杀场景。需要再快就水平扩展消费者实例。4. 一致性兜底预扣减成功但消费失败的补偿策略4.1 预扣减成功订单最终没创建怎么办这是整个方案里最需要警惕的场景用户请求进入Redis 预扣减成功MQ 消息投递成功但消费者消费时业务异常比如数据库主键冲突、商品状态已下架、用户账号异常订单最终没有创建成功。这一瞬间发生的事情是Redis 的 REST 库存已经减了MySQL 库存没减用户也没有订单。如果不去补偿可用库存会持续减少直到 0但真实库存还有余量造成“卖不完但无法购买”的诡异现象。我的处理方案是状态机 定时对账订单消息进入 MQ 后先在 Redis 里写入一个“扣减记录”缓存key 是 biz_idvalue 包含商品 ID、用户 ID、数量、状态。消费者处理成功把状态更新为 FINISH并把订单入库消费者处理失败把状态标记为 FAILED一个定时任务每 5 分钟扫描一次 Redis 里超过 N 分钟仍处于“处理中”的记录反查订单表确认订单是否真的创建成功。如果确实没有就把 REST 库存回补 1并把记录状态置为 CLOSED。需要注意随缘重试只会加重失败队列的堆积。定时对账属于兜底它不是替换重试机制而是用来处理“重试也解决不了”的问题。生产上重试和补偿要并行普通重试循环最多 3 次超过 3 次进入死信队列定时任务专门检查那些“消息还在队列里反复失败”的记录。4.2 预扣减成功但支付超时怎么办刚才提到超时未支付触发回补。这个回补跟 4.1 的失败回补不同回补的是“用户没付钱”不是“系统处理失败”。我当时的实现是订单状态机CREATED用户下单成功但未支付PAID支付成功订单落地库存真实扣减CLOSED超时未支付订单关闭预扣库存回补。用户下单成功后订单在 Redis 里有一个 TTL 15 分钟。TTL 到期后Redis 会自动删除这个 KEY。为了能准确处理超时订单我会用一个延迟队列RabbitMQ TTL 死信队列来触发超时检查下单成功后同时投递一条延迟消息到延迟队列TTL 设置为 15 分钟延迟消息到期后进入死信队列由专门的消费者检查订单状态如果订单状态仍然是 CREATED就触发关闭订单 回补库存。这里要小心 TTL 过期时间和支付接口回调时间可能产生竞态。比如用户在 14 分 59 秒支付成功支付回调消息和延迟消息几乎同时到达消费者。如果延迟消息先执行看到的状态还是 CREATED就会关闭订单并回补库存随后支付回调回来又被标记为 PAID这就出现了用户支付了但订单被关闭的资损风险。我的解法关闭订单和支付确认这两个动作在同一个消费端服务内借助 Redis 分布式锁或数据库行锁串行化处理同一个订单 ID 的操作。谁先拿到锁谁先执行后执行的再次检查订单状态如果已经是 PAID 就不再回补。这个“二次校验”非常关键建议所有敏感状态的变更都加上。4.3 对账任务的 SQL 长什么样定时对账看起来很高大上实际上核心就是一条查询定位那些“Redis 扣了但数据库没有订单”的情况SELECT * FROM order_pending WHERE status PROCESSING AND create_time DATE_SUB(NOW(), INTERVAL 10 MINUTE) LIMIT 1000;这里 create_time 是消息进入 MQ 的时间。对账任务每 10 分钟执行一次把 PROCESSING 超过 10 分钟的记录挑出来关联订单表确认是否真的没有订单存在。如果确实没有则执行回补库存操作并把 order_pending 状态改成 CANCELLED。这套对账逻辑之所以需要独立于正常业务是因为它可以兜住所有异常路径消费者宕机、消息丢失、消息被错误 ACK、死信队列没配好导致消息丢了……只要 Redis 里还有扣减记录对账任务就能发现并补偿。5. 压测结果与关键参数到底快了多少5.1 压测环境压测是为了拿数据验证方案不是凭感觉。我当时用了两台 4C8G 的云主机一台跑订单服务和消费者一台跑 Redis 和 RabbitMQ。压测工具用的是 Go 编写的简单并发脚本模拟 10000 个用户同时点击购买。MySQL 库存设为 500 件。对比基线是直接查库扣减的同步方案改良方案是 Redis 预扣减 RabbitMQ 异步消费。5.2 压测结果对比同步方案10000 并发请求数据库连接池瞬间被打满。接口平均延迟 2400ms最大延迟 7200ms成功下单数 4372其中超卖 42 件部分原因是连接超时后重试导致重复扣减逻辑失效。改良方案同样的并发强度预扣减接口平均延迟 12ms最大延迟 58ms。成功预扣减 500 件超过 500 的请求全部快速返回“库存不足”。没有超卖。消费端异步处理订单平均每秒完成约 1800 单全部 500 单在 15 秒内处理完毕。这个差异就是预扣减的价值把响应时间从“秒级”压到“毫秒级”而且在超卖控制上从“事后发现”变成了“事前拦截”。5.3 这个方案必然的代价它也不是没有代价。最大的代价是“用户看到下单成功但订单还没真正生成支付状态要异步确认”。对用户体验来说用户点击“确定购买”后页面需要稍等片刻才能看到订单详情。我当时的处理是前端文案改为“正在排队确认”订单结果通过轮询或 WebSocket 推送返回。另外Redis 里需要额外存储库存和扣减记录增加了内存开销。扣减记录单条大概几百字节即使 10 万个并发也只有几十 MB压力不大。但是订单数据本身还在 MySQL 里所以最终存储瓶颈还是数据库只不过被削峰控制住了。6. 常见问题排查实录与操作速查表6.1 Redis 命令超时排查压测时遇到过 Redis 命令超时报错io.lettuce.core.RedisCommandTimeoutException: Command timed out当时的现象是 Redis CPU 飙升到 90%响应变慢。排查后发现是 Lua 脚本里有一个循环检查多个 key复杂度 O(N)高并发下 Redis 单线程被拖垮。正确做法是限制脚本里 KEYS 的数量尽量控制在单 key 操作。如果必须操作多 key可以考虑先用 pipeline 取数据、再在应用层判断或者改造成 hash 结构减少 key 数量。第二个原因是 Redis 的 maxmemory 接近上限触发了内存淘汰策略淘汰 key 时会阻塞事件循环。这种情况需要监控内存增长曲线提前扩容不要等打爆了再处理。6.2 RabbitMQ 消费积压排查消费者消费不过来的典型表现是队列深度持续上涨延迟消息迟迟无法处理。第一步先看消费者日志有没有业务异常大概率是某些消息反复消费失败卡住了整个队列的 tail。如果业务处理正常但吞吐不够就先调大 prefetch 和 concurrency。如果单机已经调到 50/30 还追不上生产速度就加消费者实例水平扩容。确认队列因为单条坏消息阻塞的处理技巧是用 rabbitmqctl 查看队列详情找到未确认消息的堆积情况把死信队列先打开把坏消息单独转到修复队列避免阻塞正常消息。6.3 预扣减和真实库存不一致的快速排查表现象可能原因排查方向Redis 库存显示为 0但 MySQL 库存还有消费失败且没有回补机制查看消费端异常日志确认是否进入死信队列MySQL 库存超卖预扣减未用 Lua 原子操作检查扣减脚本是否拆成了 GET DECRBY 两步用户支付成功但订单被关闭超时回补和支付回调竞态检查订单状态变更是否加了分布式锁或行锁二次校验消息丢失未开启 Confirm / 未手动 ACK检查生产者和消费者配置6.4 一个永不丢消息的最佳实践清单按下面这组清单配基本能把消息可靠性做到 99.99%生产者开启 publisher-confirm-type: correlated消息设置持久化交换器、队列全部 durable消费者手动 ACK配置死信队列 死信消费者告警消费者代码里所有处理逻辑包 try-catch拒绝裸抛异常导致消息无限 requeue配置队列长度限制防止无界积压打爆内存。7. 我最后想说的这套方案我前后踩了一个多月的坑最大的体会是延迟和一致性不可能靠一个组件同时解决只能通过分工让每个组件做它最擅长的事再用补偿机制收拾残局。Redis 的速度快但本身不保证持久化和事务一致性RabbitMQ 解耦能力强但引入异步必然带来状态不一致窗口。设计者要接受这个窗口存在然后把它的边界缩到可控范围。如果还要在“完美”两个字上较劲我的经验是把“一致性”定义从“强一致”调整为“最终一致 秒级延迟”。用户不会感知到下单一瞬间数据库还没有真正落单他们感知的是页面是否流畅、支付是否成功、库存是否显示准确。能做到这三件事架构就算成功。后续如果流量再上一个量级我会考虑在预扣减环节引入分片 Redis把单 key 压力拆到多实例并把对账任务升级为 Flink 实时计算但那已经是另一个故事了。
返回列表