数据库存:技术负责人诊断清单:从并发扣减排查缓存不同步
库存接口返回“还有 1 件”,数据库却已经是 0;订单服务提示扣减成功,Redis 中的库存仍然停留在旧值;更麻烦的是,数据库没有出现负数,业务方却坚持认为系统“超卖”了。遇到这类问题,我通常不会先改缓存更新代码,而是先把“展示不一致”“扣减失败”“重复扣减”和“真实超卖”分开验证。因为在高并发库存系统中,缓存异常只是表面现象,真正的故障点可能在 SQL 条件、事务提交、请求重试、消息重复消费,甚至在订单取消后的回补逻辑。
这篇文章给出一套面向技术负责人的诊断方法:先确定库存事实源,再沿着请求幂等、并发扣减、数据库事务、缓存失效、消息同步和库存对账建立证据链。我的核心判断是:缓存与数据库短暂不一致可以被设计和治理,但扣减结果不可追踪、重复请求无法识别、补偿任务无法停止,才是库存系统真正的架构风险。
我在处理库存故障时,第一步一定是把问题拆成三层,而不是笼统地记录“Redis 和数据库对不上”。同样是数字不一致,可能对应完全不同的处理方式。
| 不一致类型 | 典型表现 | 是否一定造成超卖 | 首要证据 |
|---|---|---|---|
| 展示不一致 | 页面显示有库存,提交后提示库存不足 | 不一定 | 数据库条件更新影响行数、订单结果 |
| 状态不一致 | 数据库已扣减,缓存仍为旧值 | 不一定 | 数据库提交时间、缓存删除或更新日志 |
| 业务结果不一致 | 同一订单扣减两次、取消订单重复回补 | 可能 | 幂等记录、扣减流水、订单状态变更记录 |
| 事实源错误 | 数据库、缓存、订单流水都无法解释当前库存 | 高度可疑 | 全量流水、事务日志、补偿记录 |
例如,缓存显示库存为 1,但数据库条件更新返回 0,接口最终拒绝下单,这通常是“展示不一致”,而不是数据库扣减失效。如果此时为了让页面数字看起来正确,直接放开扣减条件,反而可能把一个可控的缓存延迟问题扩大成真实超卖。
反过来,如果数据库库存仍有 10 件,但扣减流水显示同一个订单已经成功两次,那么问题就不在缓存刷新速度,而在幂等、重试或业务状态机。诊断时先定性,再定层,最后才选择修复手段。

“数据库库存”和“缓存库存”都叫库存,但它们承担的职责可能不同。最常见的设计是数据库保存最终库存,Redis 只负责读取加速;也有系统让 Redis 承担活动库存预扣减,数据库记录订单确认后的最终结果。两种设计的诊断方法不能混用。
如果数据库是事实源,那么 Redis 的数值可以短暂落后,但数据库条件更新必须可靠,缓存删除失败需要可重试,最终还要有对账机制。如果 Redis 负责原子预扣减,那么必须额外回答:预扣成功但订单创建失败时如何回滚,Redis 扣减成功但进程宕机时如何恢复,数据库写入失败后由谁负责补偿。
我建议在架构文档中直接写出以下一句话,而不是使用“最终一致”这种模糊表述:
库存事实源:商品库存表
预扣减控制点:Redis 原子脚本
订单确认依据:预扣流水 + 订单状态
缓存同步方式:数据库提交后删除读缓存
异常恢复方式:按商品和扣减流水进行对账补偿
如果团队无法明确这五个问题,线上出现“数据库和缓存不一致”时,所有人都会拿自己负责的系统当作正确答案,排查很容易陷入争论。
库存展示接口可以接受短暂缓存延迟,但扣减接口不能仅凭缓存返回值判断交易是否成功。缓存中的“可售库存”通常只是预检查结果,真正的扣减结果必须来自原子扣减控制点或数据库条件更新的影响行数。
因此,接口设计上至少要区分三个字段:展示库存、可扣减结果和业务状态。不要让前端看到一个数字,就把这个数字当成订单成功的依据。对于高风险业务,我甚至会让下单确认接口绕过普通读缓存,直接读取事务结果或短时间有效的强一致状态。
一个看似简单的“购买一件商品”请求,实际可能经过网关、幂等校验、库存预检查、Redis 操作、数据库事务、订单写入、消息投递、缓存删除和异步补偿。任何一个环节超时,都可能让调用方无法判断“到底成功还是失败”。
典型链路可以表示为:
客户端请求
↓
网关重试与限流
↓
幂等校验
↓
库存预检查或 Redis 预扣减
↓
数据库条件扣减
↓
订单创建事务
↓
提交后发送消息
↓
删除或刷新缓存
↓
对账与异常补偿
这里最容易被忽略的是“调用方不知道结果”的场景。比如数据库事务已经提交,但应用在返回响应前连接断开,客户端看到超时后再次提交。如果没有业务幂等键,第二次请求很可能被当成新的购买请求。
线上系统经常同时存在总库存、可售库存、锁定库存、已支付库存、活动库存和仓库库存。数据库里的字段可能是总库存,缓存里的 Key 却是活动可售库存,订单系统记录的又是锁定库存。此时直接比较两个数字,本身就没有意义。
| 库存口径 | 常见存储位置 | 能否直接用于下单 | 排查注意点 |
|---|---|---|---|
| 仓库实物库存 | 仓储系统或库存主表 | 通常不能 | 要扣除损耗、冻结和不可售数量 |
| 可售库存 | 业务数据库、缓存 | 可以作为扣减依据 | 必须确认活动和渠道分摊规则 |
| 锁定库存 | 订单库、库存流水 | 不能重复扣减 | 关注超时释放和取消回补 |
| 活动库存 | Redis、活动库 | 仅限活动场景 | 关注活动结束后的归并 |
我见过最危险的排查方式,就是把数据库某个 stock 字段和 Redis 某个 stock Key 放在一起比较,发现数字不一样后立即判定“缓存同步失败”。在开始比对之前,必须先确认商品维度、仓库维度、渠道维度、库存口径和更新时间是否一致。
真实故障很少是一个单点 bug。更常见的组合是:缓存删除偶发失败、客户端遇到超时自动重试、幂等键只在订单创建后写入、消息消费没有唯一约束,最后在活动流量下集中暴露。
这类事故的难点不在于某个 Redis 命令不会使用,而在于系统没有把一次库存变化串成完整记录。没有请求 ID、订单 ID、商品 ID、扣减流水 ID 和消息 ID 的关联关系,负责人只能依赖不同服务的时间戳进行猜测。

