《数据库存:架构师风险清单:超卖排查最需警惕的缓存不同步》真正要提醒的,不是“缓存和数据库偶尔会不一致”这句常识,而是一个更危险的事实:库存超卖往往不是由某一条错误 SQL 单独造成的,而是由旧缓存、并发判断、扣减结果未校验、订单重试和库存回补失败共同拼成了一条故障链。排查时如果只盯着数据库库存表,很可能看到的只是事故最后留下的结果,而不是最早发生竞态的位置。
我在做库存类系统评审时,首先会把“缓存不一致”和“真正超卖”分开定义。数据库库存为 0、缓存仍显示 1,说明系统存在状态不同步;但如果后续数据库使用了带条件的原子扣减,并且严格检查影响行数,那么这个旧值最多造成一次错误尝试,不一定会形成成功订单。
真正的超卖,通常需要满足更严格的条件:有效订单或有效库存占用量超过可售库存,且系统没有在某个边界阻断这次请求。换句话说,缓存旧值是放大器,缺少原子扣减和结果校验才是让风险落地的关键环节。
库存系统中常见两种缓存用途。第一种是展示型缓存,用于展示“剩余库存”“可购买数量”等信息;第二种是扣减型缓存,Redis 或其他缓存组件直接参与库存预扣。前者出现旧值,主要影响用户感知和流量分配;后者出现旧值、丢失或回滚失败,则可能直接影响库存账本。
我不会因为系统使用了缓存,就直接判定它存在超卖风险。我会先问三个问题:缓存值是否参与最终扣减?数据库是否仍然有最后一道条件校验?缓存扣减成功后,订单失败是否一定有可追踪的回补?这三个问题的答案,比“使用了什么缓存产品”更能决定系统是否安全。
单独看一条日志,往往无法回答库存为什么错。一次完整排查至少要串起请求编号、商品编号、缓存键、扣减前库存、扣减后库存、数据库影响行数、订单号、消息编号和回补记录。
如果这些信息不能通过一个请求编号或订单号关联起来,系统即使拥有大量监控,也可能只能看到“库存变负了”,却无法回答“哪一个请求先读到了旧值、哪一次重试重复扣了库存、哪一条回补消息没有生效”。

一个看似简单的下单流程,实际可能同时存在商品展示缓存、库存缓存、数据库库存表、订单表、消息队列、支付状态和库存流水表。每个组件都有自己的提交时间、重试策略和失败处理方式。
例如,页面在 10:00:00.010 读取到 Redis 中的库存为 1;库存服务在 10:00:00.018 完成预扣;订单服务在 10:00:00.025 开启数据库事务;事务在 10:00:00.140 因锁等待超时;客户端在 10:00:00.200 因未收到响应再次发起请求。如果系统没有幂等键,第二次请求可能被当成一笔全新的购买行为。
这个例子里,至少有四个不同问题容易被混在一起:缓存视图是否过期、预扣是否成功、订单事务是否提交、客户端重试是否重复。它们最后都可能表现为“库存对不上”,但修复方式并不相同。
采用 Cache Aside 模式时,常见流程是先更新数据库,再删除缓存。这个模式简单、成本低,但数据库提交和缓存删除之间存在窗口。如果删除失败,缓存会继续保留旧值;如果删除后又有一个并发读请求把旧数据库内容重新写回缓存,也会重新制造脏数据。
这里最容易被忽略的细节是:“删除缓存”不是事务的一部分,除非系统明确设计了可靠的提交后通知、重试和对账机制。数据库事务成功,并不代表缓存删除也成功;消息发送成功,也不代表消费者已经完成删除。
很多高并发服务除了共享缓存,还会使用进程内缓存、网关缓存或客户端短时缓存。此时,同一个 SKU 可能同时存在四份状态:客户端看到的数量、服务节点本地缓存的数量、共享缓存中的数量和数据库中的数量。
如果只清理共享缓存,却没有清理节点本地缓存,某些请求仍会从本地缓存读到旧值。多机房部署还会进一步放大问题:一地更新成功,另一地由于复制延迟继续读取旧库存,最终出现不同地域下单结果不一致。
使用缓存原子扣减并不意味着系统只会超卖。另一种常见故障是 Redis 预扣成功,但订单创建失败,库存没有按时回补。此时系统可能少卖、库存冻结,或者在补偿任务重复执行时又产生反向错误。
因此,库存核对不能只问“有没有多卖”,还要问“有没有被错误占用”。我通常会把库存分为物理库存、可售库存、预扣库存和已支付占用四个维度,再判断差异是否符合业务规则。

