news 2026/4/17 6:00:20

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
微服务系列(五) 库存服务-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 个服务。分布式事务怎么保证?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 设计思路

核心思想是"内存抗并发,异步保落地":

  1. Redis 预扣库存:利用 Redis 单线程特性,保证扣减原子性。
  2. 发 MQ 消息:扣减成功后,异步把变动同步到 MySQL。
  3. 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 篇,往期文章可在专栏中查看。)

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/17 6:00:15

【手搓 AI Agent 从 0 到 1】第五课:让 AI 调用工具

&#x1f4cc; 前置知识&#xff1a;已完成第一课至第四课 &#x1f3af; 本课目标&#xff1a;让 AI 不仅选择动作&#xff0c;还能指定参数&#xff0c;真正调用外部能力 &#x1f4a1; 核心概念&#xff1a;工具接口 / 结构化工具调用 / 请求与执行分离 前言 上节课&#x…

作者头像 李华
网站建设 2026/4/17 5:59:51

sqli-labs靶场 less-1

一、注入点这个网站连接数据库后端的查询用户是用id查询的&#xff0c;并且请求方式是get所以在传入接口的网址后面添加id2或者别的数字&#xff0c;就会查询id1的用户信息?id1二、查看有多少字段?id1 order by 3-- //要查询表id1的数据有没有三个字段 是要提前闭合后台自带…

作者头像 李华
网站建设 2026/4/17 5:59:43

Gerber文件导出避坑手册:Allegro光绘参数设置与立创EDA兼容性实战

Gerber文件导出避坑手册&#xff1a;Allegro光绘参数设置与立创EDA兼容性实战 在硬件设计领域&#xff0c;Gerber文件作为PCB生产的"通用语言"&#xff0c;其导出质量直接决定生产成败。尤其当使用Allegro这类国际EDA工具对接国产立创EDA生态时&#xff0c;参数设置差…

作者头像 李华
网站建设 2026/4/17 5:52:30

生成式AI灰度发布必须设置的4个动态熔断阈值:基于token级延迟、置信度衰减率与用户纠错频次

第一章&#xff1a;生成式AI应用灰度发布策略 2026奇点智能技术大会(https://ml-summit.org) 生成式AI应用的灰度发布需兼顾模型行为不确定性、用户反馈敏感性与系统稳定性。不同于传统服务&#xff0c;大语言模型输出具有非确定性、上下文强依赖及潜在幻觉风险&#xff0c;因…

作者头像 李华