缓存有库存只说明读取到了一个可能过期的值。是否超卖,应以扣减事实为准。如果数据库执行的是带条件的原子更新,并且业务正确检查影响行数,那么缓存旧值最多导致用户多发起一次请求,不一定导致库存被扣成负数。
相反,如果为了消除页面上的旧数字,临时把数据库校验改成“直接减库存”,就会绕过原本的安全边界。我的经验是,在库存事故中,宁可先让一部分请求明确失败,也不要为了提高表面成功率而放弃事实源校验。
下面这种逻辑在单线程测试中通常表现正常,但在并发下存在明显竞态:
SELECT stock FROM product_stock WHERE product_id = ?;
if (stock >= quantity) {
UPDATE product_stock
SET stock = stock - ?
WHERE product_id = ?;
}两个请求可以同时读到库存为 1,然后同时通过判断。即使数据库最终只执行成功一次,另一个请求也已经进入了后续订单流程;如果更新语句没有条件,或者异常分支没有检查影响行数,就可能形成负库存。
更可靠的做法是把判断和扣减放在同一个原子操作中:
UPDATE product_stock
SET stock = stock - #{quantity},
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE product_id = #{product_id}
AND stock >= #{quantity};执行后必须读取影响行数。影响行数为 1,只代表本次条件更新成功;影响行数为 0,才进入库存不足、商品不存在或数据状态异常的细分判断。不要只判断数据库驱动是否返回“执行成功”,因为 SQL 执行成功和库存业务成功不是同一件事。
Redis Lua 脚本可以让“读取并扣减”在 Redis 内部保持原子性,但它不能自动和数据库事务绑定。Redis 扣减成功后,应用可能在写订单前宕机;数据库订单写入成功后,缓存删除也可能失败。
如果 Redis 是预扣减点,就必须保存预扣流水。流水至少包括商品 ID、请求 ID、订单 ID、扣减数量、操作状态、创建时间、过期时间和回滚状态。没有流水的 Redis 扣减,出现异常后几乎无法判断某个数字是正常消耗、重复扣减还是异常残留。
延迟双删主要用于降低并发读请求把旧值重新写回缓存的概率,但它无法解决消息丢失、进程宕机、数据库回滚、跨机房延迟和业务重复回补等问题。
比如写请求先更新数据库,再删除缓存;此时一个读请求在删除前读取了旧数据库值,并把旧值写回缓存。延迟再次删除确实有机会清除旧值,但延迟时间如何确定,删除任务是否可靠,读请求是否持续到达,都需要结合系统负载验证。延迟双删是一种降低窗口概率的手段,不是跨系统事务。
当前库存是结果,不是过程。一个商品当前库存为 0,可能是正常售罄,也可能是扣减两次后又回补一次,还可能是人工修正覆盖了真实数据。只看最终数字无法回答谁改了它、为什么改、是否重复改。
故障排查必须拿到至少一段完整时间窗口内的变更流水,并按照商品、订单和请求 ID 聚合。对于活动商品,我通常会同时取活动开始前、峰值期间和活动结束后三个时间段,否则很容易把活动初始化或结束归并误认为扣减异常。