数据库库存为负当然需要立即保护,但它不一定意味着 SQL 本身没有条件。更常见的情况是,SQL 已经返回了影响行数为 0,业务代码却没有判断这个结果,仍然继续写订单。
另一种情况是数据库库存表记录的是物理库存,而订单服务使用的是另一张预占表。两边分别正确,但汇总逻辑漏掉了取消订单或回补记录,最后才出现负数。排查前必须先确认库存字段的业务定义,不能只按字段名称作判断。
Redis 的原子自减能够保证同一个 Redis 实例上的操作不会被其他命令插入,但它无法保证“缓存扣减成功”和“订单事务提交成功”是同一个原子动作。
如果 Redis 扣减成功后,数据库连接池耗尽、订单写入超时或服务进程崩溃,系统就需要决定:这次预扣是保留、回补,还是进入人工核对。如果没有明确的状态机,原子自减只是把并发问题解决了一半。
分布式锁可以减少同一商品的并发进入数量,但它会带来锁等待、锁续期、节点故障和吞吐下降问题。更重要的是,锁只能保护被锁住的代码路径。如果后台补库存、定时回补或其他服务绕过这把锁,系统仍然会出现状态竞争。
我更关注锁的边界,而不是锁的名字:锁是否覆盖数据库提交?锁释放后消息是否仍可能晚到?同一订单重试是否重新获得锁?如果这些问题没有答案,锁可能只是让日志更难读。
延迟双删适合降低并发读写下旧缓存被重新写入的概率,但它不能保证删除动作成功,也不能解决消息重复、事务长时间未提交、多级缓存和跨地域复制问题。
延迟时间也不能凭经验固定。数据库最慢提交耗时、缓存回源耗时和读请求峰值都在变化。如果延迟时间短于慢事务完成时间,第二次删除仍可能早于数据库提交;如果延迟时间过长,又会扩大旧值暴露窗口。
当前值相同只能说明采样时刻的两个结果相同,不能证明中间没有出现过旧值、重复扣减或错误回补。库存系统是一个过程系统,真正有价值的证据通常来自流水和状态变更,而不是某一时刻的快照。
例如,库存从 10 变成 8,再因为两次错误回补变成 10,最终快照看似正常,但订单流水已经与库存变化不匹配。没有流水表和业务事件编号,这类“暂时自愈”的事故很难被发现。

