库存超卖发生后,最容易出现的误判是“数据库扣减语句有问题,赶紧加一把分布式锁”。我处理这类问题时,通常先反问三个问题:超卖的口径是什么、谁是库存事实源、一次业务请求到底改变了几次库存。很多事故并不是某一条 SQL 单独失效,而是查询与扣减分离、重复重试、取消未回补、缓存与数据库口径不一致,最终让订单数量、库存数量和履约数量分别得出了不同答案。真正可靠的做法,是建立从请求到库存流水的证据链,再用原子扣减、幂等、事务和对账把一致性风险控制在可发现、可恢复的范围内。
数据库存:技术负责人场景拆解:超卖排查如何做到保证扣减一致性
在技术讨论中,“库存”经常被当成一个单一字段。但在真实交易系统里,至少存在可售库存、锁定库存、已售库存、展示库存和缓存库存。它们的变化时点不同,是否允许回补也不同。如果团队没有先定义这些口径,任何“库存对不上”的现象都可能被误判为数据库故障。
例如,商品初始库存为 100 件,系统允许下单即锁定库存。用户下单 80 件、支付成功 60 件、取消订单 20 件,此时可售库存、锁定库存和已售库存的数值并不相同。若有人拿“成功支付订单数”直接与“当前可售库存”相减,结果一定会出现偏差,但这不一定构成超卖。
我判断超卖时采用的底层公式是:有效占用量不能超过业务规则允许的库存总量。至于有效占用量由锁定、支付、发货还是其他状态组成,要由订单状态机明确规定,而不能临时从某一张表里猜。
库存事实源不一定永远是一张数据库表,也可以是独立的库存账本或库存服务。但系统必须明确:哪个系统的哪一次成功操作,代表库存真正发生了变化。缓存可以承担快速拦截,消息可以承担异步传递,订单可以承担交易状态,但它们不能在没有规则的情况下同时成为库存账本。
如果订单服务认为“订单创建成功就扣库存”,库存服务认为“数据库更新成功才扣库存”,运营后台又能直接修改商品库存,那么同一件商品就存在三个扣减入口。此时,技术负责人不应该先优化锁,而应该先收回写权限,建立唯一库存变更入口。
一次扣减成功,不能只看接口返回“success”,也不能只看应用日志写了“扣减完成”。至少要同时具备业务请求号、订单号、库存流水号、数据库影响行数和操作前后库存快照。没有这些证据,事故复盘时只能凭时间戳和猜测拼接过程。
数据库层最基础的成功判定,是条件更新后的影响行数。影响行数为 1,表示满足条件并完成扣减;影响行数为 0,只能说明库存不足、记录不存在或条件没有满足,不能被应用层当成成功。

下面使用一组脱敏后的情景模拟数据说明排查方法,不代表某一家公司的生产统计。某活动商品初始可售库存为 1000 件,活动期间库存表最终显示剩余 12 件,数据库中从未出现负数,但有效支付订单对应的履约数量达到 1018 件。表面看库存字段很健康,业务上却已经多出了 18 件订单。
继续拆分流水后,发现订单服务创建了 1035 个锁定记录,其中 17 个订单在超时关闭后没有完成回补;同时,某个客户端在网络超时后重试,造成 18 个请求对应 9 个重复业务单号。库存表中的扣减次数为 1000 次,但订单状态和库存流水之间并没有一一对应关系。
这个案例说明,“数据库库存没有小于零”只能证明某个字段没有越界,不能证明订单、库存和履约三者一致。如果业务规则要求“支付成功即占用库存”,就必须检查支付成功量;如果规则要求“下单锁定即占用库存”,就必须检查锁定记录、取消回补和回补失败重试。
超卖事故发生后,最忌讳一边继续放量,一边修改库存字段。技术负责人应先冻结人工调库存、补偿脚本和非必要的重试任务,保留原始流水,再决定是否限流或暂停售卖。直接把库存改回一个“看起来正确”的数字,可能会破坏后续对账证据。
我通常会要求保留四类快照:事故发生前的库存快照、事故期间的扣减流水、订单状态快照、消息消费和回补任务状态。快照要带采集时间、数据库实例、商品或 SKU 标识,不能只导出一个当前库存值。
排查顺序应从一个具体业务单号开始。先查它是否产生过重复请求,再查订单状态变化,然后查库存流水,最后核对数据库更新结果和消息记录。一个请求可能经历“接口超时,客户端重试,第一次实际成功,第二次幂等拦截”的过程,如果只看接口响应,容易把一次扣减看成两次失败。
建议在日志中统一记录 request_id、idempotency_key、order_id、sku_id、stock_before、stock_after、affected_rows、transaction_id 和 message_id。字段名称可以不同,但语义必须统一,否则跨服务检索时会因为字段缺失而断链。