技术负责人不要一上来就让工程师“查 Redis”。先确认异常从什么时候开始、影响哪些商品、哪些接口、哪些机房和哪些订单状态。范围判断可以快速区分全局基础设施问题、单商品配置问题和单版本回归问题。
如果只影响页面展示而未影响订单结果,处理优先级通常是缓存修复和用户提示;如果已经出现重复扣减或负库存,优先级应转为保护事实源、停止风险入口和冻结自动回补。
我会先看实际执行的 SQL 和影响行数,而不是听开发口头描述“这里有事务”。事务只能保证一组数据库操作的原子性,不能替代条件更新;悲观锁能控制并发,也不代表调用重试具备幂等。
重点检查以下内容:
stock >= quantity 条件。数据库层面的关键指标不是“SQL 成功率”,而是“条件扣减成功率”“条件扣减失败率”“事务回滚率”“锁等待时间”和“库存为负记录数”。SQL 没有报错,并不代表业务扣减成功;影响行数为 0 也不一定是系统故障,可能只是库存不足。

很多系统把幂等记录写在订单创建成功之后,这个位置太晚。只要扣减先发生,进程就在数据库、Redis 或消息系统中留下了不可逆的副作用。正确顺序应当是先用稳定的业务幂等键识别请求,再执行扣减。
幂等键不能简单使用每次请求自动生成的随机 UUID,否则客户端重试时每次都是新请求。更合适的组合通常是用户 ID、业务单号、商品明细或客户端生成的订单请求号。对于消息消费,还应使用业务流水唯一约束,而不是只依赖消费者内存中的去重集合。
一个可操作的幂等记录状态可以设计为:
| 状态 | 含义 | 重复请求处理 | 注意事项 |
|---|---|---|---|
| 处理中 | 首次请求已经占用业务键 | 返回处理中或查询结果 | 必须有超时恢复机制 |
| 成功 | 扣减和订单结果已确认 | 返回原结果 | 不能再次扣减 |
| 失败 | 明确未产生业务副作用 | 按策略允许重试 | 失败原因需要可区分 |
| 未知 | 调用超时,结果无法立即确认 | 先查询流水,不要直接重试 | 这是最容易造成重复扣减的状态 |
排查缓存不同步时,不能只看当前 Key 值。我会要求把数据库提交、缓存读取、缓存删除、缓存写入和消息消费放在同一条时间线上。很多所谓的“缓存被覆盖”,其实是旧读请求在新写请求之后完成回填。
常见时间线如下:
如果日志只记录“缓存更新成功”或“缓存删除失败”,没有记录读请求使用的数据库版本、回填时间和业务版本号,就无法证明到底是哪一次写入覆盖了新值。
对于重要库存 Key,可以引入版本号或更新时间校验。写缓存时携带数据库版本,只有新版本能够覆盖旧版本。需要注意的是,版本校验只能解决部分乱序覆盖问题,不能替代数据库事务、消息可靠性和业务幂等。
“数据库提交后发消息”并不是天然可靠的组合。若数据库事务提交成功,但应用在发送消息前宕机,缓存删除消息可能根本没有产生;若消息已发送但消费者处理后返回超时,生产端可能重试,消费者就会重复执行。
消息排查至少要回答:
如果业务允许,缓存同步消息最好携带“目标状态”或“数据库版本”,而不是只发送一个“请减一”的增量命令。增量消息重复消费会重复改变结果,带版本的状态刷新更容易做到幂等。
当日志无法完全还原现场时,对账是最后的事实确认手段。对账不能只比较数据库和 Redis,还应将库存主表、扣减流水、订单明细、取消回补记录和预扣流水一起计算。
一个简化的库存核算公式可以写成:
理论可售库存
= 初始库存
已确认扣减数量
当前锁定数量
+ 已确认回补数量
+ 已撤销扣减数量
+ 人工修正数量
这个公式不是所有业务都能直接套用。仓库拆分、批次效期、渠道库存和组合商品都可能改变口径,但它能提醒团队:库存对账必须以变更事件为基础,而不是用两个最终数字互相覆盖。

