电商库存扣减实战:从数据库到Redis的高并发防超卖方案 1. 库存扣减这件事远比你想的要命做电商系统的人都有一个共识订单、支付、库存这三块库存是最容易出事的那一环。订单丢了可以补支付错了可以退但库存一旦扣错要么超卖发不出货要么少卖白白压着货前者赔钱赔口碑后者压资金压周转。我见过不少团队前面流量做得风生水起结果一场大促下来超卖几千单客服被打爆店铺评分直接掉一个档。所以“库存扣减”这四个字看着简单背后是一整套关于并发控制、数据一致性、性能与体验平衡的工程问题。这篇文章我就把这些年踩过的坑、试过的方案、最后沉淀下来的做法完整地聊一遍。不管你是刚接手交易系统的开发还是正在为下一次大促做技术准备都能从里面找到能直接抄作业的东西。先明确一下讨论范围这里说的库存扣减指的是用户下单那一刻系统如何安全地把可售库存减掉以及后续支付、取消、超时这些环节如何把库存还回来。它涉及的核心关键词就是超卖、并发、幂等、最终一致性、预占库存这几个。我会从整体设计思路讲起再拆到具体实现最后给你一份排查问题的速查表。2. 整体设计思路为什么库存扣减不能“想扣就扣”2.1 先搞清楚库存到底分几种很多人一上来就写update stock set num num - 1这是最朴素的做法也是事故的起点。在动手之前得先把库存的概念拆开。一个商品的可售数量其实是由好几个数字共同决定的总库存仓库里实际有多少件。可售库存当前能卖给用户的量。锁定库存已经被订单占用、但还没最终确定的量。已售库存已经完成支付、确定要发货的量。它们之间的关系大致是可售库存 总库存 - 锁定库存 - 已售库存。下单时我们做的其实是把一部分“可售”挪到“锁定”支付成功后再从“锁定”挪到“已售”如果订单取消或超时就把“锁定”还回“可售”。为什么要这么绕因为用户下单和付款之间有时间差。如果下单就直接扣总库存那用户不付款货就被白白占着如果下单不扣、付款才扣那付款那一刻可能已经没货了用户体验极差。所以预占锁定机制是绝大多数电商系统的标配这也是理解后面所有方案的基础。2.2 超卖到底是怎么发生的超卖的根因只有一个并发读改写。假设某商品可售库存是 1两个用户同时下单。两个请求都先查了一下发现库存是 1都认为可以买然后都去执行减一。结果库存变成 -1两个人却都下单成功了。这个场景在低并发下几乎不会出现但大促时一秒几千个请求打进来数据库层面如果没有正确的锁或者原子操作超卖就是必然。很多人以为加了事务就没事其实普通事务在“读-判断-写”这个模式下隔离级别不够时照样出问题。要解决它核心就是让“判断库存够不够”和“扣减库存”这两步变成一个原子操作中间不能被别的请求插进来。2.3 方案选型的三个维度市面上的库存扣减方案五花八门但选型时其实就看三个维度维度说明影响一致性要求能否容忍短暂的不一致决定用强一致还是最终一致并发量级峰值 QPS 有多高决定用数据库还是缓存业务复杂度是否有预售、组合商品等决定扣减粒度小体量业务直接用数据库行锁就能扛住中等体量数据库加乐观锁或者 Redis 预扣超大流量就得上 Redis 加异步落库甚至分库分表加库存分片。没有银弹只有适不适合。下面我逐个拆开讲。3. 核心方案逐个拆从数据库到缓存的完整实现3.1 数据库悲观锁最稳但最慢的兜底方案悲观锁的思路很直接在扣减之前先把这行数据锁住别人进不来。用 SQL 表达就是select ... for update把商品库存行锁住然后在事务里判断、扣减、提交。begin; select stock from product_stock where product_id 1001 for update; -- 应用层判断 stock 购买数量 update product_stock set stock stock - #{num} where product_id 1001; commit;这个方案的好处是绝对不超卖逻辑简单不容易写错。坏处也很明显锁的粒度是行同一商品的请求全部串行化QPS 上不去。而且for update持有锁的时间取决于事务里还干了什么如果事务里夹杂了远程调用锁会被长时间占用直接把数据库拖垮。我的经验是悲观锁适合库存竞争不激烈、但绝对不能出错的场景比如限量款、秒杀尾款这种。用的时候一定要保证事务里只有数据库操作任何 RPC 调用都要挪到事务外面。另外记得给product_id建唯一索引否则for update可能锁表那就彻底完蛋了。3.2 数据库乐观锁用版本号换并发乐观锁的思路是我不锁你但我更新的时候检查一下库存有没有被别人改过。常见做法是加一个 version 字段或者直接用库存数量做条件。update product_stock set stock stock - #{num}, version version 1 where product_id 1001 and stock #{num} and version #{oldVersion};看影响行数如果是 1 就成功是 0 就说明被别人抢先了重试或者返回失败。这个方案把“判断”和“扣减”合并到了一条 SQL 里靠数据库的行锁保证原子性并发能力比悲观锁好不少。但它有个坑ABA 问题和重试风暴。高并发下大量请求失败重试反而给数据库更大压力。所以乐观锁一般要配合重试次数限制比如最多重试 3 次超过就直接返回“手慢了”。另外用stock #{num}做条件其实已经能防超卖version 字段更多是防止更新覆盖看你的业务需不需要。3.3 Redis 预扣减扛住大促流量的主力当 QPS 上到几万甚至几十万数据库就顶不住了这时候必须把扣减动作前置到 Redis。核心思路是在 Redis 里维护一份可售库存下单时用 Lua 脚本原子扣减扣成功再异步同步到数据库。Lua 脚本大概长这样-- KEYS[1]: 库存key ARGV[1]: 扣减数量 local stock tonumber(redis.call(get, KEYS[1])) if stock nil then return -1 -- 库存不存在 end if stock tonumber(ARGV[1]) then return 0 -- 库存不足 end redis.call(decrby, KEYS[1], ARGV[1]) return 1 -- 扣减成功Redis 单线程执行 Lua天然保证原子性性能轻松上万 QPS。扣减成功后把订单信息丢进消息队列由消费者异步写数据库。这样数据库只承担最终落库的压力不用直面流量洪峰。但这个方案引入了最终一致性问题Redis 扣了数据库还没落这时候 Redis 挂了怎么办所以必须有一套对账和补偿机制。我的做法是Redis 扣减成功后立刻发一条消息到可靠队列消费者落库成功才确认如果落库失败消息重投同时记录异常订单人工介入。另外Redis 的库存数据要有持久化策略重启后能从数据库重新加载。3.4 库存分片把热点商品拆开有些商品是超级热点比如某款手机首发几十万人抢几千台。这时候哪怕用 Redis单个 key 也会成为瓶颈。解决办法是库存分片把 1000 台库存拆成 10 份每份 100 台分散到 10 个 key 上请求按用户 ID 哈希打到不同分片。这样并发压力被分散单 key 的竞争大大降低。代价是库存管理变复杂某个分片扣完了但别的分片还有货用户可能被误判为“没库存”。所以分片方案通常要配合分片间调拨或者随机重试其他分片的逻辑。我一般建议只在极少数超级热点商品上用分片普通商品用单 key 就够了别为了炫技把系统搞复杂。4. 实操落地一套能直接参考的完整流程4.1 下单扣减的完整链路把上面的方案串起来一个典型的下单扣减流程是这样的用户点击下单请求进入订单服务。订单服务先做参数校验、风控校验、限购校验。调用库存服务库存服务执行 Redis Lua 脚本预扣。预扣成功生成订单状态为“待支付”同时把锁定记录写入数据库。发送延迟消息用于超时未支付自动取消。用户支付成功库存服务把锁定转为已售数据库最终扣减。如果超时未支付取消订单Redis 库存加回数据库锁定释放。这里面每一步都要考虑幂等。比如支付回调可能重复取消消息可能重复所以每个操作都要带一个唯一的业务流水号处理前先查一下是否已经处理过。4.2 关键参数怎么定几个容易拍脑袋定错的参数我列一下我的经验值Redis 库存 key 的过期时间不要设太短建议至少覆盖整个活动周期再加几天防止活动没结束 key 就没了。我一般设 7 天。延迟消息的延迟时间就是订单超时时间常见是 15 分钟到 30 分钟。太短用户来不及付太长库存被占太久。电商一般 15 分钟虚拟商品可以更短。重试次数乐观锁重试建议 3 次消息消费重试建议 5 次以上并配合死信队列。对账频率Redis 和数据库的库存对账大促期间建议每分钟一次平时可以 5 分钟一次。这些数字不是死的要根据你的业务节奏调。比如你的用户付款很快超时时间就可以短一点库存周转更快。4.3 回滚和补偿怎么做库存扣减最怕的就是“扣了但订单没生成”或者“订单取消了但库存没还”。前者会导致少卖后者会导致超卖。所以必须有补偿机制。我的做法是双保险一是本地事务表扣减 Redis 和写订单在同一个业务操作里如果写订单失败立刻回滚 Redis二是定时对账任务扫描那些“Redis 已扣但数据库无对应订单”的记录自动补偿。对账任务要能区分正常在途订单和异常订单别把还没支付的订单给误放了。提示补偿逻辑一定要做成幂等的因为对账任务可能重复执行网络抖动也可能导致重复触发。5. 常见问题与排查技巧实录5.1 超卖了怎么快速定位超卖一旦发生先别慌按这个顺序查看是不是所有商品都超卖还是只有热点商品。只有热点商品大概率是并发控制没做好。查扣减逻辑是不是“先查后扣”的两步操作如果是基本就是它。查 Redis 和数据库的库存是否一致如果不一致看同步链路哪里断了。查有没有重复消费的消息导致库存被多加或者多减。我遇到过一次超卖最后发现是消息队列重复消费取消订单的消息被处理了两次库存加回了两次结果后面的人买到了本不存在的货。所以幂等表这个东西再麻烦也要加。5.2 库存不少但用户下不了单这种问题通常是“可售库存显示有但扣减失败”。原因可能是Redis 库存和数据库库存不一致Redis 已经扣完但数据库还有。分片库存不均衡某个分片没了但整体还有。锁没释放比如悲观锁事务卡住了。排查时先看 Redis 的实际值再看数据库的值对比差异。如果是分片问题检查哈希算法是否均匀。如果是锁问题查数据库的锁等待情况。5.3 大促前必须做的几件事根据我的经验大促前一周一定要做这几件事能避免 80% 的库存事故压测用真实流量模型压重点压热点商品看 Redis 和数据库的瓶颈在哪。对账演练手动制造不一致看对账任务能不能发现并修复。降级预案Redis 挂了怎么办数据库扛不住怎么办要有开关能快速切到兜底方案。限流库存服务前面一定要有限流防止异常流量打垮整个链路。下面这张表是我整理的问题速查表可以直接拿去用现象可能原因排查方向解决手段超卖并发扣减未原子化检查扣减 SQL 或 Lua改原子操作加幂等少卖库存未及时回滚查取消/超时链路补对账任务修回滚逻辑扣减慢锁竞争或热点 key看慢查询和 Redis 监控分片、乐观锁、异步化数据不一致同步链路中断比对 Redis 与 DB补偿任务消息重投重复扣减消息重复消费查消费日志加幂等表去重5.4 几个我踩过的坑第一个坑是在事务里做 Redis 操作。数据库事务和 Redis 操作不在一个事务里数据库回滚了 Redis 不会回滚结果就是库存被多扣。后来我把 Redis 操作挪到事务提交之后或者用补偿机制兜底。第二个坑是Lua 脚本里用了浮点数。Redis 的 Lua 对浮点支持有坑库存数量一定要用整数别用小数否则可能出现精度问题导致扣减异常。第三个坑是忘记处理库存为 0 的情况。有些商品库存扣到 0 后key 还在但值已经是 0这时候如果代码里判断stock nil才认为没库存就会出问题。所以 Lua 里要同时判断 nil 和小于购买数量两种情况。6. 最后聊几句实在的库存扣减这件事方案没有绝对的好坏只有跟你的业务阶段匹不匹配。创业初期用数据库乐观锁就够了别一上来就搞 Redis 分片维护成本太高。等流量真的上来了再一步步演进。我见过太多团队为了“技术先进”把简单问题复杂化最后系统一堆组件出问题时排查都排查不过来。真正重要的是对账和补偿机制。不管你用哪种扣减方案只要有人为或系统的不一致对账任务就是最后一道防线。把对账做扎实了哪怕中间出点小问题也能自动修复不至于演变成事故。另外库存相关的所有操作一定要打日志而且要打全。订单号、商品号、扣减前后的数量、操作类型、时间戳一个都不能少。出问题时这些日志就是你的救命稻草。我现在的习惯是库存服务的日志单独存一份保留至少 30 天大促期间延长到 90 天。这个内容后续还可以这样扩展比如预售模式的库存怎么设计组合商品的库存怎么联动扣减跨境场景下多仓库存怎么分配。每一个都是独立的话题有机会再单独聊。