我会先把每个请求拆成四个状态:读取库存、取得扣减资格、写入订单、完成库存确认。很多系统把这四件事压缩成一个“下单成功”日志,导致无法判断到底是读取错了、扣减错了,还是订单状态错了。
对于每一次异常订单,至少要回答以下问题:
只要其中一个问题无法通过日志或流水回答,系统就存在不可审计的状态空洞。架构风险并不只体现在“会不会错”,也体现在“错了之后能不能还原”。
这是我认为最重要的分类。展示数据允许短时间不精确,例如页面显示还剩 3 件,用户点击后发现库存不足;决策数据则不允许把未经验证的旧值直接当作扣减依据。
如果缓存只用于展示,修复重点是缩短失效时间、降低误导和增加最终确认。如果缓存直接决定是否允许下单,就必须增加原子扣减、资格令牌、数据库条件更新或库存服务确认,不能只依赖缓存过期。
| 缓存用途 | 允许的误差 | 必须具备的保护 | 重点监控 |
|---|---|---|---|
| 页面展示剩余量 | 可容忍短时间旧值 | 下单环节二次确认 | 展示值与最终确认值差异率 |
| 下单前库存预检查 | 不应直接决定订单成功 | 后续原子扣减与结果校验 | 预检查通过但扣减失败次数 |
| 缓存直接承担预扣 | 必须可追溯、可回补 | 幂等键、流水、补偿状态机 | 预扣成功后订单失败率 |
| 库存服务的共享状态 | 由服务协议定义 | 版本校验和统一写入口 | 版本冲突、写入拒绝次数 |
日志里出现缓存旧值,只能证明缓存曾经落后,不能证明它造成了超卖。要证明因果关系,至少需要建立一条必要链:异常请求读到旧值;该旧值使请求通过了本不应通过的判断;后续扣减没有再次阻断;订单因此进入成功或有效占用状态。
如果数据库条件更新最终只成功了一次,那么两个请求都读到旧值可能只是用户体验问题,而不是超卖根因。如果两个订单都成功,但数据库扣减只成功一次,则应继续检查订单创建是否脱离库存扣减事务,或者订单服务是否错误忽略了扣减失败。
库存系统最危险的代码,往往不是扣减语句,而是扣减结果之后的分支。比如缓存返回失败、数据库更新返回 0、消息发送超时,业务代码却因为异常被吞掉,继续返回“下单成功”。
我会特别检查以下状态转换:扣减失败到订单成功、订单失败到库存不回补、消息超时到重复消费、回补失败到任务结束。每一个转换都应该有明确的状态、重试次数和最终人工介入出口。
— 这条 SQL 只能作为数据库侧的一道防线
UPDATE stock
SET available = available – 1,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE sku_id = ?
AND available >= 1
AND version = ?;— 应用层必须同时检查:
— 1. affected_rows 是否等于 1
— 2. 是否在扣减成功后创建订单
— 3. 订单失败后是否生成唯一回补事件

下面的案例是根据库存系统常见实现整理的情景模拟,不对应某一家企业或某次已公开事故。商品 SKU-A 初始可售库存为 1,Redis 中同步保存库存数量,订单服务通过消息异步落库。系统为了提高吞吐,先读取 Redis,再调用库存扣减接口。
测试环境设置了 200 个并发请求,数据库连接池为 20,缓存服务与应用部署在同一可用区。每个请求使用唯一请求编号,但没有使用用户级幂等键。压测结果中,数据库最终库存保持为 0,订单表却出现 2 条状态为“待支付”的记录。
如果只查看数据库库存,结论可能是“数据库没有超卖”。但进一步查看订单有效占用规则后发现,待支付订单已经冻结库存,只有超时关闭后才会释放。此时业务事实是可售库存被重复占用,而不是简单的库存字段为负。
| 时间 | 请求 A | 请求 B | 缓存状态 | 数据库与订单状态 |
|---|---|---|---|---|
| T0 | 未进入 | 未进入 | 库存为 1 | 库存为 1,无订单 |
| T1 | 读取库存 1 | 读取库存 1 | 两个请求都获得旧值 | 数据库仍为 1 |
| T2 | 发送扣减请求 | 发送扣减请求 | 原子扣减先后顺序不明确 | 数据库条件更新尚未返回 |
| T3 | 收到超时 | 收到成功 | 缓存显示为 0 | 订单服务收到两条创建消息 |
| T4 | 客户端重试 | 创建待支付订单 | 重试读取到异常旧值或降级值 | 出现两个冻结库存记录 |
这条时序最值得注意的地方是:请求 A 的“超时”不等于服务端没有执行。服务端可能已经扣减成功,只是在响应返回前发生网络中断。客户端再次提交时,如果没有幂等键,就可能将同一次购买意图变成第二次库存操作。
因此,缓存不同步并不是这次情景中唯一的问题。更准确的根因组合是:缓存读取和最终扣减之间存在时间差,服务端执行结果没有被客户端可靠确认,订单创建缺乏幂等控制,待支付库存冻结又没有统一的占用账本。
在压测或线上复盘时,我建议记录四个比例:预检查通过率、原子扣减成功率、订单提交成功率和扣减后回补率。它们能够帮助定位问题发生在哪一段,而不是只告诉你库存最后是多少。
例如,预检查通过率突然从 3% 升到 12%,但原子扣减成功率仍保持在 1%,说明缓存或展示层放行了更多请求,数据库或库存服务仍然挡住了它们。若原子扣减成功率正常,但订单提交成功率下降,则重点应转向订单事务和消息链路,而不是继续调整缓存过期时间。