下面用一个简化的秒杀商品场景说明。商品 X 的数据库库存为 1,Redis 中也为 1。请求 A 和请求 B 在极短时间内同时到达。
| 时间 | 事件 | 数据库库存 | 缓存库存 | 业务判断 |
|---|---|---|---|---|
| 10:00:00.000 | 系统初始化 | 1 | 1 | 状态一致 |
| 10:00:00.018 | 请求 A 通过条件更新 | 0 | 1 | A 扣减成功 |
| 10:00:00.021 | A 删除缓存时连接异常 | 0 | 1 | 出现同步失败窗口 |
| 10:00:00.029 | 请求 B 读取缓存 | 0 | 1 | B 误以为仍有库存 |
| 10:00:00.047 | B 执行条件更新 | 0 | 1 | 影响行数为 0 |
这个案例的关键结论是:缓存显示错误,但数据库没有超卖。B 请求应该得到“库存不足”或“库存状态已变化”的业务响应,同时触发缓存删除重试。真正需要修复的是缓存失效可靠性和接口对条件更新失败的处理,而不是放宽数据库扣减条件。
如果 B 第一次收到超时,没有拿到明确结果,客户端或网关可能再次发送相同请求。只要数据库条件更新仍然正确,重复请求通常会继续返回影响行数为 0;但如果第一次请求已经成功而响应丢失,第二次请求就可能再次扣减其他库存。
因此,需要区分两类重试:
在接口协议中,我更倾向于返回明确的业务状态,而不是把所有异常都转换成 HTTP 500。至少应区分库存不足、请求处理中、订单已创建、重复请求和系统未知状态。状态越清楚,客户端越不容易采取危险的盲重试。
一旦确认数据库出现负库存,缓存就不再是第一嫌疑对象。首先检查实际扣减 SQL 是否带条件、条件是否使用了错误字段、影响行数是否被忽略,以及是否存在另一条未受保护的写入路径。
随后检查是否存在以下情况:
真实超卖的修复顺序应该是先保护数据,再修代码。可以临时关闭高风险入口、停止自动回补、限制异常商品的购买量,并保留现场数据。直接批量把负库存改成 0,只能改善页面显示,不能解释已经发生的订单和资金关系。

当数据库是库存事实源时,我通常优先考虑“先写库,再删除缓存”。它的逻辑比较容易解释:数据库提交成功后,旧缓存失效;下一次读取从数据库加载新值。
但它并不是没有窗口。数据库提交成功、缓存删除失败时,旧值会继续存在;删除成功后,读请求又可能因为并发时序把旧值回填。因此必须配套删除重试、失败告警、过期时间和必要的延迟二次删除。
| 优点 | 代价 | 适用情况 |
|---|---|---|
| 事实源清晰 | 删除失败存在旧值窗口 | 数据库承担最终扣减 |
| 缓存重建逻辑简单 | 高并发下可能反复回填 | 允许短暂展示延迟 |
| 失败后容易通过对账恢复 | 需要可靠的失效任务 | 读多写少的库存展示场景 |
先改缓存的优势是响应快,热点库存可以在缓存层直接原子扣减。但它会把更多复杂性推给补偿系统:缓存成功、数据库失败时需要回滚;应用宕机时需要恢复预扣;数据库写入成功、缓存后续状态丢失时还要重建。
这种方式适合流量极高、库存预扣有明确业务语义的场景,但前提是团队已经具备完整的预扣流水、超时释放、订单确认和异常回补机制。如果只是为了减少数据库压力而简单把 Redis 当成另一个库存表,风险通常大于收益。
异步消息可以把数据库事务和缓存处理解耦,但“异步”意味着系统接受一个明确的延迟窗口。负责人需要定义允许的最大延迟,例如库存展示可以接受几百毫秒,还是必须在几秒内修复。
消息方案的重点不是有没有消息队列,而是消息能否被追踪和重放。每条消息应该带有业务主键、库存版本、操作类型和产生时间。消费者应支持幂等,失败消息应进入可查询的重试或死信队列。
直接更新缓存看起来节省一次数据库查询,但在多写并发时,旧请求可能晚于新请求完成,从而出现旧值覆盖新值。删除缓存则让下一次读取重新获取事实源,通常更容易恢复。
如果必须直接更新,建议加入版本判断:
if incoming_version > cached_version:
write_cache(
key=stock_key,
value=incoming_stock,
version=incoming_version
)
else:
ignore_outdated_event()
这段逻辑只能处理“事件版本可比较”的情况。如果不同库存口径共用 Key、版本号在不同系统中不连续,或者回补事件与扣减事件没有统一排序,单纯增加 version 字段也无法真正解决问题。

