去年双十一,我团队负责的某个电商中台项目在零点刚过的第三分钟,库存系统就出现了第一次预警:Redis 集群中某个热门 SKU 的库存扣减日志与数据库实际存量产生了 17% 的偏差。这意味着,如果持续放量,五分钟后我们将出现至少三百单的超卖。运营团队已经在群里刷屏问“系统是不是崩了”,而我们必须在几十秒内做出判断,是直接切流降级,还是信任补偿逻辑的自我修复。这篇文章就是那次经历的完整复盘,但我不打算只讲 Redis+Lua 的常规操作。我更想拆清楚一个问题:当所有“标准方案”都失效时,你的库存系统到底靠什么活下来?
在双十一这种级别的瞬时并发冲击下,如果你还在幻想用单一技术方案去解决“数据一致性”问题,那你大概率会在某一年的大促中翻车。我这里的判断非常明确:库存一致性不是一个技术问题,而是一套“熔断-降级-补偿”三级联动体系的建设问题。
我见过太多团队把全部精力放在“如何用 Redis 原子化扣减库存”上,却完全忽略了三个更要命的事:一是 Redis 集群本身发生网络分区时,你的兜底逻辑在哪;二是当补偿队列积压超过十万条时,你的系统是继续硬扛还是主动降级;三是业务方在凌晨两点发现库存数据对不上时,他们能操作什么、不能操作什么。
所以,这篇文章的核心结论我先说清楚:
接下来我会把整个保障体系拆成五个环节讲透,每一段都来自我们团队的真实复盘。
很多没亲身经历过双十一的技术人员,对“高并发”的想象往往停留在“很多人同时下单”这个层面。但真实的压力模型远比这复杂,我分三个维度来描述。
我们监测到的一个典型现象是:零点前三十秒,系统 QPS 还维持在 2000 左右,零点零分零秒瞬间跳到 85000。但这不是最要命的。真正让库存系统出事的,是第三分钟出现的“第二波脉冲”,大量用户在第一波抢购失败后疯狂刷新,同时结算页的库存查询请求量暴涨到第一波的 2.3 倍。这个第二波脉冲的特点是:它不是下单请求,而是库存查询请求,却同样打穿了缓存层。
很多团队的 Redis 集群就是在第二波查询压力下开始出现主从切换延迟的。一旦从节点在毫秒级内读到了未同步的库存快照,前端展示的库存数据就会出现“幽灵可用”状态,用户看到还有货,点进去却提示库存不足,然后大量投诉涌入。
我们抽取了某个热门美妆 SKU 的扣减日志做了分析。这个 SKU 总库存为 8000 件,在零点三分钟内涌入 12.6 万次扣减请求。我们对这些请求做了时间窗口切片,发现在 100 毫秒的窗口内,同时到达 Redis 的扣减请求最高达到 470 次。
这意味着什么?常规的“先查后扣”模式在毫秒级并发下毫无可靠性可言,470 个请求读到的是同一个剩余库存值,然后各自减一,最终实际扣减量可能是 470,但正确结果应该是其中大多数请求因库存不足而失败。这就是超卖的根因,也是必须引入 Lua 脚本原子化执行的原因。但我必须指出一个很多人忽略的点:Lua 脚本只保证了 Redis 内部的原子性,它无法解决“扣减成功后,数据库写入失败”这个分布式事务问题。
除了大家熟知的扣减超卖,我们在复盘时还发现了另外三种一致性杀手:

