ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

高并发库存超卖实战:AI实时对账与自动补偿方案

高并发库存超卖实战:AI实时对账与自动补偿方案 凌晨两点零三分告警群把我从半梦半醒中炸醒。某款茅台秒杀活动上线第八分钟监控大屏上出现了一条刺眼的记录库存字段变成了 -1200。这不是测试数据被玩坏而是真实的高并发流量把整个库存链路打穿了。那会儿我脑子里只有一个念头必须连夜上线一套 AI 实时对账与自动补偿把这套库存系统从“失控”状态拉回“可自愈”状态。这篇文章不是理论分析而是一份真实的线上排障记录和加固方案包含了我对库存扣减、分布式事务、高并发流量治理的完整思考。如果你正在做秒杀系统或者准备面试“库存扣减”这类八股题我建议你耐心看完。1. 事故复盘从告警群里看到库存 -1200 条记录开始1.1 这不是“扣减失败”是超卖穿透了整条链路活动开始时前端做了预热瞬时 QPS 冲到 3 万以上远超过我们预估的 5000。库存服务最先顶不住Redis 里的大量热点 key 同时过期请求直接打到数据库。数据库连接池被占满大量 update 语句排队等待行锁。等到活动进行到第八分钟时用于承载秒杀的 MySQL 实例 CPU 接近 100%部分请求超时后客户端自动重试重试请求又叠加上来。我打开商品库存表发现某 SKU 的 stock 字段已经是负数同时订单表里成功创建的订单数量比库存上限多了 1200 多个。这里要明确一点这个问题不是简简单单的“扣减失败”而是超卖已经穿透了缓存、SQL、事务三层防线落到真实业务数据上了。从监控能很清楚地看到库存在前两分钟还比较正常第三分钟开始出现少量负数之后负值越来越大。原因很简单大量请求同时读到“还有库存”然后一起执行扣减数据库行锁又没能拦住所有并发最终把余额扣穿了。1.2 业务损失不止是钱还有系统的信任当时测算了一下损失按平台售价 1499 元、超卖 1200 件来算如果全部发货货品损失巨大而且根本没有那么多货可发如果强制关单退款用户投诉和赔偿又是一笔不小的费用。更麻烦的是超卖订单分布在已支付、待支付、支付超时等多个状态不能简单地一刀切。我列了一张损失估算表贴在项目群里项目数据超卖数量1200 件涉及订单1347 笔已支付订单892 笔待支付订单455 笔预计退款金额超过 130 万客服工单预估3000钱还是小问题更严重的是用户信任。很多人蹲点抢购系统却告诉他“库存超卖订单无效”这对平台口碑的影响是长期的。所以当时团队定了一个硬目标不管用什么手段必须在 30 分钟内完成全量超卖订单的识别并且在 2 小时内完成自动补偿。2. 根因拆解为什么高并发下一万个请求打穿了库存防线2.1 库存扣减的三种常见写法我们恰好用了最脆的一种库存扣减有很多种实现方式简单归纳为三类先查再改、条件更新、Redis 原子扣减。我们当时线上用的是“先查再改”// 错误示例先查库存再扣减 if (stockService.queryStock(skuId) 0) { stockMapper.deductStock(skuId, 1); }这种写法在低并发下没有问题但在高并发下就是灾难。多个请求同时查询库存发现都是 1然后一起执行扣减数据库里的库存就变成了负数。正确的做法应该是把判断和扣减合并成一个原子操作-- 正确的条件更新库存充足才会扣减 update stock set stock stock - 1 where sku_id #{skuId} and stock 0;加了stock 0条件之后数据库行锁会在更新时自动把关库存不足的请求会更新失败从根上避免负库存。我们复盘后立刻把这个 SQL 改成了条件更新但立刻又发现新的问题即使不超卖热点行依然被大量并发更新阻塞数据库吞吐量还是上不去。2.2 真正的隐形杀手事务边界里塞了远程调用刚开始我们还以为是 SQL 写得不严谨后来看调用链才发现更隐蔽的问题。库存扣减方法上挂了Transactional事务内部除了更新库存还调用了会员中心、积分服务、消息推送甚至风控服务。任何一个外部接口出现抖动整个事务就会一直挂着数据库连接和行锁都被长时间占用。这就好比一个收银台顾客结账时收银员不仅要点钱还要跑到后厨确认食材、去仓库查库存、到门口问保安天气队伍当然堵到门外。事务时间从原来的 5 毫秒被拖到 500 毫秒数据库连接池只有 100 个算下来每秒最多处理 200 个事务。一旦请求超过这个量级全部堆积等待超时后客户端重试再继续堆积最终拖垮整个服务。这个问题的本质是搞混了本地事务和分布式事务的边界。跨服务调用的状态一致性不能靠一个本地数据库事务硬撑必须拆分事务边界。我们后来的做法是库存扣减只做库存扣减所有外部通知全部放到消息队列里异步处理。2.3 缓存与数据库之间的时间窗口让库存判断形同虚设我们当时还引入了 Redis 预扣库存。接口先执行redis.decr(stock: skuId)如果结果大于等于 0再异步同步数据库。这个思路本身没问题但落地时少做了一步Redis 扣减成功、数据库扣减失败的时候没有反向回补缓存。举个例子某个 SKU 在 Redis 里还剩 1 件两个请求同时 decr都成功了Redis 变成 -1。一个请求先扣了数据库另一个请求数据库扣减时因为某种原因失败按理说 Redis 应该把 1 件库存加回来但我们的逻辑没有做补偿。结果缓存里的库存数越扣越少表面上显示还有货实际数据库已经对不上了。缓存和数据库不是天然一致的必须在任何一步失败时都有对账和补偿机制。这也是我们后来坚决要上“实时对账”的导火索。3. AI 实时对账体系把事后数小时的纠错压缩到秒级3.1 传统 T1 对账为什么救不了这种场景很多公司对账用的是 T1 模式凌晨跑批比对订单、支付、库存流水早上上班发现问题。这种模式在常规电商业务里够用但在秒杀场景下完全不行。因为库存超卖后的黄金处理窗口只有 1 到 2 个小时如果第二天发现订单早就发货了钱也结算了再补偿就得走逆向流程成本极高。另外T1 对账只能发现“对不上”很难定位“为什么对不上”。库存超卖可能由缓存不一致、重复扣减、事务回滚异常等多种原因造成没有实时链路很难快速定界。3.2 实时对账的“AI”到底是什么规则引擎加统计异常检测先说实话这个“AI”不是深度学习模型我们没有用神经网络去预测库存会不会超卖。它本质是一个实时决策系统由三部分组成规则引擎、统计异常检测、自动补偿决策流。规则引擎很好理解就是把对账逻辑显式化为规则。比如一条规则是“新增订单的数量必须小于等于同时段库存流水扣减数量”如果不满足立刻告警并标记异常。统计异常检测负责发现没有预设规则的异常。我们收集每次请求的库存变化量、响应时间、失败次数计算最近 5 分钟的滑动平均和标准差。当某个 SKU 的扣减失败率超过均值加三倍标准差时系统会自动把该 SKU 切到“保护模式”拒绝所有直接扣减请求先对账再恢复。自动补偿决策流则负责根据异常类型选择动作比如超卖关单、退款、释放库存、调整余额。这三部分组合起来看起来就像一个 AI 在实时处理库存问题实际上后台跑的是几千条明确规则和一堆统计阈值。3.3 对账数据管道设计实时对账的核心是数据管道的完整性。我们使用消息队列把所有关键事件全部串起来包括订单创建事件、支付成功事件、库存扣减事件、库存回补事件、退款事件。每个事件都带上全局流水号、业务类型、商品 ID、数量、发生时间。数据管道结构大致是这样订单服务产生订单事件发到订单主题支付服务产生支付事件发到支付主题库存服务产生库存扣减/回补事件发到库存主题实时消费程序从三个主题消费数据做窗口关联写入对账宽表。对账任务每隔 10 秒执行一次扫描最近 5 分钟的对账宽表按 SKU 维度比对“订单数量、支付数量、库存扣减数量、库存实际余额”四个指标。任何一个指标偏差超过阈值就会触发补偿任务。对账宽表的设计非常关键。我们用了独立的数据库不跟业务库混在一起。表结构很简单核心字段是 SKU ID、对账维度、流水总量、异常标记、补偿状态、第一次发现时间和最后更新时间。这个表既是实时监控的数据源也是补偿任务的任务队列。下面是四个核心对账维度我直接用表格展示对账维度比对双方异常类型补偿动作订单与支付订单表 vs 支付流水已支付但订单不存在创建补单或退款支付与库存支付流水 vs 扣减流水支付成功但库存未扣补扣库存或锁单库存流水与余额扣减流水汇总 vs 库存余额流水不等于余额生成调整单并调账缓存与数据库Redis 库存 vs MySQL 库存缓存与实际不一致重建缓存并告警4. 自动补偿机制库存负数的收口与业务恢复4.1 补偿不能一股脑“退单”先分级再处理库存负数出现后最忌讳的操作是“把所有超卖订单全部取消”。因为超卖订单里有一部分其实还没支付另一部分已经支付但用户可能不想要了还有一部分无论如何都想买到。统一处理只会制造更多客诉。我们设计了三档补偿策略第一档待支付超卖订单直接关闭不产生实际资金损失系统自动发一条短信说明“商品已售罄”。这个动作可以全自动执行。第二档已支付且用户必须履约的订单优先尝试调拨其他仓库的库存或者从线下门店调货。如果实在无货可发再走关单退款同时发放一张大额补偿券。第三档已支付但用户对时效不敏感的订单锁住库存进入现货采购流程和采销系统打通后续到货后优先发货。这种分级策略避免了补偿动作的“一刀切”也让真正有购买意愿的用户尽量保留订单。4.2 幂等设计和状态机自动化链条的保险丝自动补偿任务跑起来之后最怕的就是“同一个订单被补偿了两次”。第一次补偿把订单关了、退款了第二次补偿又跑一遍用户就会收到两笔退款资金对账直接乱套。解决方法是给每条补偿任务设置唯一业务键键的组成是“对账批次号 订单号 补偿类型”。在补偿执行前先查询任务表里是否已存在相同键存在就跳过。同时每条补偿任务都有严格的状态机待补偿、补偿中、补偿完成、补偿失败、人工审核。每个状态只能按照既定方向流转不能跳跃。比如“补偿失败”的任务只能进入“人工审核”不能直接改回“待补偿”重新执行防止死循环。我画了一个状态转换列表在代码里我们用枚举严格约束待补偿 - 补偿中领取任务时补偿中 - 补偿完成执行成功补偿中 - 补偿失败执行异常补偿失败 - 待补偿人工确认后重新入队补偿失败 - 人工审核无法自动处理4.3 补偿执行链路和核心代码逻辑补偿执行链路分为五步锁定订单、判断状态、执行补偿动作、更新库存/退款、写补偿流水。伪代码如下Transactional public void compensate(CompensationTask task) { // 1. 幂等校验 if (compensationRecordMapper.exists(task.getBizId()) 0) { return; } // 2. 锁定订单 Order order orderMapper.selectByIdForUpdate(task.getOrderId()); if (order.getStatus() ! OrderStatus.PAID) { // 状态不匹配直接标记完成 compensationRecordMapper.insert(task.getBizId(), SKIP); return; } // 3. 执行关单退款 order.setStatus(OrderStatus.CLOSED); orderMapper.updateById(order); refundService.refund(order.getOrderId(), order.getPayAmount()); // 4. 释放库存如果是超卖场景则不需要释放而是修正库存 if (task.getActionType() ActionType.RELEASE_STOCK) { stockService.releaseStock(task.getSkuId(), task.getQuantity()); } // 5. 写补偿流水 compensationRecordMapper.insert(task.getBizId(), SUCCESS); }注意一个坑这个Transactional里面调用了refundService.refund()如果退款是远程调用千万不能直接放在本地事务里。正确做法是先更新本地订单状态为“退款中”发一条退款消息再由消息消费者去执行退款最终通过消息回调更新订单状态。5. 关键实现细节Sentinel 流量治理与分布式事务配置5.1 为什么引入 Sentinel而不是只靠数据库乐观锁有人会说既然改成了update ... where stock 0就不会超卖了为什么不直接上数据库乐观锁理由是能防超卖但防不了系统崩溃。秒杀场景下所有请求都去更新同一个商品的行哪怕条件更新再快热点行锁也会让大量请求排队等待。数据库连接池一旦被占满整体可用性就会断崖式下跌。所以我们需要在数据库前面加一层流量治理。我们选择了 Alibaba Sentinel主要用它做了两件事匀速排队限流和熔断降级。匀速排队限流是把突刺流量平滑掉。比如预估库存服务最大能扛 2000 QPS就设置一个 2000 QPS 的 FlowRule超过部分进入排队队列以固定速率放行。这样数据库看到的请求是均匀的不会被瞬时流量打爆。熔断降级则是保护下游依赖。当库存服务的 P95 响应时间连续 3 秒超过 100msSentinel 会打开熔断开关后续请求快速失败并返回“系统繁忙”而不是继续压在数据库上。Sentinel 的核心配置我贴在下面FlowRule flowRule new FlowRule(stock_deduct); flowRule.setGrade(RuleConstant.FLOW_GRADE_QPS); flowRule.setCount(2000); flowRule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER); flowRule.setMaxQueueingTimeMs(500); FlowRuleManager.loadRules(Collections.singletonList(flowRule)); DegradeRule degradeRule new DegradeRule(stock_deduct); degradeRule.setGrade(RuleConstant.DEGRADE_GRADE_RT); degradeRule.setCount(100); degradeRule.setTimeWindow(3); DegradeRuleManager.loadRules(Collections.singletonList(degradeRule));接口上加上SentinelResource注解并在 fallback 方法里返回友好文案SentinelResource(value stock_deduct, blockHandler blockHandler) public boolean deductStock(Long skuId, Integer quantity) { return stockMapper.deductStock(skuId, quantity) 0; } public boolean blockHandler(Long skuId, Integer quantity, BlockException ex) { // 被限流后进入排队或直接返回 return false; }5.2 分布式事务选型TCC 还是本地消息表订单和库存之间天生就是跨服务分布式事务。很多团队一上来就想用 Seata 的 TCC我们评估后放弃了。原因很简单TCC 需要为每个业务接口实现 Try、Confirm、Cancel 三套逻辑对库存扣减这种高频接口来说开发和运维成本太高而且 Confirm/Cancel 阶段失败后仍然需要人工介入并不能保证绝对一致。我们最终选择了“本地消息表 对账补偿”方案。核心思路是订单服务和库存服务各自维护自己的数据库事务跨服务一致性通过消息队列和定期对账来保证。举一个具体流程。用户下单后订单服务在本地事务里创建订单并同时插入一条“待发送扣库存消息”到本地消息表。事务提交后后台任务扫描消息表把消息投递到 MQ。库存服务消费消息后执行库存扣减扣减成功后再发送“扣减成功”消息。订单服务消费到成功消息后把订单状态改为“待支付”。这个方案有一个核心约束本地事务和消息写入必须在一个事务里否则就会产生消息丢失。具体到实现上就是不能用“先更新业务表再发 MQ”而是把 MQ 消息先落到本地消息表再异步投递。这一步很关键很多坑都出在这里。5.3 库存服务的本地消息表建模本地消息表结构很简单核心字段包括消息 ID、业务类型、业务键、消息内容、状态、重试次数、下一次执行时间。建表 SQL 大致如下CREATE TABLE local_message ( id bigint NOT NULL AUTO_INCREMENT, biz_type varchar(32) NOT NULL COMMENT 业务类型ORDER_CREATED, STOCK_DEDUCTED, biz_key varchar(64) NOT NULL COMMENT 业务唯一键订单号等, message_body text NOT NULL COMMENT 消息内容JSON, status tinyint NOT NULL DEFAULT 0 COMMENT 0待发送 1已发送 2成功 3失败, retry_count int NOT NULL DEFAULT 0, next_retry_time datetime DEFAULT NULL, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_biz_key (biz_type,biz_key) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;后台会有一个定时任务每隔 100ms 扫描一批状态为待发送且到达重试时间的消息投递到 MQ投递成功后更新状态。如果投递失败消息会进入重试队列超过最大重试次数后转入人工处理。这套方案虽然做不到强一致但在秒杀场景下完全够用。库存扣减失败导致的最终不一致可以由实时对账系统发现并补偿。这也提醒我们分布式事务没有一个银弹强一致和最终一致需要根据业务场景做取舍。6. 上线后的效果与还能踩的坑6.1 灰度上线后的数据变化整个对账与补偿系统上线后我们先用压测流量跑了一轮重点验证两个指标超卖数量和补偿延迟。压测数据很直观指标上线前上线后模拟超卖数量12000超卖识别时间T1 发现秒级触发补偿完成时间手动处理 1 天自动处理 5 分钟库存对账偏差率3.2%0.02%真实活动上线时我们专门盯了一个 SKU 的库存链路。活动开始后订单量瞬间冲高但因为 Sentinel 匀速排队限流数据库没有再被打垮。对账系统每 10 秒刷新一次对账宽表当晚共触发 17 次自动补偿全部是支付超时导致的库存锁定后释放没有产生新的超卖。6.2 三个容易忽略的隐藏问题第一个问题是补偿任务可能误杀正常订单。自动补偿对“已支付且无库存”的订单执行关单退款的策略如果库存数据本身存在短时抖动就可能把正常订单关掉。我们的解决办法是加了一个“冷静期”机制超卖异常触发后先标记不立即关单等 30 秒二次对账再执行补偿。同时对关单操作设置单次阈值比如单次补偿超过 100 单就必须人工确认。第二个问题是消息重复消费。本地消息表投递到 MQ 时可能因为网络原因导致消息重复。我们给每条消息都绑定了业务键并在消费端做了幂等判断消费前先查记录处理过就直接返回。这个和补偿任务的幂等设计是同一个思路不能省。第三个问题是监控指标不能只盯库存余额。库存余额为 0 不代表没有超卖可卖库存的公式是“商品总库存 - 锁定库存 - 已售库存”。我们后来专门建了一个“可卖库存”的实时监控项一旦可卖库存低于安全水位就提前告警而不是等看到负数才处理。顺便回应一下最近总有人问的 SAP 库存问题。很多搞 ERP 的同学会拿 SAP 里的 Q 库存、批次级库存估价来对比互联网库存模型我的观点是这俩根本不是一个物种。SAP 的库存更关注物料管理、批次追溯和财务估价而互联网秒杀库存关心的是高并发下的原子性和最终一致性。如果你在面试时被问到库存扣减最好先问清楚对方是互联网实时扣减还是 ERP 库存管理否则很容易答偏方向。6.3 后续演进从“补偿”到“预防”实时对账和自动补偿本质上还是“出了问题后快速纠正”的手段。真正的高并发库存系统应该把重心放在“预防”上。我们后续的规划是把库存抽成独立的库存中心提供预占、确认、释放三个标准接口。下单不再直接扣库存而是先预占库存支付成功后再确认占用超时未支付自动释放。这样订单状态和库存状态可以解耦预占动作本身天然支持幂等超卖的概率会进一步降低。我个人在实际操作中的体会是库存系统最重要的是留痕。不管用 Redis 还是 MySQL每个扣减动作都必须有流水每一条流水都能和订单、支付串起来。只要链路完整、对账及时哪怕真的出了负库存也能在几分钟内定位到根因并自动修复。这个思路本身比任何花哨的 AI 算法都更重要。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进