第一,不能仅凭 Redis 出现旧值,就断言它直接造成了两笔有效订单。必须证明这两个订单都实际取得了库存资格,或者订单服务错误地绕过了库存结果。
第二,不能看到数据库库存为 0,就断言没有超卖。若系统把待支付订单视为库存占用,必须将订单冻结量纳入可售库存计算。
第三,不能因为加入了 Lua 脚本,就认为回补问题已经消失。Lua 只负责一次脚本执行的原子性,无法替代订单失败后的可靠事件和补偿状态机。
这是最适合主动处理的阶段。此时不要直接刷新所有缓存,也不要先重启服务掩盖问题。应先保存异常 SKU、缓存版本、数据库版本、最近一次更新事件和缓存删除结果,然后再执行定向修复。
如果缓存只是展示用途,通常可以采用定向失效和异步重建;如果缓存参与扣减,应立即暂停该 SKU 的高风险入口,等待库存账本、订单占用和预扣状态完成核对。
这类现象说明缓存可能暴露了过期库存,但最终扣减边界仍然有效。业务上未必已经超卖,但用户会频繁经历“页面显示有货、提交后无货”的落差。
行动重点不是贸然改数据库,而是优化读路径和用户提示:缩短展示数据的有效时间,降低展示库存的精确承诺,或者将“剩余库存”改为“可购买资格以提交结果为准”。同时应监控预检查通过到最终扣减成功之间的落差。
这是优先级最高的一类问题。它说明库存扣减失败没有阻断订单链路,缓存不同步可能只是触发了更多错误请求,真正的业务漏洞在于失败状态被转换成了成功订单。
任何“扣减返回失败但订单仍然成功”的路径,都不应通过增加缓存重试来修复。重试只会让问题更难收敛。
此时需要先确认服务端是否已经成功创建订单。客户端超时只代表响应没有在预期时间内返回,不代表服务端没有执行。正确做法是使用请求幂等键查询处理结果,而不是让客户端盲目重新扣减。
如果订单确实没有创建,应将预扣记录转入“待确认”状态,而不是立即无条件回补。因为订单写入可能只是延迟,立即回补会造成原订单和补偿订单同时占用库存。更安全的方式是设置确认窗口,查询订单状态后再决定回补。
这类问题表面上是少卖,实际上会反过来影响后续超卖判断。系统可能为了补足可售量,手工增加库存;原来的延迟回补消息之后又成功执行,最终造成库存虚增和重复售卖。
库存回补必须具备唯一业务事件编号。例如以订单号和操作类型组成幂等键,同一订单的“取消回补”只能成功一次。人工补库存也应记录原因、操作者、前后数量和关联事件,不能直接修改库存字段。