最常见的代码逻辑是先查询可用库存,应用层判断库存是否大于购买数量,再执行减法。单线程测试中这段代码完全正常,但在两个请求并发访问同一 SKU 时,两个请求都可能读到库存为 1,然后都通过判断。
问题不在于查询语句本身,而在于“判断结果”与“扣减动作”之间存在时间窗口。只要两个动作不是同一个原子操作,应用层看到的库存就可能在执行扣减前失效。
-- 风险写法:查询与扣减之间存在并发窗口 SELECT available_stock FROM product_stock WHERE sku_id = :sku_id; -- 应用层判断 available_stock >= :quantity UPDATE product_stock SET available_stock = available_stock - :quantity WHERE sku_id = :sku_id;
分布式锁的作用是让多个执行者在某一时间段内协调访问,但它不等于数据库约束。锁服务可能发生超时、网络分区、主从切换、客户端误释放或锁租约过期。若业务线程持锁时间超过租约,另一个线程可能获得同一把锁,数据库仍然需要最后一道条件保护。
更稳妥的组合是:锁用于降低热点竞争,数据库条件更新用于保证扣减边界,幂等记录用于防止同一业务重复执行。三者解决的是不同问题,不能把其中任意一个当成另外两个的替代品。
缓存预扣减可以有效挡住大量库存不足请求,也能减少数据库热点行的竞争。但缓存操作和数据库事务通常不在同一个原子边界里。缓存扣成功而数据库写失败时,必须回补;数据库成功而消息发送失败时,也必须让后续任务能够补发或对账。
如果团队只能回答“Redis 扣减失败怎么办”,却回答不了“数据库成功、消息丢失、订单最终取消时怎么办”,说明当前方案只设计了正向路径,没有设计库存状态机。
数据库库存为正只说明当前字段仍有余额。它可能没有扣除已经锁定但未支付的订单,也可能因为取消订单没有回补而偏小。更隐蔽的情况是,库存服务按 SKU 扣减,订单服务按 SPU 统计,商品包含多个规格时,汇总口径不一致也会造成“库存正常、履约超卖”的结果。
事务只能保证它覆盖的数据库操作具有原子性。例如订单表、库存表和库存流水表在同一数据库事务中提交,可以保证这三张表不会只成功一半。但缓存、消息队列、支付网关和仓储系统不在这个事务内,提交成功不代表外部系统已经知道结果。
我更愿意把事务理解成局部一致性的边界,而不是全链路一致性的承诺。跨系统场景必须额外设计消息重试、状态机、补偿任务、幂等和对账。
把所有请求放进一把大锁里,确实可以降低并发冲突,但会显著放大等待时间、连接池占用和故障影响范围。库存系统的目标不是让所有请求排队,而是在明确约束下,让有效请求原子地竞争库存,让无效请求尽早被拦截。