如果订单结果、数据库扣减流水和库存主表都正常,优先按展示层故障处理。可以对受影响 Key 执行删除或重建,开启缓存删除失败告警,并在短时间内提高对账频率。
不要在没有确认口径的情况下全量刷新缓存。全量重建可能增加数据库压力,活动期间还可能把大量并发读请求集中打到数据库。更安全的做法是按异常商品、版本和时间窗口进行定向修复。
先区分库存不足和系统失败。可以按失败原因、影响行数、数据库错误码和接口状态拆分统计。如果库存不足占比升高,可能是活动库存已经消耗完;如果锁等待、连接超时和事务回滚同时升高,则更接近数据库或应用容量问题。
立即冻结重复回补和重复确认路径,保留扣减流水、订单状态和消息记录。不要先批量删除幂等记录,因为这些记录是确认重复操作的重要证据。
修复时,应先增加唯一约束或原子幂等写入,再处理历史数据。对于已经重复扣减的订单,按订单状态决定是否回补,不能依据“发现两条记录”就直接加回库存。已支付、已发货和已取消订单的修复策略完全不同。
这是最高优先级场景。第一步是停止继续产生副作用的入口,包括高风险商品扣减、自动回补和可能重复消费的消息;第二步是固定现场数据;第三步才是修复库存。
库存修复至少要同时处理四件事:
如果业务涉及支付,库存修复不能由技术团队单独决定。技术团队负责还原事实和提供影响范围,产品、运营、财务和履约团队共同决定缺货订单的补偿策略。
如果消息积压,先判断消息是“缓存刷新消息”“库存回补消息”还是“订单确认消息”。缓存刷新消息可以在确认版本后丢弃过期事件;库存回补消息则不能简单清空,因为它可能影响真实库存。
对于重复消费,应先启用消费者幂等保护,再处理积压。直接扩容消费者只能提高重复执行速度,不能解决业务副作用。如果消息没有版本或业务流水,建议先进入隔离队列,由程序或人工按订单逐条核验。

缓存命中率很高,不代表库存链路健康;数据库 CPU 很低,也不代表订单没有重复扣减。库存系统应同时观察业务结果和技术过程,最好让两类指标使用相同的商品、订单和请求维度。
| 业务指标 | 对应技术指标 | 异常组合 | 可能方向 |
|---|---|---|---|
| 库存不足率 | 条件更新 0 行比例 | 同步升高 | 正常售罄或库存口径变化 |
| 重复订单率 | 幂等冲突次数 | 业务升高、冲突不升 | 幂等校验可能未覆盖入口 |
| 库存差异商品数 | 缓存删除失败数 | 差异升高、删除失败不变 | 可能存在绕过统一缓存逻辑的写路径 |
| 订单取消回补量 | 回补消息重试次数 | 回补量异常升高 | 状态重复变更或消息重复消费 |
| 库存为负记录数 | 数据库锁等待和 SQL 版本 | 负库存突然出现 | 并发控制或代码回归 |
每次扣减、回补、冻结和释放,至少应该记录以下字段。字段不一定全部写入同一张表,但必须能够通过关联 ID 查询出来。
尤其要保留 before_value 和 after_value。只有“扣减 1 件”的增量记录,无法判断当时库存是多少,也无法发现两个系统对同一库存口径的理解是否不同。
很多团队有对账脚本,却只有一条 SQL:查数据库库存和 Redis 库存是否相等。这样的脚本只能发现数字差异,不能解释差异,更不能安全修复。
一个可用的对账任务至少应具备四层能力:
修复动作也要有幂等键和审计记录。对账任务本身如果可以重复加库存、重复删缓存,系统就会出现“用补偿修复事故,补偿又制造事故”的循环。