最朴素的安全边界是数据库条件更新。库存只有在 available 大于等于购买数量时才允许扣减,并由应用层检查影响行数。对于普通商品、并发量可控且数据库容量充足的业务,这通常是最容易审计的方案。
UPDATE stock SET available = available - :quantity, version = version + 1 WHERE sku_id = :sku_id AND available >= :quantity AND version = :version;
它的优势是库存变化和数据库事务天然接近,流水可追溯,异常处理相对清晰。短板是热点 SKU 可能形成行锁竞争,数据库连接池、事务日志和主库写入能力会成为瓶颈。
将库存预先加载到共享缓存,再通过原子命令或脚本扣减,能够减少数据库在瞬时流量下的写压力。它适合秒杀、抢购和库存极少但流量极高的场景。
但这种方案必须增加至少四类记录:预扣流水、订单关联、补偿状态和幂等键。没有这些记录,缓存中的一个数字无法解释“为什么减少”“减少后是否成交”“失败后是否归还”。
| 方案 | 主要优势 | 主要风险 | 更适合的场景 |
|---|---|---|---|
| 数据库条件扣减 | 权威性和审计性较好 | 热点行锁、主库写入压力 | 并发可控、库存关系复杂的普通交易 |
| 共享缓存原子预扣 | 吞吐高、响应快 | 订单失败、缓存故障、回补复杂 | 高峰突发、库存简单、可接受异步确认 |
| 库存服务统一扣减 | 边界集中、规则统一 | 服务成为关键依赖,需要高可用 | 多业务共享库存、规则需要统一治理 |
| 令牌或库存券 | 能把可售数量转成唯一资格 | 令牌丢失、过期和回收复杂 | 严格限制售卖数量的活动型业务 |
一些系统会先扣 Redis,再扣数据库;另一些系统则先扣数据库,再同步 Redis。两边都扣,看起来更安全,实际上容易造成重复扣减、失败补偿和顺序错乱。
如果两个组件都被当成“库存真相”,就很难定义谁先谁后。我的判断是:库存系统应明确一个最终权威来源,其他组件只能承担加速、预扣、投影或缓存职责。不要用增加一个扣减动作的方式,去掩盖权威数据没有定义的问题。
延迟双删主要降低并发读写造成的旧缓存残留;可靠消息主要解决数据库提交后的缓存更新通知;定期对账主要发现已经发生的差异。三者不是互相替代的关系。
如果系统要求库存实时准确,应该把原子扣减和权威账本放在前面;如果系统允许短暂最终一致,可以通过消息、重试和对账降低长期偏差;如果系统处于活动高峰,熔断和限流可能比继续追求缓存实时更新更重要。

库存为负是非常晚的信号。等到这个指标触发时,错误订单可能已经产生,甚至已经支付。更早的信号包括缓存与数据库版本差异、预检查通过但扣减失败、扣减成功后订单超时、回补延迟和重复消费。
建议至少建立以下指标:
全局平均值经常掩盖热点商品的问题。一个活动 SKU 出现 20 次库存异常,可能在全局比例中几乎没有波动,但对这个商品而言,已经足以造成严重损失。
SKU 粒度用于发现热点和账实差异;订单粒度用于追踪一次业务是否重复;请求粒度用于还原超时、重试和跨服务调用。三种粒度缺一不可,否则只能看到统计结果,看不到事故路径。
很多压测只验证“同时来了多少请求”,没有验证缓存删除失败、数据库锁等待、消息重复、服务重启和客户端超时。这样的压测很容易得到一个漂亮但不可靠的结论。
我建议至少注入以下故障:
“使用缓存、消息队列和分布式锁”不是一个可执行的架构结论。评审时应该要求团队画出成功、失败、超时、重试和回补五类时序,并明确每个节点的幂等键和最终状态。
如果一张流程图只画了成功路径,我会认为方案还没有完成。库存系统最需要评审的,恰恰是没有响应、响应重复、消息晚到、数据库提交不确定和补偿再次失败的路径。