对于单 SKU、直接扣减的场景,最小可用方案通常是带条件的原子更新。示例 SQL 如下:
UPDATE product_stock SET available_stock = available_stock - :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND :quantity > 0 AND :quantity <= max_purchase_quantity AND available_stock >= :quantity;
执行后必须立即读取 affected_rows。若 affected_rows 等于 1,才允许继续创建或确认对应业务状态;若为 0,应区分库存不足、SKU 不存在、购买数量非法等原因,而不是统一返回“系统繁忙”。
如果扣减和库存流水、订单状态需要一起落库,应该放入同一数据库事务。库存流水建议使用唯一幂等键,例如 order_id 加 operation_type,避免事务重试时生成重复扣减记录。
乐观锁通常通过 version 字段实现:读取版本号后,更新时要求版本号仍然相同。它适合冲突相对可控、需要检测并发修改的业务,但在极热点 SKU 上可能产生大量更新失败和重试,重试又会增加数据库压力。
UPDATE product_stock SET available_stock = available_stock - :quantity, version = version + 1 WHERE sku_id = :sku_id AND version = :version AND available_stock >= :quantity;
直接条件扣减和版本号乐观锁并不是非此即彼。库存不允许为负是硬约束,version 主要用于识别并发修改和控制状态更新。若业务只需要保证“库存足够才扣”,条件更新往往已经是更简单的底线方案;若还需要精确检测库存对象是否被其他流程修改,再增加版本约束。
一个常见错误是开启数据库事务后,先锁库存,再调用支付、优惠、物流等外部服务,最后才提交。外部调用的延迟会直接延长数据库锁持有时间,热点 SKU 可能因此产生大量锁等待。
更合理的边界通常是:在本地事务内完成库存状态和订单预占记录,然后提交;通过可靠消息或任务驱动后续支付、取消和回补。这里的关键不是“全部异步”,而是明确每个状态的含义和允许的下一步。
库存操作需要区分业务失败和技术失败。库存不足属于确定性业务失败,不应无限重试;数据库连接断开、事务提交结果未知,则属于技术不确定,需要通过幂等流水查询或业务状态确认后再决定是否重试。
特别要注意“客户端超时但数据库已经提交”的情况。此时如果客户端重新发起一个没有幂等号的新请求,系统可能再次扣减。接口必须要求业务方传递稳定的幂等键,不能用每次重试都变化的随机请求号代替。

以下是一组用于说明方法的样本推演。某活动 SKU 初始库存 5000 件,活动持续 15 分钟,进入库存接口的请求为 420000 次。经过资格校验和限流后,进入库存服务的请求为 62000 次,最终创建订单 5072 笔,数据库成功扣减流水 5000 笔。
如果只看数据库库存,结果是剩余 0 件,似乎没有负库存问题。但进一步核对发现,5072 笔订单中有 51 笔被判定为支付成功,另外 21 笔处于待取消状态。也就是说,业务上已经出现 72 笔无法由现有库存覆盖的订单。
继续查看库存流水后,发现 43 笔订单使用了同一用户和 SKU 的重复请求,9 笔订单是消息重复消费,20 笔订单的取消回补任务失败。不同问题叠加后,数据库“扣了 5000 次”这个数字仍然成立,却无法解释订单侧的有效占用量。
第一步是按 idempotency_key 聚合请求。如果同一个幂等号关联两条以上扣减流水,说明幂等约束没有真正生效,或者流水写入不在扣减事务内。这里不能只查应用日志,因为应用日志可能记录了请求,但未必代表数据库提交成功。
第二步是按 order_id 和 operation_type 查询库存流水。一个订单最多应该有一条成功的正向扣减流水;如果订单取消后出现两条回补流水,就要检查取消事件是否重复投递,以及回补接口是否具备唯一约束。
第三步是核对消息消费状态。消息重试并不一定是故障,真正需要关注的是重复消费之后是否重复改变了库存。消费者应先用消息 ID 或业务操作号做幂等判断,再执行回补或扣减。
修复并不是简单把库存调大,而是同时完成四件事:将库存扣减改为条件更新;为正向扣减和回补建立唯一幂等键;把订单预占、库存变化和库存流水放入同一数据库事务;为取消回补增加可重试任务和死信告警。
验证时需要重新执行三类测试。第一类是同一幂等号并发提交,预期只有一笔成功扣减。第二类是库存为 1 时并发提交多个不同幂等号,预期只有一笔 affected_rows 为 1。第三类是模拟数据库提交成功但客户端超时,重试同一幂等号时预期返回原处理结果,而不是再次扣减。
我不会只看压测的平均响应时间。还会看库存负数次数、成功扣减流水数、成功订单数、重复消费次数、回补失败数和最终对账差异。平均响应时间下降,但对账差异上升,不能算方案成功。