压测时不能只看每秒请求数,还要检查库存结果是否满足约束。假设初始库存为 100,发送 1000 个并发扣减请求,每个请求购买 1 件,最终成功数不应超过 100,扣减流水成功数量、订单确认数量和数据库库存变化应能够相互解释。
建议至少覆盖以下场景:
如果从未主动让缓存删除失败、消息消费失败和数据库连接中断,团队就不知道补偿逻辑是否真的可用。故障注入不必一开始就在线上进行,可以在预发布环境针对单个商品和固定库存做定向演练。
每次演练都要记录四个时间点:
这样才能区分“系统修复快”和“系统根本没有发现问题”。很多团队把恢复时间写成补偿任务执行完成时间,却忽略了中间数小时没有任何告警。
库存系统最重要的不是某个组件是否高可用,而是业务不变量是否始终成立。以下约束应当写成自动化测试和监控规则:
库存问题经常不是主链路代码突然失效,而是新功能增加了一条“临时扣库存”接口、后台补偿脚本或活动专用逻辑。这些路径没有经过统一幂等和库存校验,就会破坏原有约束。
每次涉及订单、营销、仓储、活动和退款的发布,都应检查是否新增库存写入路径。只要一个服务能够直接修改库存,就必须纳入同一套流水、权限、审计和对账体系。

如果库存数字主要用于列表页、详情页或活动预告,用户看到旧值几十到几百毫秒通常可以接受,前提是提交订单时仍由强约束控制点判断。此时优先保证数据库稳定和缓存高命中,不必为了页面瞬时一致引入跨系统同步事务。
但如果页面展示会直接触发支付、锁价或稀缺资源分配,允许的延迟就要重新评估。用户反复看到有货、反复提交失败,会形成大量重试流量,最终可能压垮数据库。
在库存差异持续扩大、缓存删除链路故障、Redis 数据疑似损坏或对账无法确认时,可以对特定商品、特定接口临时绕过缓存读取数据库。这个动作会增加数据库压力,因此必须同时配置限流、热点保护和恢复条件。
不建议直接全站绕过缓存。全站切换通常会在故障已经发生时制造第二个故障:缓存流量回源、数据库连接池耗尽、锁等待增加,随后扣减接口和查询接口一起不可用。
Redis 预扣减适合热点集中、瞬时并发远高于数据库直接承载能力的业务,但前提是库存状态可以拆成“预扣,确认,释放”三个明确阶段。若业务只有简单的数据库库存字段,没有预扣流水和释放机制,不建议为了追求吞吐直接切换。
采用预扣模式后,团队需要承担更多治理成本:
如果并发规模可控、库存扣减逻辑复杂、业务更重视事实清晰和审计追溯,数据库条件更新通常更容易维护。它的瓶颈可以通过分库存表、热点拆分、队列削峰和合理索引缓解。
行锁并非越多越安全。锁粒度过大可能导致一个热点商品拖慢其他业务;事务中包含远程调用则会延长锁持有时间。我的判断标准是:先确保业务不变量,再根据真实压测结果决定是否引入更复杂的预扣模型。
| 方案 | 一致性解释 | 峰值吞吐 | 故障恢复成本 | 更适合的场景 |
|---|---|---|---|---|
| 数据库条件更新 + 删除缓存 | 强事实源,缓存允许短暂延迟 | 中等 | 较低 | 普通商品、库存变化可审计 |
| 数据库事务 + 消息失效 | 最终一致,延迟可观测 | 中高 | 中等 | 读多写少、允许异步同步 |
| Redis 原子预扣 + 数据库确认 | 分阶段一致,需要补偿闭环 | 高 | 高 | 热点活动、瞬时流量集中 |
| 串行队列扣减 | 顺序清晰,处理延迟可控 | 取决于分片数 | 中等 | 可按商品分片、强顺序业务 |
表格中的“高吞吐”不能单独作为选型理由。吞吐提升通常伴随状态拆分、补偿、重放和监控成本增加。对于一个每天只有少量库存写入、但对审计要求极高的业务,复杂的 Redis 预扣可能是过度设计。

