上个周三下午,我盯着监控面板,库存数字从50变成-7。那一瞬间,我明白今天晚上不用睡了。线上高并发抢茅台的活动中,数据库里库存直接被扣到负数,用户投诉和系统告警同时涌进来。这是典型的超卖事故,而更麻烦的是,我必须在最短时间内把账对清楚,再把多扣的订单处理掉。那天晚上我做了一套看起来不起眼、但真正救命的方案——AI实时对账加自动补偿,今天把整个设计和落地过程完整写出来。
这套方案解决的核心问题,不是“怎么把库存扣对”这种单点问题,而是“扣错了怎么第一时间发现、怎么自动纠正、怎么避免连锁反应”。它适合所有面临高并发秒杀场景的后端团队,尤其是负责订单、库存、支付这类核心链路的人。无论你用的是Spring Boot、Spring Cloud还是其他技术栈,里面关于数据一致性、对账指标、补偿状态机的思路都可以直接抄作业。
1. 库存扣成负数,问题到底出在哪
1.1 事故现场:库存被扣成负数是怎么发生的
先还原一下当时的场景。我们的抢购链路是标准的四级结构:Nginx网关、应用集群、Redis缓存、MySQL数据库。用户在页面上点“立即抢购”,请求打到应用层,应用先通过Redis预扣库存,扣成功后再异步写订单,最后再从库里扣减最终库存。
这套链路在平时的流量下没什么问题,问题是活动当天流量直接是平时的十几倍。Redis预扣那一步扛住了压力,但订单结算那个环节开始积压,大量请求堆积造成了重复处理。再加上应用层没有做严格的幂等控制,同一个用户的同一笔订单被结算线程处理了两三次,每次成功处理都会去执行一次库存扣减。最终结果就是,Redis里的预扣库存已经归零甚至出现负数,MySQL主库里的库存也被扣穿。
这里我想先给一个基础概念:所谓“扣库存”,本质上是一个读改写的过程。读一下当前库存,判断是否大于0,如果大于0就减1,再把结果写回去。在高并发下,多个请求同时读到同一个库存值,同时判断“大于0,可以扣”,然后一起把减1后的结果写回去,就出现了多个请求共同消费同一份库存的情况。库存从1变成-1,再变成-2,就是这个原因。
1.2 为什么常规的乐观锁和事务没有拦住
很多团队的第一反应是,我们用的事务和乐观锁,怎么会超卖?这里有一个非常容易被忽略的坑。
事务确实能保证一组操作的原子性,但原子性不等于并发安全。假如你的扣减SQL是update stock set quantity = quantity - 1 where product_id = x,这行SQL在MySQL的默认隔离级别下,依靠行锁是能保证并发安全的。但如果你的SQL先select quantity查出库存,在代码里判断库存大于0,再执行update,你查到的就是一个快照值,判断和更新之间隔了并发窗口,超卖就发生了。
我们当时的问题是出现在订单结算环节。结算服务从MQ里拉取消息后,先查订单状态,再更新订单,最后扣库存。查询订单状态和扣库存并不是一个原子操作,同一个订单的消息因为消费失败被重复投递,消费端拿到重复消息后又没有通过唯一键去重,于是同一个订单被结算了两次,库存就这么被多扣了。
所以,库存扣成负数,表面上是并发问题,本质上是链路里的幂等性和资源竞争控制没做好。这也是我后来把所有扣库存操作全部收敛到一处、用Lua脚本保证原子性、再加一层实时对账的根本原因。
1.3 库存扣成负数为什么必须马上处理
可能有人觉得,库存扣成负数,把负数数字回滚成0不就行了,有什么大不了的。问题远没有这么简单。
用户侧,超卖意味着用户下了单,但你根本没货可发。如果不处理,平台要给用户发货,就得去市场高价采购,直接亏钱。如果直接取消订单,又会有大量投诉和赔付,活动口碑也崩了。财务侧,每一笔扣减对应的都是真实的资金流水,库存负数意味着账面上“卖出去的商品”比“实际库存”还多,财务怎么对账都对不齐。系统侧,负库存会反过来影响后续的库存查询、补货计划、活动额度统计等多个模块,牵一发动全身。
所以当监控面板上出现负库存的那一刻,我需要的不只是一个修复方案,而是一整套“发现问题、定位问题、纠正问题”的机制。实时对账就是发现问题的那只眼睛,自动补偿就是纠正问题的那双手。
2. 实时对账与自动补偿的整体设计思路
2.1 对账的目标:账平、账准、账快
做实时对账之前,我先把“账”的定义理清楚。这里的“账”不是财务会计里的账本,而是业务系统里各种计数之间的一致性关系。电商场景中,最核心的对账关系是:订单成功数、库存扣减数、支付成功数、发货数,这几个数字之间必须满足固定的等式关系。
以库存为例,核心等式是:期初库存减累计扣减加累计回补等于当前库存。如果系统里存在“订单成功,但库存没有扣减”,或者“库存扣减了,但没有对应的订单”,就说明账不平。账不平要么是代码bug,要么是数据异常,无论哪种都要立刻暴露出来。
实时对账的指标设计,我是按三层来做的。第一层是总账,看订单总数、扣减总数、回补总数、剩余库存之间的勾稽关系;第二层是按商品维度看,每个SKU的独立账目;第三层是按用户维度看,每个用户的订单和扣减记录是否一一对应。三层账目互相印证,哪一层出现偏差都能迅速锁定范围。
2.2 “账快”:为什么必须实时而不是T+1
很多人做对账第一反应是跑批,每天凌晨跑一次脚本,第二天看结果。这个方案在低流量场景下够用,但在抢购活动里完全不适用。你想,活动晚上8点开始,如果凌晨2点才发现库存扣成负数,用户早就在社交平台上抱怨一晚上了。
实时对账的价值不在于技术上的“实时”两个字,而在于把发现异常的窗口从小时级压缩到秒级。库存扣成负数的前几秒,异常订单量通常还很小,可能只有十几笔。这时触发自动补偿,纠正成本极低。如果等到第二天,异常可能已经被数据修复、人工录单、客服改单等操作叠加,原始现场完全被破坏,再想自动处理就难了。
我们采用的技术方案是,把订单和库存操作事件实时发到Kafka,对账服务消费事件流,用Flink做流式关联计算,窗口设置为5秒滚动窗口。每5秒算一次各类计数的勾稽关系,偏差率超过阈值就触发告警和补偿评估。这算是一套典型的流式对账框架,核心就是事件流加窗口聚合。
2.3 AI在这套方案里到底扮演什么角色
这个点我必须说清楚,因为现在AI这个词被用得太泛了。我这里的“AI实时对账”,不是用了什么大模型,而是用了一套轻量级的机器学习方法来辅助异常判定和阈值调整。它的核心作用是解决传统规则引擎最头疼的两个问题:阈值怎么定,以及怎么适应流量波动。
先说阈值。库存扣减偏差的阈值如果设得太严,正常流量波动就会频繁误报;设得太松,真实异常又会被漏掉。我们早期用固定阈值,结果活动开始后报警消息刷屏,全是误报。后来我换了一个思路:让模型学习过去7天同一时段的库存变化规律,实时计算当前扣减速率的正常区间,超出正常区间才判定为异常。这里用到的主要是统计上的滑动平均加标准差,再加上一个简单的自适应漂移检测,整体逻辑非常简单,但效果比固定阈值好很多。
再说流量波动。秒杀场景有一个特点,流量不是在一天之内平滑分布的,开场前几分钟是洪峰,之后快速回落。固定阈值不可能同时适配洪峰和低谷。AI或者说自适应模型,能够跟着近期窗口的均值走,洪峰时阈值自动放宽,低谷时阈值自动收紧。这才是它最大的价值。
2.4 自动补偿的策略设计
对账发现异常之后,不能马上无脑补偿。补偿动作本身有代价,比如退款要经过支付渠道、库存回补要动主库数据、通知用户要发短信,这些操作如果做错,比不做的损失还大。
所以补偿策略我设计了四个等级。一级是只记录,对账发现账不平,但偏差在容忍范围内,只生成对账差异记录,不采取自动动作。二级是自动重试,偏差已经确认,但可能是单笔漏扣或重复扣,先通过幂等重试把账补平。三级是自动退单,确认超卖,自动取消受影响订单并原路退款。四级是冻结人工,涉及金额较大或影响面广,自动补偿流程先冻结任务,转人工处理。
自动补偿还有一个前提是记录所有补偿动作的审计日志。补偿动作包括:谁触发、处理了哪些订单、执行了什么操作、操作前后的数据状态,全部落库。这不仅是安全要求,更是事后复盘和追责的依据。
3. 核心代码与落地实现
3.1 第一步:先把扣库存改成原子操作
在讨论实时对账和补偿之前,先把最根本的扣减路径修好。我们用Redis加Lua脚本做预扣减,保证判断和扣减是原子操作。Lua脚本的好处是Redis是单线程模型,执行脚本时不会被其他命令插入,判断和写回一气呵成。
以下是我们后来上线的库存预扣减Lua脚本:
-- KEYS[1]: 库存key -- KEYS[2]: 已扣减用户集合 -- ARGV[1]: 用户ID -- ARGV[2]: 扣减数量 -- ARGV[3]: 总库存 local current = tonumber(redis.call('get', KEYS[1]) or '0') local need = tonumber(ARGV[2]) if current < need then return -1 end redis.call('decrby', KEYS[1], need) redis.call('sadd', KEYS[2], ARGV[1]) return current - need这个脚本做了两件事:先判断库存够不够,够则减库存同时把用户记录下来。sadd这一步是幂等用的,同一用户同一活动只能扣一次。
MySQL这一步,也统一收敛成了一条原子SQL:
update stock set quantity = quantity - #{quantity} where product_id = #{productId} and quantity >= #{quantity}注意这里的and quantity >= #{quantity},它确保扣减操作只会发生在库存足够的情况下。执行结果返回0代表扣减失败,代码层直接返回“库存不足”,不会出现库存被扣成负数的问题。
这一条修完后,新的超卖数据从源头上被堵住了。但已经发生的超卖怎么办?就需要靠对账和补偿来清算了。
3.2 第二步:构建实时对账事件流
扣库存操作修好后,我开始搭建实时对账的事件流。所有影响库存的操作,包括预扣减成功、订单创建成功、订单取消、库存回补、支付成功,都会发送一条事件到Kafka指定topic。
事件的格式统一为:
{ "eventId": "uuid", "eventType": "STOCK_DEDUCT", "productId": "P001", "orderId": "O20231107180001", "userId": "U10086", "quantity": 1, "stockAfter": 49, "occurTime": 1699351200123 }stockAfter字段是本次操作后的剩余库存值,这个字段在对账时非常有用,可以直接用来做连续性校验。
Flink消费这个topic,按productId分组,开5秒的滚动窗口,在窗口内计算:
- 当前库存快照值
- 窗口内扣减事件总量
- 窗口内回补事件总量
- 窗口内订单创建成功量
对账规则是:上个窗口结束时的库存,减掉窗口内扣减总量,加上窗口内回补总量,应当等于当前库存的实时快照值。偏差不为0时,触发异常事件输出。
这段核心逻辑用Java实现大致是这样的:
DataStream<StockEvent> stockStream = ...; stockStream .keyBy(StockEvent::getProductId) .window(TumblingProcessingTimeWindows.of(Time.seconds(5))) .process(new StockReconciliationProcessFunction()) .filter(ReconciliationResult::isAbnormal) .map(AbnormalEvent::fromReconciliation) .addSink(new KafkaProducerSink(...));关键的StockReconciliationProcessFunction里面,核心逻辑就是维护三个变量:lastStock、windowDeduct、windowRestore,窗口结束时用公式校验。如果对不上,就把差异数据输出。
这里直接捞到一个踩坑经验:Flink的窗口时间如果和处理时间混用,很容易出现前后窗口边界的数据重叠或遗漏。我后来统一使用事件时间加水位线,并且给数据流打了时间戳,尽量让事件按发生顺序进入窗口,对账准确率才提上来。
3.3 第三步:异常检测模型加持
纯粹靠固定规则对账,只能发现“账不平”的结果,很难预测“账可能要平不了”。AI的介入让系统多了一双提前预警的眼睛。
我实现的是一个轻量的自适应异常检测器,训练数据来自过去7天同时间窗口的正常指标。以“当前扣减速率”为例子,模型维护了一个滑动窗口的均值mu和标准差sigma。正常情况下,每个窗口的扣减速率都围绕均值波动。当某个窗口的扣减速率超过mu + 3 * sigma时,就判定为异常。
具体逻辑用Python展示一下,方便理解:
import numpy as np class AdaptiveRateDetector: def __init__(self, window_size=100, sigma_threshold=3.0): self.window = [] self.window_size = window_size self.sigma_threshold = sigma_threshold def update(self, rate): self.window.append(rate) if len(self.window) > self.window_size: self.window.pop(0) return self.detect(rate) def detect(self, rate): if len(self.window) < 30: return False mu = np.mean(self.window[:-1]) sigma = np.std(self.window[:-1]) if sigma < 1e-6: return abs(rate - mu) > 1.0 return rate > mu + self.sigma_threshold * sigma这个检测器本身不复杂,但非常有效。我把它包成服务,Flink每输出一个异常对账结果,都会先调用这个检测器判断当前窗口偏差是否真的值得告警,过滤掉大量因为活动预热、瞬时尖峰导致的误报。
后来我又给检测器加了一个趋势特征,不只是看当前偏差大小,还看偏差是否连续三个窗口都在扩大。连续扩大说明问题在恶化,要升级告警等级。这就是所谓的“AI”在这套方案里的实际价值,不是花里胡哨,而是让告警更聪明、更克制。
3.4 第四步:自动补偿状态机
自动补偿不是一个简单的操作,而是一套状态机流程。我定义了一个补偿任务表,每条补偿任务都有明确的状态:待处理、补偿中、补偿成功、补偿失败、需人工介入。
补偿状态机的流转逻辑如下:
public enum CompensateState { PENDING, PROCESSING, SUCCESS, FAILED, MANUAL_REVIEW }补偿任务的完整执行链路是:对账服务产出异常记录,补偿调度器把异常记录转为补偿任务,根据补偿等级决定执行路径,补偿执行器处理单条任务,处理后回调更新任务状态。
我最关心的一个问题就是幂等。补偿任务不能重复执行,同一个订单不能重复退款。所以补偿任务表里的order_id字段加了唯一索引,每次新任务插入时如果冲突,直接忽略,确保同一个订单只有一条有效补偿任务。
核心代码如下:
@Transactional public void createCompensateTask(String orderId, String reason, CompensateLevel level) { int inserted = compensateMapper.insertIgnore(orderId, reason, level); if (inserted == 0) { log.warn("补偿任务已存在,跳过: orderId={}", orderId); return; } compensateTaskService.submit(orderId); }这里insertIgnore就是利用了数据库的唯一索引做到天然去重。从根上杜绝了补偿风暴的问题。
3.5 补偿业务的落地细节
补偿等级对应的执行逻辑如下。
一级“只记录”最简单,就是把异常记录存下来。二级“自动重试”针对的是“下单成功但库存未扣”的情况,补偿器重新调用一次扣减服务,并用分布式锁防止并发重复扣减。三级“自动退单”是超卖场景的主战场:标记订单失效,调用退款接口,回补库存,发送站内信。这个流程每一步都要有审计日志,如果哪一步失败,任务状态回退到待处理,等待重试。
我特别想提一下库存回补的顺序问题。如果是超卖导致的补偿,回补库存必须发生在退款成功之后。原因很直接:退款没成功就把库存加回去,会造成“库存增加了,但订单还在占用”的账目错乱。反过来,如果退款成功但库存回补失败,库存账目会缺一块。所以补偿器的执行顺序是严格固定的:失效订单、退回款项、回补库存、更新状态、通知用户。
这一套流程下来,我们那晚处理了几百笔超卖订单,每笔平均耗时300毫秒左右,没有出现重复退款,也没有出现库存回补丢失。
4. 常见问题与排查技巧实录
4.1 对账数据延迟:对完账显示不平,吓一跳
第一版对账服务上线后,告警反而变多了。排查发现,很多“账不平”是数据还没到齐导致的假差异。比如当前窗口的扣减事件已经到Kafka,但对应订单的创建事件还在上游服务里排队,窗口一关,两边就对不上。
这个问题我花了很长时间才调好。最终方案是把Flink窗口改为事件时间窗口,并为事件设置合理的乱序容忍度。同时,在窗口结束时,不是立即判定,而是延迟5秒再触发对账计算,给迟到的数据一个缓冲时间。
如果你也做实时对账,遇到“一开始对不平,过几分钟又自动平了”的情况,大概率就是数据延迟导致的假差异。先去查数据产生时间和到达时间的差值,再调整窗口机制,不要急着改对账规则。
4.2 补偿风暴:补偿任务瞬间堆积
补偿任务是用MQ触发的,正常情况下量不大。但有一次因为缓存崩溃,大量订单同时补偿成功,导致回补库存的操作把数据库打满了。这属于补偿自身的连锁反应问题。
解决办法是给补偿加上流量控制。我用一个信号量控制并发执行的补偿任务数量,同时给每一批补偿做分批提交。具体来说,补偿调度器每次只从任务表里取50条,执行完后取下50条。这样即使异常量很大,对下游系统的冲击也是可控的。
这个思路和网上常说的Sentinel流量治理思路类似,差异点在于补偿场景不需要复杂的熔断降级,一个简单的并发限制加分批处理就够了。
4.3 误报与漏报的平衡
对账系统的指标设置,直接影响误报和漏报的比例。阈值太敏感,天天被误报骚扰,大家会变的麻木,反而掩盖了真正的风险。阈值太宽松,真正的异常又发现不了。
我反复调整后,最终采用的策略是双层告警。第一层是规则告警,账不平立即触发。第二层是AI异常检测告警,用于过滤规则告警的真实度。两层都满足才升级为P0级别的实时通知,只满足一层则记录成普通事件,白天再统一看。这个组合下来,误报率从最初的70%降到了10%以内,同时漏报率也维持在很低的水平。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| 对账总是显示账不平,过会儿又自己平 | 数据延迟或乱序 | 对比事件产生时间和到达时间 | 改用事件时间窗口,增加迟到容忍度 |
| 库存扣减偶尔重复 | 幂等控制缺失 | 查同一订单的扣减日志 | 增加insert ignore唯一约束 |
| 补偿任务重复执行 | 补偿任务幂等失效 | 查任务表唯一索引 | 在订单维度加唯一索引 |
| 告警风暴刷屏 | 固定阈值不适应流量波动 | 看触发告警的数据分布 | 改用自适应阈值检测 |
| 补偿回补库存后账目还是不平 | 回补顺序错误 | 查补偿审计日志 | 严格按订单失效、退款、回补的顺序执行 |
| 实时计算延迟高 | 窗口太大或计算复杂 | 查看Flink任务耗时指标 | 缩小窗口,优化状态存储 |
5. 写在最后的一些体会
这套实时对账与自动补偿方案,从我上线第一版到基本稳定,前后花了一周多的时间。现在回头看,整套系统里最有价值的并不是引入了什么高深的技术,而是把“发现问题、定位问题、修复问题”的能力转成了自动化闭环。
我个人在实际操作中最深的体会是,对账系统的核心不是工具,而是你对业务数字之间关系的理解。库存、订单、支付、物流之间的勾稽关系理得越清,对账规则写得越准。花在梳理业务流程上的时间,远比花在写代码上的时间值钱。
再分享一个小技巧:对账异常事件一定要带上上下文快照,包括当时库存值、操作时间、操作类型、关联单号。这样不仅是系统自动补偿需要这些数据,人工介入排查时也省去了从几十个系统里拼线索的麻烦。
这套方案后续还能扩展的方向很多。比如结合更多维度的数据训练异常检测模型,让AI判断哪些异常适合自动处理、哪些必须人工介入。又比如针对特定用户的抢购行为画像,把恶意刷单和真实用户误操作区分开。库存对账这件事,做到今天这个程度也只是及格线,想要让系统的数据一致性真正可靠,永远有下一公里要走。