修复后至少应满足以下关系:成功扣减流水的幂等键无重复;同一订单的正向扣减最多一条;取消订单的回补操作最多成功一条;有效订单占用量不超过库存事实源允许的上限;缓存库存与事实源的差异能够在规定时间内收敛。
如果系统采用异步回补,短时间内存在差异并不一定错误,但必须有最大收敛时间。例如要求 5 分钟内完成回补,超过时间就进入告警和人工核查。没有收敛目标的“最终一致性”,在生产上往往只是把问题推迟。
缓存预扣减至少会产生三类结果:成功并进入订单创建、成功但后续业务失败、缓存操作失败。第一类需要继续落库,第二类需要回补,第三类需要决定是直接转数据库校验还是返回稍后重试。
回补操作不能简单执行“库存加一”。如果同一个订单的取消消息重复到达,执行两次加一就会制造新的库存虚增。因此回补也必须带业务幂等号,并记录操作前后库存。
在多数消息系统中,消费端更现实的目标是“至少一次投递加消费幂等”,而不是把所有希望寄托在绝对只消费一次。消息可能重复、延迟或乱序,库存状态机必须能够识别这些情况。
例如订单先收到取消事件,再收到支付成功事件,若没有状态版本或状态转移规则,两个消费者都可能认为自己有权修改库存。建议为订单状态附带版本号,只有满足预期前置状态时才允许推进,过期事件进入补偿队列。
一个可靠的回补任务至少需要记录待处理、处理中、成功、失败待重试和人工介入几种状态。重试次数、下一次执行时间、最后错误原因和关联订单号都应该保留。
无限重试会造成两个风险:技术故障恢复后消息集中重放,导致数据库热点;业务数据本身不满足回补条件时,任务会不断制造噪声。达到重试上限后,应进入死信或人工审核,不应静默丢弃。
缓存失效后,重建动作应从库存事实源或库存账本读取,而不是从某个可能已经过期的订单汇总表读取。重建期间还要考虑并发扣减,否则可能出现“刚重建完成就被旧值覆盖”的问题。
如果缓存只是前置拦截层,重建错误的影响可能表现为多放行一些请求,最终由数据库条件更新拦截;如果缓存被当成最终扣减账本,重建错误则可能直接造成库存损失。因此两种架构的容错边界完全不同。

如果单个 SKU 的并发不高,建议优先采用数据库条件更新、同库事务、库存流水和业务幂等。没有明显热点时,直接增加缓存预扣减只会引入回补、重建和对账成本。
高并发活动的主要矛盾是请求量远大于有效购买量。此时不能让所有请求都直接竞争数据库热点行,应在网关、资格校验、用户限购、缓存预校验和队列入口处逐层过滤。
但前置层只能减少无效请求,数据库或库存账本仍要承担最终扣减责任。异步下单也不能省略幂等和订单状态机,否则只是把同步超卖变成延迟超卖。
预售场景中,库存可能被锁定数小时甚至数天。此时最重要的不是瞬时吞吐,而是锁定过期、支付失败、退款、拆单和部分履约的状态管理。
建议把可用、锁定和已售拆成清晰字段或账本事件,禁止通过多个服务之间的隐式约定推算库存。每一种状态转移都应该有唯一业务事件和可重放记录。
多仓场景要明确库存是按仓库、区域还是商品总量扣减。多规格商品要明确 SKU 是否独立占用库存,组合商品则要明确一个组合订单如何同时占用多个子 SKU。
组合扣减最容易出现部分成功。例如组合商品需要 A、B 两个 SKU,A 扣减成功而 B 库存不足,如果没有同一事务、预占协议或补偿机制,就会留下 A 已扣但订单未创建的孤立占用。
不少团队只关注用户下单,却允许运营人员直接修改库存字段。事故发生后,发现库存差异来自人工调账、临时脚本或一次性补货任务。后台调整也必须经过库存变更接口,记录操作者、审批单、原因和前后值。