这些问题的共同特征是:它们都不是“纯技术bug”,而是“状态机设计缺陷”加上“异常链路覆盖不足”。如果你只盯着 Redis 的原子性做优化,你永远修不完这些边角问题。
我在协助多个团队做双十一架构复盘时,发现几个反复出现的误区。这些误区不是“新手才会犯的错误”,有些甚至来自多年经验的资深架构师。
Redis 单线程执行 Lua 脚本确实能保证脚本内操作的原子性,这一点没错。但问题出在脚本之外:
我们在压测中实测了第三个场景:手动触发一次 Redis 主从切换,在 10 万 QPS 的压力下,切换窗口内丢失的已执行扣减操作平均达到 147 次。这意味着至少有 147 个订单在 Redis 里扣减成功了,但新主节点不认这笔账。
我见过一个典型案例:团队为了“万无一失”,对所有库存扣减操作都加了 Redisson 分布式锁,锁粒度是 SKU 级别。零点一过,热门 SKU 的锁竞争直接把 Redis 的 CPU 打到了 95% 以上,锁等待超时的请求堆积如山,系统吞吐量反而比不加锁时下降了 60%。
分布式锁的正确用法是:只在“不得不加锁”的环节使用,并且锁的粒度要尽可能细。比如,不要对整个 SKU 加锁,而是把库存分成多个逻辑分区,每个分区独立加锁并行处理。我们在后续优化中把一个 SKU 的 8000 件库存分成了 8 个 1000 件的逻辑分区,锁等待时间从平均 230 毫秒降到了 18 毫秒。

消息队列是异步解耦的利器,但它引入了一个新问题:消息的顺序性和幂等性你必须自己保证。
我们遇到过这样一个事故:用户先下单,然后立即取消。下单消息和取消消息几乎同时进入 Kafka,但由于分区策略和消费顺序,取消消息先被消费了,回补逻辑发现“没有对应的扣减记录,跳过”;随后下单消息被消费,库存被正常扣减。最终结果就是:用户订单取消了,但库存没回补,这件商品就“凭空消失”了。
这个问题不是 Kafka 的 bug,而是业务逻辑设计时没有考虑“消息乱序”的场景。我们在修复时做了两件事:一是给每条库存变更消息强制带上业务时间戳和前置状态校验;二是在消费端执行“状态机校验”,每个库存操作都检查当前状态是否允许该操作执行。
这是最致命的一个误区。很多团队把数据一致性押注在“事后跑批对账”上,认为大促结束后统一修复就行。但问题是:双十一的每一分钟都有真实用户在交易,你没有资格等“事后”。
我们的做法是:在库存系统中内嵌了一套实时对账探针。每 30 秒对热门 SKU 做一次 Redis 与 MySQL 的库存差值计算,差值超过阈值(我们设置的是总库存的 0.5%)立即触发告警,同时自动执行“冻结该 SKU 的增量扣减、只允许查询和回补”的降级策略。这套探针在双十一当天捕获了 7 次异常偏差,其中 3 次是在用户还没感知到库存异常之前就自动止损了。
讲完误区,我来完整呈现我们最终落地的四层保障体系。这套体系不是一次性设计出来的,而是经历了两次双十一、三次 618 的打磨才稳定下来的。
这一层的目标是:在 99.9% 的正常流量下,保证扣减操作在 5 毫秒内完成,同时为异常情况留好退路。
核心技术方案确实是 Redis+Lua,但我们在标准方案上加了三个关键设计:
Redis 扣减成功后,扣减记录进入 Kafka,由消费端批量写入 MySQL。这一层的核心设计点是:

这一层是我最想强调的,因为它直接回答了文章开头的问题:“当所有标准方案都失效时,系统靠什么活下来?”
我们的实时对账探针每 30 秒执行一次以下逻辑:
三级响应的具体策略:
| 响应等级 | 偏差范围 | 自动动作 | 通知方式 |
|---|---|---|---|
| 一级 (预警) | 0.5% – 1% | 记录日志,加大探测频率到10秒一次 | 飞书机器人推送 |
| 二级 (限流) | 1% – 3% | 该SKU切换为"限流模式":扣减请求排队执行 | 飞书+短信通知运维 |
| 三级 (冻结) | 大于3% | 该SKU冻结增量扣减,仅允许查询和回补 | 飞书+短信+电话通知值班人员 |
在去年双十一当天,这套探针一共触发了 7 次一级预警、2 次二级限流、1 次三级冻结。那次三级冻结发生在凌晨两点十七分,原因是某个 SKU 的预售库存合并脚本异常,导致 Redis 库存虚增了 4.2%。系统在三十秒内自动冻结了该 SKU,避免了后续两个小时内的持续超卖。值班人员花了十五分钟手动修复数据并解冻,整个过程中用户侧只看到了“该商品暂时缺货”的提示,没有产生任何超卖订单。
最后一层听起来不够“技术范儿”,但它救了太多次急。
我们开发了一个专门的后台工具,叫“库存安全控制台”。它不依赖任何自动化逻辑,直接操作数据库和 Redis,功能包括:
这个控制台的核心设计理念是:把“人”作为系统的最后一道防线,给“人”最快的操作路径。设计它的原则是“三步以内完成任一操作”,因为在大促凌晨,每多一步操作就多一分出事的风险。