如果现场只能回答“Redis 现在是多少”,这还不算开始排查。真正有价值的证据应该能回答:谁在什么时间,以什么业务原因,对哪个库存口径做了什么变更,变更是否提交,后续同步是否成功,失败后有没有补偿。
缓存与数据库之间是否允许短暂不一致,应当由业务风险、接口用途和可接受延迟共同决定。列表页可以容忍短暂旧值,订单确认不能只相信旧缓存;活动预扣可以追求高吞吐,但必须承担预扣释放和对账成本。
真正需要追问的是:系统是否定义了事实源,是否保护了并发扣减,是否识别了重复请求,是否能追踪消息,是否可以在异常后安全恢复。没有这些能力,所谓最终一致性只是把问题推迟到事故发生之后。
不要一开始就改造所有商品和所有库存表。选择一个热点商品或一条高风险扣减接口,补齐请求 ID、幂等键、扣减流水、缓存版本、消息状态和对账记录,再进行一次缓存删除失败与客户端超时重试演练。
演练结束后,要求团队在规定时间内回答三件事:数据库最终扣减了多少次、缓存何时恢复一致、哪些订单需要人工处理。如果这三件事都能用日志和流水直接回答,说明系统已经从“依赖经验排查”迈向“依赖证据治理”。
我对库存系统的最终判断是:缓存不同步并不可怕,可怕的是团队无法区分旧值、失败、重复和真实超卖。先建立事实链,再讨论缓存策略;先保护业务不变量,再追求峰值吞吐,这才是技术负责人面对并发扣减问题时最稳妥的顺序。
我遇到过缓存显示还有库存、数据库却已经扣到 0 的情况,第一反应很容易是怀疑扣减代码失效。后来我发现,真正的问题只是缓存删除失败,数据库条件更新其实已经正确拦住了第二次扣减。面对这类故障,我应该怎样快速区分展示不一致、扣减失败和真实超卖?
先不要直接改缓存更新代码,而要把问题拆成三个结果:页面显示是否正确、数据库扣减是否成功、订单最终状态是否正确。这三件事经常被混在一起,导致团队把“缓存旧值”误判成“库存超卖”。
我通常先抽取同一个商品、订单号和请求 ID,按时间顺序对比四份证据:库存变更流水、数据库扣减 SQL 的影响行数、缓存读写日志、订单状态变化。只看 Redis 当前值没有意义,因为它只能说明现在缓存里是什么,不能证明此前发生过什么。
现象优先核对证据初步判断 缓存为 1,数据库为 0数据库提交记录、删除缓存结果大概率是缓存旧值,不等于超卖 缓存为 0,数据库为 1回补记录、缓存预扣日志可能是回滚或补偿未同步 数据库出现负数扣减 SQL、影响行数、其他写入路径优先怀疑并发控制或绕过扣减逻辑 同一订单扣减两次幂等记录、重试日志、消费记录优先怀疑幂等失效或重复消费 我的判断标准是:数据库是否是库存事实源。
如果数据库条件扣减成功且影响行数判断正确,那么缓存短暂不一致属于读模型问题;如果数据库本身已经错误,就必须继续查事务、并发、重试和其他写入入口,不能靠刷新缓存解决。
我以前见过一段看似没有异常的扣库存代码:SQL 执行没有抛错,服务也返回成功,但库存不足时实际更新了 0 行,业务层仍然创建了订单。很多团队只判断数据库调用有没有异常,却忽略了“SQL 执行成功”和“库存扣减成功”是两回事,这个问题应该怎么验证?
数据库执行没有异常,只代表语句被数据库接受并完成处理,不代表满足了库存条件。库存扣减必须把“影响行数等于 1”作为业务成功条件,把“影响行数等于 0”明确转换成库存不足、商品状态变化或条件不匹配。
推荐使用带条件的原子更新,而不是先查询再修改:
UPDATE product_stock SET stock = stock - :quantity WHERE product_id = :product_id AND stock >= :quantity;应用层需要严格处理返回值:影响行数为 1 才进入订单确认流程,影响行数为 0 则停止后续创建或进入可重试分支。这里最容易踩的坑是 ORM 或数据访问层只返回“执行成功”,把 0 行更新包装成了正常结果。排查时可以做一个低库存并发测试。
假设初始库存为 10,同时发起 100 个数量为 1 的请求,正确结果应该是最多 10 次扣减成功,数据库库存为 0,其余请求全部失败。若成功订单数超过 10,重点检查是否存在先查后改、影响行数未判断或其他服务绕过了统一扣减入口。
实现方式并发风险负责人判断 先查询库存,再执行扣减多个请求读到同一个旧值不应作为核心扣减逻辑 带 stock 条件的原子更新主要风险转移到结果处理和重试适合作为数据库事实源方案 只在 Redis 中扣减数据库写入失败时出现跨系统偏差必须设计回补和对账 我的经验是,检查影响行数比讨论锁类型更优先。
因为锁只能解决并发访问秩序,不能替业务判断扣减是否真正成立;如果 0 行更新仍被当成成功,换更复杂的锁也只是把错误隐藏得更深。
我们做过一次压测,Redis 扣减成功后故意让数据库连接超时,结果缓存库存已经减少,但订单没有创建。如果只在异常时简单把库存加回去,重试和网络超时又可能让同一笔请求回补两次。我想知道,预扣库存、数据库确认和失败补偿之间应该怎样设计?
Redis 内部的原子性只能保证多个请求不会同时把同一个 Redis 数值改坏,不能保证数据库事务也成功。因此,只要 Redis 承担了预扣库存职责,就必须把一次扣减建模成可追踪的状态流转,而不是简单执行减一或加一。至少应为每次操作生成稳定的业务幂等键,例如订单号加商品 ID。
记录可以包含“预扣成功、数据库确认、回补完成、最终失败”等状态,并为状态变化保留请求 ID、重试次数和时间戳。补偿任务每次执行前先检查当前状态,只有处于可补偿状态的记录才能回补。我更倾向于把数据库确认作为库存最终生效条件。Redis 预扣成功后写入订单或扣减流水;
数据库事务成功则发送确认事件,数据库失败或超时则进入待确认状态。对于超时场景,不能马上回补,因为请求可能已经在数据库侧提交成功,应该先通过订单号或扣减流水查询结果。
异常情况不能直接做的事推荐动作 数据库明确回滚无条件连续回补按幂等键执行一次回补 数据库连接超时立即判断为失败并回补先查询事务或订单最终状态 消息重复消费每消费一次就重复扣减以业务状态和唯一键拦截重复操作 补偿任务执行中断人工直接再次加库存保留任务状态,支持安全重试 如果业务不要求 Redis 预扣,我通常建议优先采用数据库条件扣减,Redis 只做读缓存。
这样故障复杂度会明显降低:缓存更新失败是同步问题,数据库失败是交易问题,两者不会同时承担库存事实。只有在数据库无法承受峰值写入时,才值得引入 Redis 预扣和完整的确认、回补、对账链路。
线上报警时,研发往往同时打开数据库、缓存、消息队列和应用日志,结果看了半小时仍然不知道问题在哪。我的困惑是:库存故障排查是否有固定顺序?哪些指标可以帮助我先判断影响范围,再决定是限流、绕过缓存,还是暂停扣减入口?
排查顺序应该从业务结果开始,而不是从某个基础组件开始。建议先确认影响范围,再确认事实源,最后沿着扣减、同步和补偿链路定位。这样可以避免看到 Redis 报错就直接把全部问题归因于缓存。第一步是确认故障类型:是超卖、少卖、重复扣减、库存展示错误,还是订单状态错误。
第二步锁定时间窗口、商品范围、机房和接口版本。第三步抽样对比数据库库存、缓存库存、订单和库存流水,确认问题是个别商品还是系统性偏差。第二轮检查数据库:确认扣减 SQL 是否带库存条件,是否检查影响行数,是否出现锁等待、死锁或事务回滚,并搜索是否有后台调整、取消回补等其他写入入口。
只有数据库事实明确后,才进入 Redis 和消息链路。第三轮检查缓存与消息:查看缓存删除或更新失败数、Key 是否重复、TTL 是否异常、消息是否积压、是否重复消费、失败消息是否进入重试或死信队列。重点不是看某个组件“有没有报错”,而是确认一次业务操作在哪个节点失去了可追踪性。
排查阶段关键问题可采取的动作 业务结果是否真实超卖或重复扣减暂停高风险入口,保护事实数据 数据库扣减是否原子、事务是否提交修复条件更新和结果判断 缓存旧值是否被重新写回临时绕过异常缓存并重建 消息补偿是否丢失、重复或乱序停止危险补偿,按幂等键重放 对账治理差异能否自动发现和修复建立周期对账与告警 止血时,最重要的是避免继续扩大损失。
若数据库是事实源,可以让关键查询短暂绕过缓存;若怀疑回补任务重复执行,应先暂停补偿消费者;若数据库本身出现负库存,则先限制扣减入口,再修复数据。恢复之后必须做一次全链路复盘,因为没有请求 ID、库存流水和补偿状态的系统,下一次故障仍然只能靠猜。


读者评论
文章把“缓存不一致”和“真实超卖”区分开来,这一点很实用。很多排查一上来只盯着 Redis 数值,反而忽略了数据库条件更新的影响行数和订单流水。
对并发扣减的示例比较清楚,先查询再判断确实容易产生竞态。把库存校验放进带条件的原子更新,并检查影响行数,是更可靠的做法。
文中提到库存口径不统一的问题很关键。总库存、可售库存和锁定库存不能直接横向比较,否则很容易把正常业务差异误判成缓存故障。
文章对请求超时和重复提交的分析比较贴近线上场景。仅依赖订单创建后的幂等校验确实不够,最好从扣减前就关联请求 ID、订单 ID和扣减流水。
延迟双删并不是万能方案,这个判断比较客观。对于预扣减、消息重试和异常回补场景,仍需要完整流水、对账机制以及可停止的补偿任务。