前两小时的目标不是立刻重构系统,而是止损和保留证据。可以按以下顺序执行:
这个阶段不要直接执行“库存加回去”这样的修复 SQL。若必须止损,应使用带审批、带流水的库存调整操作,并把调整单与事故编号关联,否则后续对账无法区分原始异常和人工修复。
短期修复应优先补齐数据库边界和可追溯性,而不是先引入复杂中间件。建议在一周内完成以下工作:
仅做正常压测不能验证一致性。应设计包含客户端超时、数据库连接中断、消息重复、消息延迟、订单取消、缓存重启和消费者重启的故障场景。
每次演练都要给出可量化结果:最大并发请求、数据库更新成功数、重复业务请求数、回补任务数、最终库存差异、最大收敛时间和人工介入数量。没有结果指标的演练,往往只能证明“流程跑过”,不能证明系统能恢复。

直接使用数据库条件更新,最大的优点是事实边界清晰、实现简单、排查成本低。对于普通交易、库存热点不高的商品,它往往比“缓存预扣减加异步队列”更容易维护。
它的边界也很明显:当大量请求竞争同一库存行时,数据库会出现锁等待、连接池堆积和事务响应变慢。此时应先确认是否可以通过限流、用户资格过滤、拆分库存或排队降低竞争,而不是马上把一致性责任转移到缓存。
缓存预扣减适合请求洪峰明显、库存量有限且用户有效购买率很低的场景。它能快速拒绝大部分库存不足请求,减少数据库热点压力。但它需要可靠回补、缓存重建、消息重试和对账,运维复杂度会明显上升。
如果团队没有成熟的任务平台、告警体系和人工介入流程,我不建议把缓存直接设计成最终库存账本。性能收益再高,也不应该以无法解释和恢复库存差异为代价。
分布式锁适合需要协调多个服务或多个资源的场景,例如同一订单涉及多个库存对象,或者需要保护某个短时状态转换。它可以降低并发冲突,却会增加锁服务依赖、续期、超时和故障转移问题。
对于单行库存扣减,数据库条件更新通常已经提供了必要的原子边界。若再叠加分布式锁,应能明确说出锁解决了什么额外问题,否则很可能只是增加了故障面。
队列可以削峰、排序和隔离突发流量,但它把用户的即时成功感变成了“请求已受理”或“排队中”。这要求前端、订单和客服流程都能接受延迟确认,也要求消费失败、重复消费和死信处理足够成熟。
如果业务必须在几百毫秒内明确告诉用户是否买到,纯异步方案可能不适合;如果业务更关心峰值稳定和最终订单处理能力,则队列通常更有价值。

当前库存只能告诉你结果,不能告诉你过程是否发生过重复执行。更有价值的指标包括库存更新影响行数为 0 的比例、同一幂等键出现次数、订单与扣减流水的差值、回补失败数以及消息重复消费数。
例如,影响行数为 0 的比例突然从 30% 上升到 90%,可能意味着库存已经售罄,也可能意味着数据库索引失效、SKU 参数错误或事务条件不匹配。监控要配合错误分类,否则一个“库存不足”指标会掩盖真正的系统故障。
库存表适合提供当前状态,库存流水适合回答“为什么变成这样”。流水至少应记录 SKU、业务单号、操作类型、数量、操作前库存、操作后库存、幂等键、请求时间、操作者和处理结果。
操作类型要足够明确,不能只记录“变更库存”。建议区分正向扣减、预占、支付确认、取消回补、退款回补、运营调增、运营调减和异常修复。操作类型越模糊,后续对账越依赖人工解释。
一个常见的基础核对关系是:
可用库存
= 初始库存
当前锁定库存
已售库存
+ 已确认回补库存
其他有效扣减库存
但这不是所有业务的通用公式。若支付成功后才转为已售,支付前的订单应计入锁定;若仓库拣货后才算最终占用,发货与支付的关系又不同。公式必须从订单状态机推导,不能从某次事故的数字倒推。
跨系统短暂不一致可以接受,但必须被量化。例如缓存与数据库允许短时间存在少量差异,回补任务允许在 5 分钟内完成,超过时间则触发告警。没有阈值和时限,所谓“最终一致”就没有可验收标准。
对于高价值商品或库存数量极少的商品,阈值应更严格。库存只剩 1 件时,任何一次回补失败都可能直接影响履约;不能用大促整体平均差异掩盖单个热点 SKU 的严重异常。

