库存服务:WMS 微服务化里最棘手的那个崽
副标题:分布式库存扣减、并发控制与最终一致性设计
1. 问题引入:大促当晚,库存超卖了 300 单
说实话,我做 WMS 这么多年,最怕的不是仓库现场打架,也不是快递爆仓,而是大促零点那一刻,库存数字对不上了。
去年双十一,咱们系统就翻车了。零点刚过,运营群里炸锅:某爆款 SKU 显示还有 500 件库存,结果愣是出了 800 多单。超卖 300 单,后面补货、道歉、赔运费,折腾了一周。
问题来了:这库存扣减,不是早就做了吗?怎么就超卖了?
咱们来还原一下现场。
那天晚上,有两个出库服务实例同时收到订单,都要扣同一批库存。单体时代,这事儿好办,数据库一行锁(SELECT ... FOR UPDATE)就搞定了。但现在咱们是微服务架构,库存服务独立部署,订单服务调 A 实例,OMS 调 B 实例,两个请求几乎同时打到库存表上。
结果?
- 实例 A 读到库存 500,准备扣 200。
- 实例 B 也读到库存 500,准备扣 300。
- 俩人一提交,库存变成 200 和 100,反正不是预期的 0。
这就是分布式环境下最头疼的问题:库存强一致性。在微服务里,行锁跨不了进程,事务边界被服务边界切开了,传统的 ACID 那一套玩不转了。
说白了,库存服务就是 WMS 微服务化里最棘手的那个崽。今天咱们就聊聊,这个崽到底该怎么带。
2. 库存服务的边界与模型
在动手改代码之前,咱们得先搞清楚:库存到底是个啥?它不能乱拆,拆细了事务管不住,拆粗了业务又不够用。
2.1 核心实体:四种库存状态
咱们系统里,库存不是简单的一个数字,而是分了四种状态:
| 状态 | 含义 | 例子 |
|---|---|---|
| 可用库存 | 真正能卖的 | 仓库里放着,没人动 |
| 锁定库存 | 订单已占,还没出库 | 用户付了钱,货给你留着 |
| 在途库存 | 采购在路上的 | 明天到仓,今天可以先预售 |
| 冻结库存 | 盘点、报损、调拨暂扣 | 暂时不能动 |
真正决定能不能下单的,是这条公式:
可售库存 = 可用库存 + 在途库存 - 锁定库存 - 冻结库存2.2 为什么不能拆太细?
有同事提过:“咱们把库存服务再拆一拆,按仓库拆、按货主拆,甚至按 SKU 拆,每个微服务管自己的一摊,多清爽。”
听起来很美,但真这么干了,你会发现一个订单要扣 5 个 SKU、3 个仓库的库存,得调 15 个服务。分布式事务怎么保证?Seata?TCC?性能直接崩盘。
所以咱们的原则是:库存服务作为一个领域服务保留,内部按业务维度做数据分片,但不拆成更细的微服务。
2.3 我们的模型:四级维度
最终咱们定的库存模型是:
库存唯一键 = SKU + 仓库(warehouse) + 货主(owner) + 库位(location)- SKU:最小商品单位,没什么好说的。
- 仓库:华东仓、华南仓,库存物理隔离。
- 货主:第三方商家的货和自营的货不能混。
- 库位:具体到货架、库区,WMS 现场作业要用。
日常销售扣减,一般只到SKU + 仓库 + 货主三级。库位级别的扣减交给下游的波次拣货服务去分配,库存服务不掺和那么深。
这样设计的好处是:事务边界清晰,一次库存扣减最多影响一条或几条记录,不会拖垮整个服务。
3. 方案一:数据库行锁(保守但有效)
好,进入正题。先说最老实巴交的方案——数据库行锁。
3.1 适用场景
如果你的业务并发量不高,比如日均几千单,或者对一致性要求极高(比如高价值商品、奢侈品),行锁依然是个稳妥的选择。
3.2 伪代码实现
// 开启事务@TransactionalpublicbooleandeductStock(DeductRequestreq){// 1. 用 FOR UPDATE 锁住库存记录Stockstock=stockMapper.selectForUpdate(req.getSku(),req.getWarehouse(),req.getOwner());// 2. 判断库存是否充足if(stock.getAvailable()<req.getQty()){thrownewBizException("库存不足");}// 3. 扣减可用库存,增加锁定库存stockMapper.deduct(stock.getId(),req.getQty());// 4. 记录库存流水(后面会讲,这是幂等和兜底的关键)stockFlowMapper.insert(buildFlow(req));returntrue;}关键点在哪?
SELECT ... FOR UPDATE会把这条记录锁死,其他事务必须排队等。- 锁的粒度是行级别,只要不同 SKU 不冲突,并发还能接受。
- 实现简单,不需要引入 Redis、MQ 这些中间件。
3.3 缺点也很明显
但问题就在于这个"排队等"。
咱们做过压测:单条热点 SKU,行锁方案的 TPS 大概在200~300。大促时候一秒钟几千单涌进来,数据库连接池很快就打满了,大量请求超时。而且 InnoDB 的行锁在并发高的时候容易演变成锁等待、死锁,运维半夜起来杀慢 SQL 是常事。
所以行锁方案可以用,但只适用于并发不高的场景,或者作为兜底方案保留。
4. 方案二:Redis 分布式锁 + 异步落库
大促场景下行锁扛不住,那怎么办?很多团队第一反应就是:上 Redis!
4.1 设计思路
核心思想是"内存抗并发,异步保落地":
- Redis 预扣库存:利用 Redis 单线程特性,保证扣减原子性。
- 发 MQ 消息:扣减成功后,异步把变动同步到 MySQL。
- MySQL 最终一致:消费者慢慢落库,不要求实时强一致。
4.2 伪代码实现
第一步:Redis 预扣
publicbooleandeductStock(DeductRequestreq){StringredisKey=buildStockKey(req);// 1. 先 Lua 脚本原子扣减 Redis 库存Longresult=redisTemplate.execute(REDIS_DEDUCT_SCRIPT,Collections.singletonList(redisKey),String.valueOf(req.getQty()));if(result==null||result<0){// 库存不足,直接失败returnfalse;}// 2. 扣减成功,发 MQ 异步落库mqProducer.send(newStockChangeEvent(req));// 3. 记录流水(这里也可以异步写)stockFlowMapper.insert(buildFlow(req));returntrue;}Lua 脚本(保证原子性)
localkey=KEYS[1]localqty=tonumber(ARGV[1])localavailable=tonumber(redis.call('get',key)or0)ifavailable<qtythenreturn-1endredis.call('decrby',key,qty)returnavailable-qty第二步:MQ 消费者异步落库
@RocketMQMessageListener(topic="STOCK_CHANGE")publicclassStockChangeConsumerimplementsRocketMQListener<StockChangeEvent>{@OverridepublicvoidonMessage(StockChangeEventevent){// 幂等校验:流水号去重if(stockFlowMapper.exists(event.getFlowNo())){return;}// 更新 MySQL 库存stockMapper.deduct(event.getSku(),event.getWarehouse(),event.getQty());// 记录流水stockFlowMapper.insert(buildFlow(event));}}4.3 风险与兜底
这个方案吞吐确实高,咱们压测单 SKU 能跑到3000+ TPS。但它不是银弹,有几个坑咱们必须提前想好:
坑一:Redis 挂了怎么办?
- 如果 Redis 主从切换,可能会丢几秒数据。
- 咱们的做法是 Redis 集群 + 持久化,同时保留 MySQL 的"真实库存"作为基准。Redis 只是并发扣减的"缓冲层"。
坑二:Redis 和 MySQL 不一致怎么办?
- 比如 Redis 扣了,MQ 丢了,或者消费者失败了,MySQL 就没扣。
- 这就需要对账机制,后面第 6 节会详细讲。
坑三:Redis 预扣了,但订单最后取消了,库存怎么回滚?
- 释放库存同样走 Redis Lua 脚本 + MQ,保持链路一致。
- 回滚也要有幂等流水,防止重复释放。
所以,Redis 方案适合大促高并发,但必须配套对账、幂等、补偿机制,否则就是给自己挖坑。
5. 方案三:分段库存(我们的最终选择)
行锁太保守,Redis 方案又太重。咱们团队琢磨了很久,最终选了一个折中方案——分段库存。
5.1 核心思路
说白了,就是把一条热点库存记录,拆成 N 条子记录。扣减的时候随机选一段,段内再用乐观锁竞争。
这样一来:
- 原来 1000 个请求抢 1 行锁,现在变成抢 100 行锁,竞争降低 100 倍。
- 不需要引入 Redis,纯数据库方案就能大幅提升吞吐。
- 实现复杂度比 Redis 方案低,但比分库分表还是要麻烦一些。
5.2 数据模型
-- 库存主表(汇总)stock_summary: sku,warehouse,owner,total_available,total_locked-- 库存分段表(实际扣减在这里)stock_segment: sku,warehouse,owner,segment_no,available,locked,version比如某 SKU 总库存 10000,拆成 100 段,每段 100。
5.3 伪代码实现
第一步:初始化分段库存
publicvoidinitSegments(Stringsku,Stringwarehouse,Stringowner,inttotalQty,intsegmentCount){intperSegment=totalQty/segmentCount;for(inti=0;i<segmentCount;i++){StockSegmentseg=newStockSegment();seg.setSku(sku);seg.setWarehouse(warehouse);seg.setOwner(owner);seg.setSegmentNo(i);seg.setAvailable(perSegment);seg.setVersion(0);segmentMapper.insert(seg);}// 同步更新汇总表summaryMapper.init(sku,warehouse,owner,totalQty);}第二步:扣减逻辑(随机选段 + 乐观锁)
publicbooleandeductStock(DeductRequestreq){intsegmentCount=getSegmentCount(req);intmaxRetry=3;for(intretry=0;retry<maxRetry;retry++){// 1. 随机选一个段号,分散竞争intsegmentNo=RandomUtil.nextInt(segmentCount);// 2. 读取该段库存StockSegmentseg=segmentMapper.selectByNo(req.getSku(),req.getWarehouse(),req.getOwner(),segmentNo);if(seg.getAvailable()<req.getQty()){// 这段不够,换一段试试(也可以顺序遍历)continue;}// 3. 乐观锁更新:where version = 当前版本intaffected=segmentMapper.deductWithVersion(seg.getId(),req.getQty(),seg.getVersion());if(affected>0){// 扣减成功,更新汇总表(可异步)summaryMapper.deduct(req.getSku(),req.getWarehouse(),req.getQty());// 记录流水stockFlowMapper.insert(buildFlow(req,segmentNo));returntrue;}// 乐观锁冲突,重试}returnfalse;}关键点在哪?
segmentNo随机选,是为了让请求均匀分散到各个段上。如果固定顺序,第一段永远最忙。- 乐观锁
version控制并发,冲突时重试,不会阻塞其他请求。 - 汇总表可以异步更新,甚至定时汇总,不阻塞主流程。
5.4 优缺点分析
优点:
- 吞吐大幅提升。咱们压测下来,分段 100 段时单 SKU TPS 能到1500~2000,比行锁高了一个数量级。
- 纯数据库方案,不依赖 Redis,架构更简单。
- 天然支持回滚,释放库存时找到原分段加回去就行。
缺点:
- 实现复杂,初始化、分段合并、余量不均都是问题。
- 余量可能不均:比如某段只剩 1 件,但订单要扣 10 件,这段就"废了",得继续找下一段。极端情况下大量小段余量碎片化,影响命中率。
- 查询总可用库存时,需要汇总各段,比单条记录慢。
咱们实际的做法是:日常销售用分段库存,大促前对热点 SKU 做"段内归并",把零散余量重新整理。虽然麻烦,但综合下来性价比最高。
6. 库存对账与补偿
不管你用哪种方案,只要涉及分布式、异步、多数据源,对账和补偿就是必修课。
6.1 为什么必须对账?
Redis 和 MySQL 可能不一致,分段库存的汇总表和明细段也可能不一致,MQ 消费失败、网络超时、服务重启,都会导致数据偏差。
咱们的原则是:允许短暂不一致,但不允许长期不一致。
6.2 对账方案
咱们设计了三层对账:
| 层级 | 频率 | 作用 | 发现差异后 |
|---|---|---|---|
| 实时对账 | 每笔操作 | 流水校验 | 立即告警 |
| 小时对账 | 每小时 | Redis vs MySQL / 汇总 vs 分段 | 自动补偿 |
| 日终对账 | 每天凌晨 | 全量库存大盘点 | 人工复核 |
6.3 幂等设计:库存变动流水表
所有库存变动,不管是扣减、释放、补货、报损,必须落一条流水。流水表的核心字段:
CREATETABLEstock_flow(idBIGINTPRIMARYKEY,flow_noVARCHAR(64)UNIQUENOTNULL,-- 业务流水号,幂等键skuVARCHAR(64),warehouseVARCHAR(64),ownerVARCHAR(64),biz_typeVARCHAR(32),-- DEDUCT / RELEASE / RESTOCKqtyINT,segment_noINT,-- 分段库存用create_timeDATETIME);幂等校验伪代码:
publicvoidprocessStockChange(StockChangeEventevent){try{// 唯一键冲突即幂等,直接忽略stockFlowMapper.insert(buildFlow(event));}catch(DuplicateKeyExceptione){log.warn("重复消息,已忽略,flowNo={}",event.getFlowNo());return;}// 真正执行业务更新stockMapper.deduct(event.getSku(),event.getWarehouse(),event.getQty());}6.4 定时对账任务伪代码
// 每小时跑一次@Scheduled(cron="0 0 * * * ?")publicvoidreconcileStock(){// 1. 找出过去一小时内发生变动的 SKUList<String>changedSkus=stockFlowMapper.findChangedSkus(lastHour);for(Stringsku:changedSkus){// 2. 汇总流水得到理论库存intflowQty=stockFlowMapper.sumQtyBySku(sku,lastHour);// 3. 读取 MySQL 实际库存intdbQty=stockMapper.getAvailable(sku);// 4. 对比差异if(flowQty!=dbQty){// 记录差异,发告警alertService.send("库存差异告警",sku,flowQty,dbQty);// 小额差异自动补偿(比如差 1~5 件)if(Math.abs(flowQty-dbQty)<=5){stockMapper.adjust(sku,flowQty-dbQty,"AUTO_RECONCILE");}}}}6.5 补偿的边界
自动补偿只能处理小额差异,大额差异必须人工介入。因为大额差异往往意味着业务逻辑有 bug,比如重复扣减、漏释放,盲目自动补可能会越补越乱。
咱们的经验是:对账是最后一道防线,前面的方案设计再漂亮,没有对账都不敢上线。
7. 验证与总结
好了,三种方案都讲完了。咱们来看看压测数据和最终结论。
7.1 压测数据对比
测试环境:8C16G 容器 × 3,MySQL 8.0 主从,Redis 6.x 集群,热点 SKU 并发扣减。
| 方案 | 单 SKU TPS | 一致性级别 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 数据库行锁 | ~250 | 强一致 | 低 | 低并发、高价值商品 |
| Redis + 异步落库 | ~3500 | 最终一致 | 高 | 大促秒杀、超高并发 |
| 分段库存(100段) | ~1800 | 强一致(段内) | 中 | 日常大促、综合最优 |
7.2 我们的选择
咱们最终的架构是组合方案:
- 日常销售:分段库存为主,兼顾性能和一致性。
- 大促秒杀:对极少数超级爆款,启用 Redis 预扣 + 异步落库,分段库存兜底。
- 低并发场景(比如 B2B 大宗、奢侈品):保留行锁方案,通过配置开关切换。
说白了,没有银弹。库存服务这崽子,你得根据它的脾气,换不同的招儿来带。
7.3 踩过的坑(诚实透明环节)
- 坑 1:分段库存刚上线时,随机选段用了
Random,结果高并发下随机数生成成了瓶颈。后来改成ThreadLocalRandom,TPS 直接涨了 15%。 - 坑 2:Redis 方案初期没做对账,大促后发现有 0.3% 的 SKU 存在微量差异。虽然用户没感知,但咱们 internally 复盘了一个月。
- 坑 3:库存回滚和扣减共用一套 MQ topic,结果消息顺序错乱,释放比扣减先到,导致负库存。后来拆成两个 topic,并加状态机校验。
7.4 写在最后
库存服务真的是 WMS 微服务化里最难啃的骨头之一。它不像订单服务那样有明确的生命周期,也不像商品服务那样 mostly 只读。库存是高频写、强一致、业务敏感的三重叠加,稍有不慎就是真金白银的损失。
希望这篇文章能给你一些参考。如果你也在做库存服务的设计,或者踩过类似的坑,欢迎在评论区聊聊——
你们是怎么解决库存超卖和并发扣减的?分段库存、Redis 预扣,还是另有奇招?
咱们一起交流,共同进步!
(本文是 WMS 微服务化系列第 5 篇,往期文章可在专栏中查看。)