普通商品通常不需要把所有流量都压到缓存预扣上。若并发规模可控,我更倾向于使用数据库条件扣减作为最后防线,缓存只承担展示和读优化职责。
这种方案的优势是账目清楚、人工复盘简单、订单与库存关系容易解释。代价是热点商品可能出现数据库竞争,需要通过分片、队列、限流或按商品维度串行化来控制峰值,而不是直接取消条件扣减。
活动型业务的难点不只是扣减速度,还包括瞬时流量远大于库存数量。让几百万请求直接访问库存数据库,本身就是架构错误;但让几百万请求直接改变缓存库存,也会把订单确认和回补复杂度推到后面。
更稳妥的做法是先通过排队、令牌、限流或分层过滤,把明显不可能成功的请求挡在前面,再使用缓存原子预扣。缓存预扣必须与唯一资格、订单幂等和失败补偿绑定,不能只返回一个布尔值。
对于高价值商品、稀缺票券或履约成本很高的商品,系统应将“少卖”视为可接受损失,将“多卖”视为高优先级风险。此时可以设置更保守的库存安全线、加强人工审核,甚至在缓存和数据库版本无法确认时直接拒绝下单。
这种策略会牺牲一部分转化率,但能降低赔付、客诉和供应链失信成本。架构取舍不能只看每秒处理请求数,还要看一次错误订单的真实业务代价。
多机房系统不能笼统地说“最终一致”。需要明确是在同一机房内实时一致、跨机房秒级一致,还是只保证最终库存不被突破。
如果同一 SKU 可以在多个机房同时扣减,必须考虑全局库存令牌、中心化库存服务、区域库存配额或预分配机制。单纯依赖缓存复制延迟,无法保证低库存商品在多个地域同时出售时不越界。
部分业务允许“先接受订单,再按供应能力确认”,这不等于系统可以无边界超卖。必须把候补、待确认、已锁定和已履约区分开,并在用户界面和订单规则中明确告知。
如果业务允许候补,缓存不一致造成的额外订单可能进入“待确认”而不是“成功订单”。这样做的前提是用户权益、退款规则和确认时限都已经定义,否则技术上的状态延迟会转化成运营和客服风险。


发现库存异常时,第一动作不是清空缓存,而是冻结异常 SKU 的高风险入口,保留缓存键、数据库记录、订单记录、消息记录和应用日志。清缓存可能让系统暂时恢复表面正常,却会同时破坏最重要的现场证据。
改造不必一开始就重写库存服务。先让每次扣减都产生一条可查询的业务记录,记录操作类型、数量、来源、前后库存、订单号、请求号和幂等键。
有了这条流水,团队才能区分“缓存读到了旧值”“数据库扣减失败”“订单重复创建”和“回补没有执行”。没有证据链时,任何修复方案都容易变成经验性的配置调整。
无论系统最终选择数据库扣减还是缓存预扣,都应该有一道不能被绕过的库存边界。数据库方案使用条件更新并检查影响行数;缓存方案使用原子资格并将资格与订单状态关联;多服务场景则通过统一库存服务集中处理。
这一阶段要优先修复“失败仍成功”的路径。缓存过期时间、删除重试和双删策略可以随后优化,但不能替代库存结果校验。
正常成功测试无法证明库存系统可靠。必须人为制造“服务端成功但客户端超时”“消息重复”“数据库锁等待”“订单写入失败”和“回补晚到”等场景,验证每种情况下最终库存和订单状态是否符合定义。
测试结果应同时检查库存表、库存流水、缓存值、订单状态和补偿任务,不能只断言接口返回码。一个接口返回失败但后台订单成功的案例,往往正是线上事故的起点。
库存异常不可能完全依靠自动修复。架构师需要提前定义什么情况下暂停销售、什么情况下强制从权威源重建缓存、什么情况下关闭客户端重试,以及什么情况下允许人工补偿。
应急策略必须包含负责人、执行条件、回滚方式和核对口径。否则告警触发后,团队仍然要临时讨论“先删缓存还是先停服务”,会进一步扩大事故窗口。