如果数据库条件更新没有库存条件,或者查询与扣减分离,优先修复数据库边界。如果数据库扣减严格、流水无重复,但支付、取消和履约数量对不上,问题更可能出在业务状态机或库存口径。
两类问题的修复路径不同。前者需要改 SQL、事务和幂等;后者需要重新定义锁定、售出和回补状态。把业务口径问题当成数据库锁问题,往往会花很多时间却没有真正减少异常。
如果团队无法回答“某一笔订单是否扣过库存”,不应该优先优化吞吐。没有流水、幂等和统一请求标识,吞吐提升只会让问题更快发生,也更难复盘。
如果证据链已经完整,且确认热点行锁等待是主要瓶颈,再考虑缓存预校验、排队、库存分片和并发控制。性能优化应建立在知道瓶颈在哪里的基础上。
技术故障导致的消息延迟、数据库暂时不可用,通常可以通过重试和幂等自动恢复。订单状态冲突、人工调账缺少依据、组合商品部分扣减,则可能需要人工审核。
系统应明确人工介入的边界和操作权限。人工修复不能直接改汇总库存,而应生成有审批、有原因、有操作者和有前后快照的调整流水。
每增加一个缓存、锁服务或消息队列,就增加一个状态同步点和故障边界。组件数量不是架构成熟度的证明,能够解释每个组件的职责、失败方式和恢复路径,才是成熟度。
我更认可这样的方案描述:“缓存负责前置拦截,数据库条件更新负责最终扣减,事务负责同库绑定,消息负责异步推进,幂等负责重复执行,对账负责发现遗漏。”如果一个方案无法用这类职责边界讲清楚,通常还没有真正设计完成。
超卖问题最值得警惕的,不是偶尔出现一个负库存数字,而是系统无法解释库存为什么变化。只要存在没有业务编号的扣减、没有幂等键的重试、没有状态约束的回补、没有审计记录的人工调账,库存就可能在表面正常的情况下逐渐失真。
技术负责人真正要保证的,不是所有系统在任何瞬间都完全同步,而是库存变化具备明确事实源、原子边界、重复保护、失败补偿和最终对账能力。这五个条件缺一不可:原子边界防止并发越界,幂等防止重复执行,事务保证局部一致,消息和补偿处理跨系统失败,对账则负责发现那些自动机制没有覆盖的异常。
下一步可以从一件具体事情开始:随机抽取一笔成功订单,尝试用 request_id、order_id、库存流水号和 message_id 还原它的完整生命周期。如果在十分钟内无法回答它扣了几次、何时扣、是否回补、谁修改过库存,那么当前系统最需要的不是再加一个组件,而是先补齐库存事实和证据链。
我原来以为只要在代码里先判断库存大于 0,再执行减库存就够了,实际压测时却发现库存为 1 时,两个并发请求都可能创建成功订单。想请教一下,数据库层到底应该怎样写,才能证明扣减确实成功?
超卖排查时,我会先看“库存判断”和“库存扣减”是不是两个独立动作。如果代码先执行 SELECT,应用层判断库存充足,再执行 UPDATE,那么两个请求可能同时读到相同库存,之后分别完成扣减,这个并发窗口就是最常见的根因之一。
正确做法是把库存条件直接放进 UPDATE,让数据库在同一个原子操作中完成“判断库存是否足够”和“扣减库存”:
UPDATE product_stock SET available_stock = available_stock - :quantity WHERE sku_id = :sku_id AND available_stock >= :quantity;。应用程序必须根据影响行数判断结果:影响行数为 1,才代表扣减成功;影响行数为 0,只能判定为库存不足、SKU 不存在或条件未满足,不能继续创建成功订单。这里有一个容易被忽略的细节:SQL 正确并不代表接口一定正确。如果扣减成功后,应用因为网络超时返回失败,客户端可能重试,第二次请求又再次扣减。
因此,原子更新还必须配合业务幂等号、扣减流水和订单唯一约束。
实现方式库存为 1 时的风险我的判断 先查询再扣减并发请求可能同时通过判断不应作为库存底线 带库存条件的 UPDATE数据库层保证单次扣减条件原子执行必须采用 只依赖分布式锁锁超时、误释放或绕过锁仍可能出错不能替代条件更新 排查时不要只看代码是否出现了“锁”或“事务”,而要直接检查最终执行的 SQL、影响行数、事务提交结果和扣减流水。
能否用一条业务请求还原出这些证据,比架构图上写了多少组件更重要。
我见过一种方案:请求先在缓存里扣库存,缓存扣成功后再异步创建订单。这样看起来既能扛住高并发,也能保护数据库,但我担心缓存扣成功、数据库写失败时会不会出现少库存,甚至后续回补造成重复增加?这种方案应该怎样设计边界?
缓存预扣减解决的是流量削峰和热点拦截,不是天然的最终一致性方案。实际排查时,我会把它看成一个“预占动作”,而不是已经完成的库存扣减。只要缓存扣减和数据库写入不在同一个事务里,就必须面对两者之间的失败窗口。
例如一次示例压测中,初始库存为 100,缓存预扣成功 100 次,其中 3 次订单消息发送失败,2 次数据库事务回滚。如果没有可靠回补,缓存会显示剩余 0,但数据库只记录了 95 次有效扣减;如果补偿任务重复执行,数据库又可能被回补两次。
问题不在于缓存一定不能用,而在于预占、确认、释放三个状态没有被记录和幂等化。我建议至少为每次预占建立唯一业务流水,并明确状态流转:预占成功、订单创建成功、支付成功、订单取消、释放处理中、释放完成。
回补操作必须以流水状态作为前置条件,只有从“预占成功”或“订单取消待释放”转换到“释放完成”时,才能增加库存,不能让定时任务无条件执行加库存。
场景可能结果应对方式 缓存扣减成功,订单创建失败可用库存被错误占用记录预占流水并可靠释放 数据库扣减成功,响应超时客户端重复提交使用幂等号查询原处理结果 回补任务重复执行库存被多次增加回补状态机加唯一约束 缓存重启或数据丢失缓存口径与事实源漂移以库存账本重建并对账 我的判断是:如果业务并发并不高,直接使用数据库原子扣减、幂等和流水,通常比“缓存预扣减加异步补偿”更容易做对。
只有当数据库热点行确实成为瓶颈,并且团队有能力维护回补、重试、死信和对账闭环时,才值得引入缓存预占。
我在评审库存方案时经常看到“分布式锁加事务”的组合,大家会把它当作防超卖的核心保障。但如果某个后台脚本没有加同一把锁,或者消息重复消费,锁是不是就失效了?这三个机制的责任边界应该怎样划分?
这三个机制解决的是不同问题,不能互相替代。数据库事务负责同一个数据库事务内的原子性和隔离性;分布式锁负责多个进程之间的协调;幂等负责同一业务动作被重复执行时只产生一次有效结果。加锁后仍然超卖,通常有三类原因。第一,所有修改库存的入口没有统一经过这把锁,例如订单服务走锁,运营后台或补偿脚本直接写库。
第二,锁的有效期短于业务执行时间,锁已经过期,另一个请求又进入临界区。第三,代码虽然获得了锁,但最终库存 SQL 仍然是无条件扣减,锁一旦异常,数据库没有第二道底线。因此,库存扣减的安全顺序应当是:用幂等号判断请求是否已经处理,再在必要时用锁协调热点操作,最后依靠数据库条件更新作为扣减底线。
即使锁服务短暂不可用,数据库也不应该因为一条无条件 UPDATE 而把库存扣成负数。
机制主要解决的问题不能解决的问题 数据库事务同库内多张表的原子提交Redis、消息队列和外部服务的一致性 分布式锁跨实例并发协调重复请求、锁失效和跨系统补偿 幂等控制重试和重复消费不同业务动作之间的状态设计 条件更新库存充足条件与扣减原子执行订单取消后的自动回补 评审方案时,我不会问“有没有加锁”,而会问四个更具体的问题:库存有几个写入口?
锁失效时数据库是否安全?同一个订单重试会不会重复扣减?取消和回补是否有独立流水?这四个问题比“用了什么锁组件”更能判断方案是否可靠。
我遇到过数据库库存没有变成负数,但实际成功订单数却超过了可售库存的情况。单看当前库存值根本找不到原因,我想知道技术负责人应该保留哪些数据,怎样通过一条完整链路还原每次扣减、回补和重复处理?
当前库存值只能告诉你结果,不能解释结果是怎样产生的。排查超卖时,我更看重库存流水,因为它能把一次库存变化和订单、请求、消息、操作者关联起来。没有流水,团队往往只能依赖应用日志,而日志可能被采样、覆盖,或者无法证明数据库事务最终是否提交。
一条合格的库存流水至少应包含 SKU、业务单号、业务类型、变更数量、变更前库存、变更后库存、请求 ID、幂等键、消息 ID、操作来源、事务时间和处理结果。扣减、取消回补、支付失败回补、运营调整和人工修复都应使用不同业务类型,不能全部记成“库存变更”。
可以用一组示例数据说明对账过程:初始可用库存 100,成功扣减 82,订单取消回补 5,人工调整减少 3,那么理论可用库存应为 20。如果数据库显示 20,但有效订单、支付和履约数量相加已经达到 103,就说明问题可能不在当前库存字段,而在预占、订单状态或履约口径没有对齐。
核对项示例值排查意义 初始库存100确定本次活动的库存基线 成功扣减82核对数据库影响行数和流水 取消回补5检查是否重复或遗漏回补 人工调整-3确认后台操作是否有审计记录 理论可用库存20与数据库、缓存和订单口径比较 我的排查顺序通常是先按 SKU 汇总流水,再按订单号检查重复扣减,接着对比数据库影响行数与订单成功数,最后核对取消回补和消息重试。
若发现订单数大于扣减流水,重点查订单重复创建;若扣减流水大于订单数,重点查事务回滚后的错误记录、消息重复消费或孤儿扣减。最终要建立的是“可解释的库存变化”,而不是只设置一个库存为负告警。告警只能发现异常,对账和流水才能帮助你定位责任边界,并决定应该补库存、关订单,还是修正错误状态。


读者评论
文章把“数据库库存没变负”和“业务没有超卖”区分开了,这个口径很重要。实际排查确实不能只盯着库存表,还要核对订单状态、履约数量和回补记录。
对查询再扣减、分布式锁和条件更新的分析比较清楚。锁只能缓解并发竞争,最终仍应依赖数据库条件更新和影响行数判断,这一点对热点库存场景很实用。
文中强调保留请求号、订单号、库存流水号和影响行数等证据,比较符合事故复盘的实际需要。没有统一链路标识时,跨服务还原问题确实很困难。
缓存预扣减、数据库提交和消息发送之间的边界讲得比较客观。只设计成功流程而没有回补、重试和对账机制,往往才是库存异常长期存在的原因。
文章的排查顺序较有参考价值,先冻结写入和补偿操作,再按时间线核对请求、订单、库存及消息状态,能避免直接改库存导致证据丢失。