上面讲的四层体系是面向“年 GMV 三十亿以上、双十一当天订单量百万级”的场景设计的。但我非常清楚,绝大多数团队用不起、也不需要这么重的架构。这一节我根据不同的业务量级,给出对应的取舍建议。
这个阶段的核心矛盾不是技术,而是人力和时间。你不要去折腾分布式锁和 Kafka 集群,你的目标是:用最少的组件、最简单的逻辑,做到“不超卖”。
建议方案:
这个方案的代价是性能走不远,但好处是维护成本几乎为零,一人团队也能hold住。
这个阶段数据库行锁开始撑不住了,你需要引入缓存层,但不急着上全套异步体系。
建议方案:
这就是我前面完整展开四层体系的场景。这里我只补充两个踩过坑的决策细节:

这一节我不讲理论,就讲两个真实翻车事件。每次复盘我都觉得,这些教训比那些“最佳实践”值钱一百倍。
2022 年双十一前一天晚上十一点,我们执行缓存预热脚本,把所有热门 SKU 的库存数据预先加载到 Redis 中。这个脚本的逻辑是:从 MySQL 一次性拉取 5000 个 SKU 的数据,然后循环写入 Redis。
问题出在“循环写入”上,没有加任何限流和批量化处理。5000 个 SKU 的单条写入命令在三十秒内砸向 Redis,直接把单节点的连接池耗尽。Redis 开始拒绝连接,但预热脚本没有处理这种异常,继续重试,造成更严重的连接堆积。最终 Redis 节点宕机,连带影响了已经在线上运行的其他服务。
我们花了四十分钟紧急重启 Redis 并重新预热,差点导致零点活动延迟。事后的修复很简单:批量写入用 Pipeline,每批 200 条,批次之间间隔 50 毫秒。
2023 年 618,一个看似不起眼的 bug 造成了重大事故。我们的消息队列消费端在做幂等校验时,用的唯一键是“SKU + 扣减数量 + 时间戳”。这个组合在正常场景下是唯一的,但在一个特殊场景下撞车了:用户同时购买了两件相同商品,同一 SKU、同一扣减数量 1、同一秒内产生的两条消息,时间戳只差了 3 毫秒,但在我们截取到秒级的时间戳里是完全相同的。
结果第二条消息被幂等校验判定为“重复消息”而丢弃,导致用户付了两件钱,但库存只扣了一件。这笔账直到第二天财务对账时才被发现,涉及的商品已经全部售罄。
事后我们把幂等键改成了订单号,这是唯一不会撞的标识。这个错误太基础了,但它教会我一句话:幂等键的设计要用业务主键,不要用“你觉得不会撞”的组合键。
读到这里,你可能在想:“这些架构改造不是一朝一夕的事,我现在能做点什么?”下面是三件你可以立刻执行的事情,不需要改代码架构,但能快速堵上最危险的漏洞。
打开你的代码,找到库存扣减的 SQL 或 Redis 操作。检查它是不是“先查后扣”模式:先 SELECT stock,然后在代码里减一,再 UPDATE(或 SET)回去。如果是,立即改成:
MySQL 版本:
UPDATE inventory
SET stock = stock - 1, version = version + 1
WHERE sku_id = #{skuId}
AND stock > 0
AND version = #{currentVersion}Redis+Lua 版本:
local stock = redis.call('GET', KEYS[1])
if tonumber(stock) > 0 then
redis.call('DECR', KEYS[1])
return 1
else
return 0
end这个改动只需要十五分钟,但它能把你的超卖概率从“看运气”降到“几乎为零”。
不需要开发复杂的对账系统。你现在就可以在数据库里跑一条 SQL,找出库存量前 50 的热门 SKU,然后在你的后台管理页面给它们加一个“安全库存上限”的监控字段。当实际库存低于这个上限时,主动通知运营人员手动核查一次。
这个方法很土,但在去年双十一救了至少两个小型团队。他们都是在零点后收到自动报警,手动核实后发现 Redis 和数据库差了十几件库存,及时修正避免了超卖。
不管你用不用分布式锁,你都需要一个能快速“冻结某个 SKU 交易”的能力。不需要做成自动化,一个简单的配置开关就够了:在 Redis 或配置中心里给每个热门 SKU 增加一个 freeze_flag,你的扣减逻辑在入口处检查这个 flag。当值班人员在后台把这个 flag 设为 true 时,该 SKU 的所有扣减请求直接返回“库存不足”。
这个开关的响应速度应该在十秒以内。你不需要现在就搞自动化降级,但你必须让值班人员能在发现问题后的三十秒内手动止损。快一秒钟,可能就少十个超卖订单。
库存一致性的终极解法,不是找到一个完美的技术方案,而是承认你的系统一定会出问题,然后为“出问题”这件事设计好应对机制。好的架构不是不出故障,而是故障发生时,系统有路可退、有人可找、有数据可追溯。
如果这篇文章里你只能带走一条原则,我希望是这一条:永远给你的库存系统留两个出口,一个是自动化降级策略,一个是人工一键冻结的按钮。这两样缺一个,你的系统就没有“保命”的资格。
我是一名电商后端开发,公司之前一直用数据库乐观锁配合重试机制来处理库存扣减,但去年双十一还是出现了严重超卖。我想不明白,乐观锁在理论上能保证原子性,为什么实际中却失效了?是不是我使用方式不对,还是说这种方案从根本上就不适合高并发场景?
我曾经踩过这个坑,亲身经历过乐观锁在双十一压力下崩溃的现场。直接原因有两点:第一,乐观锁在高冲突场景下会导致大量事务回滚,数据库连接池瞬间被打满。
我们当时的压测数据显示,在10万QPS并发下,乐观锁版本的库存扣减成功率从期望的99%骤降到不足30%,大部分请求都因版本冲突失败,迫使业务层重试,最终拖垮了整个订单接口。第二,重试机制引入了‘幽灵超卖’,因为重试时读取的库存值可能已经是其他请求更新后的值,但版本号没变化,导致实际上扣减了多次。
更致命的是,乐观锁无法处理‘库存预占’这类长事务场景(比如购物车超时释放),因为它只能保证单条SQL的原子性,无法跨多个操作。我们的教训是:双十一场景下,务必用悲观锁或Redis+Lua方案,而不是依赖乐观锁+重试这种看似优雅实则脆弱的妥协方案。
我照着网上教程用Redis+Lua实现了秒杀库存扣减,本地测试一切正常,但上线后偶尔还是会发现数据库里的库存和Redis缓存对不上,甚至出现过负数库存。难道Redis+Lua不是银弹吗?我到底忽略了哪些关键细节?
Redis+Lua确实能通过单线程原子性避免并发冲突,但‘完美’的前提是你必须处理两个致命细节。第一,Redis宕机后缓存与数据库的同步问题:我们曾因Redis主从中断导致Lua脚本执行后的缓存与数据库长期不一致,最终靠凌晨的定时对账脚本才发现。
解决方案是引入‘双写+延迟双删’策略,并在写入数据库时用分布式锁兜底。第二,Lua脚本执行超时:当脚本操作大量key或网络抖动时,Redis会将其标记为‘慢查询’并抛出超时异常,此时库存可能已被部分扣减但未提交到数据库。
我们的压测案例中,即便只执行两个key的脚本,在连接池耗尽时也有0.3%的概率超时。正确做法是:对所有Lua脚本设置最大执行时间(如50ms),并配合MQ异步校正。别信‘零超卖’的噱头,要自己写补偿逻辑。
我见过最稳的方案是:Redis+Lua做高性能扣减,同时将扣减流水写入MQ,再由消费端幂等同步到数据库并用定时任务核对差异,这样即使Redis挂了也不会丢数据。
我们团队只有3个后端,用的还是单机MySQL,今年双十一流量预计翻5倍,老板要求不能超卖,但又不给预算买昂贵的分布式组件。我查了很多方案,都是Redis集群、消息队列一整套,根本用不起。有没有既简单又可靠的保命方法?
我曾在类似的小团队待过,我们的解法是‘降级+人工兜底+分桶’三步走。第一步,降级:把库存模型从实时刻扣改为‘预占+异步扣减’,比如预售商品在0点前只接受下单,但不实时扣库存,而是用MySQL行锁分批扣减,这样并发压力从秒杀流量分散到后续5分钟。
第二步,人工兜底:在管理后台预留一个‘一键冻结库存’按钮,当监控发现异常时,运维直接手动将可售卖量设为0,防止超卖扩散。我们去年双十一就靠这个按钮紧急按了一次,避免了百万级损失。第三步,分桶:把商品库存按订单号哈希分成多个‘小库存桶’,每个桶对应一个独立线程,用本地锁而不是分布式锁。
例如1000件库存分成10个桶,每个桶100件,并发冲突概率骤降。实测在1000QPS下单机MySQL也能扛住,TP99从2秒降到200ms。
核心思路是:小团队别追求通用分布式方案,而要结合业务形态做‘业务级水平拆分’,比如将商品按品牌或仓库维度独立部署,每个实例只负责几千个SKU,绝不让一个DB实例硬扛全站流量。
我打算用RocketMQ实现库存扣减的最终一致性,但担心消息重复投递导致多扣,或者库存不足时消息丢失。我查了网上资料,都说要‘幂等’和‘补偿’,但具体实现时发现很多坑:比如幂等表该用什么索引?本地消息表如何保证原子性?我希望能看到真实生产环境的踩坑记录。
这块我实打实压测过并已上线运行两年。关键要解决三个问题:1)消息幂等:不要在业务表上用唯一索引(比如订单号),因为可能重复插入不同业务操作(取消订单也要扣增库存)。
正确做法是单独建一个‘幂等记录表’(id+业务类型+唯一索引,如订单ID+操作类型),扣减前先查询该表,存在则跳过分发,不存在则执行并写入。我们线上规模每天500万消息,幂等命中率约0.2%,但杜绝了95%的重复扣减。2)本地消息表+事务:确保业务操作和发送消息在同一个本地事务中。
比如扣减库存时,先写业务记录,再插入待发送消息表,然后用定时任务轮询发送。我们踩过坑:如果直接用@Transactional+同步发送MQ,网络抖动会导致业务回滚但消息已被发出,出现‘多扣’。正确做法:消息发送前设置‘半状态’,收到MQ broker确认后才标记为已发送。
3)补偿机制:库存扣减失败(比如余额不足)时,需要自动触发‘回滚’,但回滚也会出现并发冲突。我们的方案是:将回滚也作为一条‘反向消息’进入同一个MQ,消费端用 Redisson 分布式锁防止重复回滚。别忘了设置死信队列和监控告警,当补偿消息重试超过3次立即发钉钉告警,人工介入。
最后,一个容易被忽略的细节:消费端必须支持‘资源预留’,比如库存扣减时先扣冻结库存,再异步扣实际库存,这样即使补偿失败,还能通过定时任务释放冻结。


读者评论
实测 Redis 主从切换丢失 147 次扣减那个数据太真实了。我们之前也遇到过类似问题,当时全靠事后对账修复,用户端已经一堆投诉。文章提到的“黑匣子”流水表设计是个好思路,下次大促前我得试试给每个扣减请求提前落盘,避免网络抖动导致的重试乱扣。
作者能把“库存一致性不是技术问题而是体系问题”这句话讲透,说明是真在大促一线扛过。我特别赞同对分布式锁粒度分区的实测对比,8 分区 18ms 等待时间确实是最优平衡点。不过扣减上限 110% 给回补偿预留空间的做法,实际业务方接受度怎么样?我们这边财务很难接受总库存虚标。
读完最大的收获是“对账要在交易中做,而不是等事后”。我们之前双十一后就靠跑批对账,结果凌晨四点才发现某个 SKU 多卖了 200 件,只能一个个打电话道歉补偿。今年准备按文中的思路,内嵌实时差值探针,阈值 0.5% 自动冻结增量扣减,就算误触发也比出事强。