缓存不同步之所以危险,不是因为缓存天然不可靠,而是因为很多系统把一个可过期的读取结果,误当成了最终库存事实。只要缓存旧值能够直接决定订单成功,系统就把展示层的短暂误差升级成了交易层的业务承诺。
我对库存系统的最终判断通常只有一句话:任何一次库存扣减,都必须能够回答谁扣的、扣了多少、凭什么扣、订单是否承接、失败后如何恢复,以及这次操作是否已经执行过。
如果系统目前只能回答“Redis 里还有多少”和“数据库里还剩多少”,说明它还没有真正掌握库存状态。下一步应先定义唯一权威来源,再补齐扣减结果校验、订单幂等、回补流水和差异对账,最后才是选择延迟双删、消息更新或更复杂的缓存策略。
对于普通商品,可以优先选择可审计的数据库条件扣减;对于突发流量,可以引入缓存原子预扣,但必须接受并治理异步确认和回补复杂度;对于高价值稀缺库存,则应在无法确认状态时宁可拒绝,也不要让不确定的库存继续进入订单链路。
建议把本文的风险清单纳入三类固定流程:上线评审、压测验收和事故复盘。库存系统真正可靠的标志,不是从未出现缓存延迟,而是出现延迟、超时、重复和回补失败时,系统仍能阻断错误订单、及时发现差异,并最终把每一件库存追溯到明确的业务事实。
我排查库存异常时,发现 Redis 里的库存比数据库多 1,但订单数量并没有超过实际库存。既然缓存已经是旧值,为什么有时只是页面显示错误,有时却会演变成真正的超卖?判断这两类问题时,最应该看哪一层?
缓存不同步首先制造的是“错误库存视图”,不一定直接制造超卖。真正决定是否超卖的,是这个错误视图是否被后续流程当成了最终扣减依据。我在做库存链路压测时,专门构造过“数据库先扣减、缓存删除失败”的场景:数据库库存从 10 变成 9,Redis 仍返回 10。
若后续数据库执行的是带条件的原子扣减,且严格检查影响行数,最终只能成功扣减 9 次,缓存旧值最多造成多余请求进入扣减流程。高风险实现通常是“先读缓存判断库存,再直接创建订单”,或者数据库更新失败后仍继续落订单。此时,缓存旧值就会从展示问题变成业务事实错误。
现象是否必然超卖关键判断 缓存库存比数据库多否数据库扣减是否有库存条件 多个请求同时读到库存 1有风险是否存在原子扣减 扣减失败仍创建订单高概率是否校验更新影响行数 因此,排查时不要只截图比较 Redis 和数据库的数字,而要继续追踪订单号、扣减流水和数据库更新结果。
只有确认有效订单数超过可售库存,才能定义为真正的超卖。
我看到过一些库存代码,先从缓存读取数量,判断大于 0 后再执行数据库扣减。单个请求看起来完全正常,但在秒杀并发下仍可能出现多个订单成功,这种写法究竟错在哪里?
问题不在于读取缓存本身,而在于把一次非锁定读取结果当成了扣减资格。缓存返回的库存只是某个时间点的快照,不能保证下一个数据库操作仍然成立。以库存为 1 的场景为例,请求 A 和请求 B 可能在同一个毫秒内都从缓存读到 1。两者随后分别执行数据库更新。
如果更新语句只是“available = available – 1”,没有“available > 0”条件,数据库可能被扣成 -1;如果更新失败但代码没有检查影响行数,两个订单仍可能继续创建。
我通常用下面这组时序检查代码,而不是先看锁的名字: 阶段安全实现危险实现 库存判断作为提示或快速拦截作为最终扣减依据 库存扣减原子操作或条件更新先查询再普通更新 结果处理失败立即停止下单无论结果都创建订单 并发控制依赖数据库或脚本原子性依赖多个分离步骤 数据库侧至少应使用类似 UPDATE stock SET available = available – 1 WHERE sku_id = ?
AND available > 0 的条件更新,并检查影响行数是否为 1。缓存可以减少无效请求,但不能替代最终扣减校验。
我原本以为使用 Redis 原子自减或脚本扣库存后,超卖问题就解决了,但测试中遇到过 Redis 扣减成功、订单写入失败的情况。这个场景到底会造成少卖、库存冻结,还是也可能在重试时演变成超卖?
Redis 原子扣减只解决了“多个请求同时修改同一个库存值”的竞争问题,没有自动解决库存扣减与订单落库之间的事务边界。我在压测中见过一种很容易被忽略的链路:Redis 扣减成功后,服务等待数据库写订单;数据库连接超时,客户端收到失败并重试。
第一次请求已经消耗了 Redis 库存,第二次请求又可能重新执行扣减。如果没有请求幂等键,结果可能是库存被扣两次;如果订单服务部分成功,还会出现订单与库存流水对不上。
不同故障的表现并不相同: 故障场景直接表现后续风险 扣减成功,订单失败库存减少、订单不存在少卖或库存冻结 客户端超时后重试同一业务重复扣减库存流水异常 回补消息重复消费库存被多次加回可售库存虚增,可能导致超卖 Redis 重启或数据丢失预扣状态消失库存账本与订单失配 因此,Redis 原子扣减后必须配套业务幂等键、扣减流水、订单状态机和失败回补。
排查时要把 Redis 操作记录与订单号、请求 ID、消息 ID 对齐,不能只看“脚本执行成功”这一条日志。
线上发现库存为负或订单数异常后,我经常不知道应该先查缓存、数据库,还是消息队列。日志很多但互相对不上,最后只能人工拼时间线。有没有一套更适合故障现场的排查顺序,能快速判断根因而不是只找到表面现象?
我处理这类问题时,不会先从“缓存为什么没删掉”开始,而是先确认事实:到底是显示错、库存账实不符,还是有效订单真的超过了可售库存。顺序错了,很容易把正常的预扣和回补误判成超卖。第一步是建立库存账本,至少核对初始库存、成功扣减、取消回补、人工调整和有效订单数。
第二步确认权威来源,明确数据库、缓存、库存服务和订单服务中,哪个数字能够作为最终裁决。第三步按一个订单号还原完整时序,建议关联请求 ID、SKU、缓存 Key、数据库事务、消息 ID 和幂等键。重点不是看单条日志,而是判断以下四个动作的先后关系: 读取或预扣库存;数据库扣减是否成功;
订单是否成功落库;失败后是否完成回补。第四步检查数据库扣减影响行数、Redis 原子操作结果和消息消费次数。一次真实排查中,最有价值的证据往往不是异常堆栈,而是“库存扣减成功 100 次、订单成功 98 次、回补成功 1 次”这类数量关系。
排查顺序要回答的问题常见结论 1. 核对订单事实是否真的卖超排除展示误差 2. 确认权威库存哪个系统最终说了算避免拿错数据源 3. 还原请求时序旧缓存何时被读取定位竞态窗口 4. 对齐扣减与回补每次操作是否闭环发现重复扣减或补偿失败 如果线上缺少这些关联字段,优先补日志和流水,而不是急着增加分布式锁。
没有可追溯证据时,任何修复方案都可能只是猜测。


读者评论
文章把“缓存不一致”和“真正超卖”区分开来,这一点很实用。很多排查只看到缓存旧值,却忽略了数据库条件更新影响行数未校验、订单重复提交等后续环节。
对预扣库存、回补失败和多级缓存的分析比较全面,尤其是强调不能只看某一时刻的库存快照。实际系统中还需要结合请求编号、订单号和库存流水,才能还原完整时序。
文中对分布式锁和延迟双删的说明比较客观,没有把它们当成万能方案。不过文章主要聚焦排查思路,若能进一步补充幂等键设计、补偿任务监控和告警阈值,落地性会更强。