微服务系列(五) 库存服务-WMS微服务化里最棘手的那个崽

张开发
2026/4/17 6:00:21 15 分钟阅读

分享文章

微服务系列(五) 库存服务-WMS微服务化里最棘手的那个崽
库存服务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 个服务。分布式事务怎么保证SeataTCC性能直接崩盘。所以咱们的原则是库存服务作为一个领域服务保留内部按业务维度做数据分片但不拆成更细的微服务。2.3 我们的模型四级维度最终咱们定的库存模型是库存唯一键 SKU 仓库(warehouse) 货主(owner) 库位(location)SKU最小商品单位没什么好说的。仓库华东仓、华南仓库存物理隔离。货主第三方商家的货和自营的货不能混。库位具体到货架、库区WMS 现场作业要用。日常销售扣减一般只到SKU 仓库 货主三级。库位级别的扣减交给下游的波次拣货服务去分配库存服务不掺和那么深。这样设计的好处是事务边界清晰一次库存扣减最多影响一条或几条记录不会拖垮整个服务。3. 方案一数据库行锁保守但有效好进入正题。先说最老实巴交的方案——数据库行锁。3.1 适用场景如果你的业务并发量不高比如日均几千单或者对一致性要求极高比如高价值商品、奢侈品行锁依然是个稳妥的选择。3.2 伪代码实现// 开启事务TransactionalpublicbooleandeductStock(DeductRequestreq){// 1. 用 FOR UPDATE 锁住库存记录StockstockstockMapper.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 分布式锁 异步落库大促场景下行锁扛不住那怎么办很多团队第一反应就是上 Redis4.1 设计思路核心思想是内存抗并发异步保落地Redis 预扣库存利用 Redis 单线程特性保证扣减原子性。发 MQ 消息扣减成功后异步把变动同步到 MySQL。MySQL 最终一致消费者慢慢落库不要求实时强一致。4.2 伪代码实现第一步Redis 预扣publicbooleandeductStock(DeductRequestreq){StringredisKeybuildStockKey(req);// 1. 先 Lua 脚本原子扣减 Redis 库存LongresultredisTemplate.execute(REDIS_DEDUCT_SCRIPT,Collections.singletonList(redisKey),String.valueOf(req.getQty()));if(resultnull||result0){// 库存不足直接失败returnfalse;}// 2. 扣减成功发 MQ 异步落库mqProducer.send(newStockChangeEvent(req));// 3. 记录流水这里也可以异步写stockFlowMapper.insert(buildFlow(req));returntrue;}Lua 脚本保证原子性localkeyKEYS[1]localqtytonumber(ARGV[1])localavailabletonumber(redis.call(get,key)or0)ifavailableqtythenreturn-1endredis.call(decrby,key,qty)returnavailable-qty第二步MQ 消费者异步落库RocketMQMessageListener(topicSTOCK_CHANGE)publicclassStockChangeConsumerimplementsRocketMQListenerStockChangeEvent{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){intperSegmenttotalQty/segmentCount;for(inti0;isegmentCount;i){StockSegmentsegnewStockSegment();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){intsegmentCountgetSegmentCount(req);intmaxRetry3;for(intretry0;retrymaxRetry;retry){// 1. 随机选一个段号分散竞争intsegmentNoRandomUtil.nextInt(segmentCount);// 2. 读取该段库存StockSegmentsegsegmentMapper.selectByNo(req.getSku(),req.getWarehouse(),req.getOwner(),segmentNo);if(seg.getAvailable()req.getQty()){// 这段不够换一段试试也可以顺序遍历continue;}// 3. 乐观锁更新where version 当前版本intaffectedsegmentMapper.deductWithVersion(seg.getId(),req.getQty(),seg.getVersion());if(affected0){// 扣减成功更新汇总表可异步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(cron0 0 * * * ?)publicvoidreconcileStock(){// 1. 找出过去一小时内发生变动的 SKUListStringchangedSkusstockFlowMapper.findChangedSkus(lastHour);for(Stringsku:changedSkus){// 2. 汇总流水得到理论库存intflowQtystockFlowMapper.sumQtyBySku(sku,lastHour);// 3. 读取 MySQL 实际库存intdbQtystockMapper.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 容器 × 3MySQL 8.0 主从Redis 6.x 集群热点 SKU 并发扣减。方案单 SKU TPS一致性级别实现复杂度适用场景数据库行锁~250强一致低低并发、高价值商品Redis 异步落库~3500最终一致高大促秒杀、超高并发分段库存100段~1800强一致段内中日常大促、综合最优7.2 我们的选择咱们最终的架构是组合方案日常销售分段库存为主兼顾性能和一致性。大促秒杀对极少数超级爆款启用 Redis 预扣 异步落库分段库存兜底。低并发场景比如 B2B 大宗、奢侈品保留行锁方案通过配置开关切换。说白了没有银弹。库存服务这崽子你得根据它的脾气换不同的招儿来带。7.3 踩过的坑诚实透明环节坑 1分段库存刚上线时随机选段用了Random结果高并发下随机数生成成了瓶颈。后来改成ThreadLocalRandomTPS 直接涨了 15%。坑 2Redis 方案初期没做对账大促后发现有 0.3% 的 SKU 存在微量差异。虽然用户没感知但咱们 internally 复盘了一个月。坑 3库存回滚和扣减共用一套 MQ topic结果消息顺序错乱释放比扣减先到导致负库存。后来拆成两个 topic并加状态机校验。7.4 写在最后库存服务真的是 WMS 微服务化里最难啃的骨头之一。它不像订单服务那样有明确的生命周期也不像商品服务那样 mostly 只读。库存是高频写、强一致、业务敏感的三重叠加稍有不慎就是真金白银的损失。希望这篇文章能给你一些参考。如果你也在做库存服务的设计或者踩过类似的坑欢迎在评论区聊聊——你们是怎么解决库存超卖和并发扣减的分段库存、Redis 预扣还是另有奇招咱们一起交流共同进步本文是 WMS 微服务化系列第 5 篇往期文章可在专栏中查看。

更多文章