《数据库存:技术负责人怎么用:从库存流水到改善缓存同步》真正要解决的,不是“库存放 MySQL 还是 Redis”这道选型题,而是一个更容易被忽略的问题:当数据库、库存流水和缓存中的数字不一致时,团队能不能解释差异、阻止错误继续扩散,并在不依赖人工查表的情况下恢复系统。我在做库存、订单和营销数据链路治理时,见过最危险的情况不是缓存短暂旧几秒,而是系统没人知道哪一个数字才算数。
先讲核心结论:库存不是一个数字,而是一套可追溯的数据系统
先把三种库存分开,否则缓存同步一定会混乱
库存系统里通常同时存在三种不同性质的数据。第一种是库存状态,例如某个 SKU 当前可售数量为 97;第二种是库存流水,例如这 3 件库存分别因下单锁定、支付扣减还是订单取消而发生变化;第三种是缓存副本,例如 Redis 中保存一个供商品详情页快速读取的库存数量。
这三者回答的问题完全不同。库存状态回答“现在是多少”,库存流水回答“为什么变成这样”,缓存副本回答“如何让更多请求更快拿到结果”。如果团队把三者都称为“库存”,就会在故障时出现争论:有人认为数据库对,有人认为缓存对,还有人拿订单日志去证明自己的判断。
数据形态
主要问题
建议承担的职责
出现差异时的处理方式
库存状态
当前还能卖多少
交易校验、库存扣减、库存快照
以业务定义的权威存储为准
库存流水
库存为什么发生变化
审计、对账、回放、异常定位
检查业务单号、幂等键和变更顺序
缓存副本
如何更快读取库存
展示、预校验、流量削峰
删除、重建或按版本刷新
我的基本判断是:缓存可以是库存状态的服务副本,但不能在没有持久化、审计和恢复机制的前提下,直接成为唯一业务事实。这不是对某一种架构的绝对否定,而是要求技术负责人先明确业务后果:缓存丢失时能否重建,缓存错误时会不会造成超卖,数据库写成功而缓存更新失败时有没有补偿。
数据库不是“存数据的地方”,而是业务事实的边界
很多团队把数据库理解成性能瓶颈,把缓存理解成性能答案。但在库存业务里,数据库更重要的职责是保存可验证的业务结果。一次库存扣减至少要能回答四个问题:谁发起的、扣了多少、扣减前是多少、扣减后是多少。
如果数据库里只有一个 available_stock 字段,而没有对应的流水记录,那么系统即使短期运行正常,后续也很难处理订单取消、支付超时、重复请求和人工修复。技术负责人不能只问“这条 SQL 能不能扣减成功”,还要问“一个月后财务或运营追查这次变化时,能不能还原完整过程”。
缓存同步的目标不是绝对同时,而是可控的最终一致
数据库事务提交和缓存更新通常不是一个原子操作。数据库提交成功后,缓存删除可能失败;缓存删除成功后,下一次读取又可能在数据库短暂不可用时加载失败;消息异步同步还会引入延迟、重复消费、乱序和积压。
因此,工程上更现实的目标是把一致性拆成三个层次:交易扣减必须可靠,库存流水必须可追溯,缓存副本必须在可接受时间内收敛。对于商品列表展示,几秒钟延迟通常可以接受;对于最终下单扣减,就不能只依赖一个可能过期的缓存值。

背景和真实场景:库存出错通常发生在链路交界处
一个“看起来只是缓存旧了”的故障
我曾经处理过一类典型问题:活动商品详情页显示还有库存,用户提交订单后却频繁提示库存不足。最开始,开发人员把问题归因于缓存刷新延迟,并准备缩短缓存过期时间。进一步排查后发现,真正的问题有三层。
第一层是数据库扣减成功后,缓存删除请求因网络抖动失败;第二层是重试任务没有携带版本号,旧的库存事件晚到后覆盖了较新的缓存值;第三层是订单取消回补只更新了库存表,没有生成独立流水,因此对账时无法区分“正常回补”和“人工补库存”。
换句话说,缓存旧值只是用户看到的表象。系统真正缺少的是一条从库存变化到缓存收敛的可验证链路。单纯把 TTL 从 60 秒改成 10 秒,只能缩短部分错误的存活时间,不能解决事件乱序和回补丢失。
库存变化至少有六种业务动作
技术方案如果只考虑“下单减库存”,一定会在真实业务中失效。至少需要区分初始化入库、下单锁定、支付确认、订单取消释放、售后回补和人工调整。不同动作的业务含义不同,反向操作也不能简单地用一个“加库存”覆盖。
初始化入库:仓储或采购系统将可售数量写入库存系统。
下单锁定:为订单暂时占用库存,防止并发订单重复占用。
支付确认:把锁定库存转化为已售或已出库库存。
订单取消释放:释放未支付订单占用的数量。
售后回补:退货、拒收或质检完成后重新进入可售库存。
人工调整:盘点差异、损耗、赠品或业务补偿导致的变更。
我建议流水中的“变更类型”不要只写正负数,而要写成具有业务语义的枚举。因为 1 件库存增加,可能是订单取消释放,也可能是仓库入库;前者应关联订单,后者应关联入库单。没有动作类型,后续的对账和权限审计都会变得模糊。
“库存为 0”也不是一个简单结论
有些系统只有一个可用库存字段,但业务上至少要区分物理库存、锁定库存、可售库存和在途库存。一个商品物理上有 100 件,可能已经有 20 件被订单锁定,那么可售库存未必还是 100。
如果缓存只保存一个笼统的 stock,而订单服务、商品详情页和仓储服务对这个字段的解释不同,缓存同步越快,错误传播反而越快。因此,技术负责人需要先统一库存口径,再讨论同步频率和缓存结构。

常见误区:很多“高并发优化”其实只是把问题往后推
误区一:库存都放进缓存,数据库只做落盘
这种方案在读多写少的商品展示场景中看起来很有效,但如果库存扣减直接依赖缓存,就必须回答 Redis 故障、持久化延迟、主从切换和数据恢复后的库存来源问题。缓存中的数字一旦成为唯一扣减依据,系统的可靠性就会被缓存集群的异常行为牵引。
我并不反对使用 Redis 承担热点库存扣减。对于极端流量、强削峰和快速失败场景,缓存原子操作确实可能比数据库行锁更适合承受第一层请求。但这时必须把“缓存扣减成功”和“业务订单最终成立”分开,建立持久化流水、可靠事件和补偿机制,而不是把一个原子减法误认为完整交易。
误区二:数据库更新成功后直接更新缓存,就已经一致
直接更新缓存的最大风险是并发乱序。假设数据库先后产生库存 99 和 98 两个版本,代表 98 的事件因为网络抖动先到,代表 99 的旧事件随后到达。如果消费者只执行 SET stock 99,缓存就会被旧事件覆盖。
更新缓存方案至少需要版本控制。缓存记录中可以保存库存值和逻辑版本,例如 stock:98。消费者只有在事件版本大于当前版本时才更新,或者使用按 SKU 分区的顺序消费机制。否则,所谓“实时更新”只是把数据乱序问题暴露给了缓存。
误区三:延迟双删可以解决所有缓存一致性问题
延迟双删通常被描述为:更新数据库前删除一次缓存,数据库更新后再删除一次缓存。它可以降低某些并发读写场景下旧值重新进入缓存的概率,但它不是事务协议,也不能保证删除请求一定成功。
当数据库事务回滚、服务进程崩溃、删除任务丢失或多个数据中心存在延迟时,延迟双删仍然会失效。我的判断是:它适合低复杂度、可容忍短暂旧值的系统,不能替代可靠事件、失败重试、对账和缓存重建。
误区四:消息队列只要开启重试,就不会丢数据
重试只能处理消费者暂时失败,不能自动解决生产端事件丢失、消息重复和死信无人处理。如果业务事务提交成功,但发送消息的进程在发送前崩溃,单纯依赖“事务后发送消息”就可能形成数据库已有变化、消息系统却没有事件的缺口。
库存场景更适合采用 Outbox 或可靠事件表。业务事务在更新库存的同时写入事件记录,后台投递器再将事件发送到消息队列。这样即使投递器暂时故障,也能根据事件表继续投递,而不是靠内存中的一次发送机会。
误区五:监控缓存命中率,就等于监控库存一致性
缓存命中率只能说明请求有没有从缓存拿到结果,不能说明结果是否正确。一个已经过期但仍被命中的库存值,会让命中率看起来很好,却可能造成大量错误下单。
库存系统至少需要同时监控缓存命中率、缓存删除失败数、库存事件延迟、消息积压量、数据库与缓存差异数量,以及差异持续时间。尤其是“差异持续时间”,比单纯的差异次数更能说明补偿链路是否真正有效。

专业判断逻辑:先定事实边界,再选同步方案
第一个问题:谁是最终事实来源
我通常会要求团队在架构文档第一页写出一句明确的话:“当库存表、库存流水、缓存和订单状态不一致时,系统以什么为准。”如果这句话无法写清楚,后面的缓存策略、消息重试和监控指标都没有稳定基础。
在多数普通交易系统中,库存表和库存流水共同构成核心事实,库存表保存当前快照,库存流水保存变化依据。缓存只是为了提高读取效率的派生副本。对于极端流量系统,也可以让缓存承担第一层库存令牌扣减,但数据库或事件存储仍然需要保存最终可审计结果。
权威模式
适合场景
主要优势
主要代价
数据库权威
普通电商、订单、仓储库存
事务、审计和恢复路径清楚
热点行更新可能产生锁竞争
缓存先扣减、数据库落账
秒杀、抢购、强流量削峰
快速失败,降低数据库瞬时写压力
需要可靠落账、回滚和丢失恢复
事件流驱动
多个下游需要订阅库存变化
解耦、可追踪、便于重放
顺序、重复、积压和消费治理复杂
第二个问题:不同读路径是否需要同一个一致性等级
商品详情页显示“仅剩少量”通常是营销展示,它关注的是用户体验和转化,不一定要求每次读取都与数据库完全同步。下单校验则不同,它直接关系到是否承诺库存,应该走更严格的扣减逻辑。
技术负责人不应追求全系统统一的强一致,因为这会把所有读请求都压到最昂贵的链路上。更合理的做法是按业务动作分级:展示允许秒级延迟,预校验允许短暂误判但必须快速失败,最终扣减必须具备原子条件和幂等约束,财务与仓储结算必须可审计。
第三个问题:数据变化如何可靠传播
库存变化通常需要通知商品展示、订单、仓储、营销和数据分析等多个下游。直接在业务代码里逐个调用,会让库存服务和所有下游强耦合;只更新数据库又会导致下游无法及时感知变化。
我倾向于把“业务事实写入”和“变化传播”拆开。库存事务负责写状态和流水,Outbox 负责记录待发送事件,投递器负责把事件发送到消息系统,消费者负责更新缓存或执行下游动作。这个设计多了几张表和一组后台任务,却换来了可检查、可重试和可追踪。
第四个问题:失败后如何证明系统已经恢复
“重试成功”不等于“数据已经正确”。例如缓存删除重试成功,只能说明删除动作执行过;如果期间有旧消息再次到达,缓存仍可能被旧值写回。因此恢复机制必须能够比较版本、检查流水,并在必要时重新从权威数据构建缓存。
我会把恢复能力拆成三个动作:第一,发现差异;第二,判断差异原因;第三,执行可审计修复。修复不能直接在后台把库存加减一个数字,而应生成一条明确的调整流水,记录操作人、原因、原值、新值和关联工单。

具体实现:一条可落地的库存扣减与缓存同步链路
先设计库存表,而不是先设计 Redis Key
一个基础库存表可以包含 SKU、可售库存、锁定库存、已售库存、版本号和更新时间。版本号不是装饰字段,它用于识别事件新旧、避免旧消息覆盖新状态,也可以帮助排查同一 SKU 的更新顺序。
`CREATE TABLE inventory (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sku_id BIGINT NOT NULL,
available_stock INT NOT NULL DEFAULT 0,
locked_stock INT NOT NULL DEFAULT 0,
sold_stock INT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_sku_id (sku_id)
);`
库存流水表则应尽量保存变更前后值,而不是只保存一个增减数量。只保存 delta = -1 不能直接证明当时库存是多少,也不利于发现并发更新和重复扣减。保存前后快照会增加存储量,但这部分成本通常远低于库存事故后的排查成本。
`CREATE TABLE inventory_flow (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
sku_id BIGINT NOT NULL,
business_id VARCHAR(64) NOT NULL,
change_type VARCHAR(32) NOT NULL,
change_quantity INT NOT NULL,
before_available INT NOT NULL,
after_available INT NOT NULL,
version BIGINT NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_idempotency_key (idempotency_key),
UNIQUE KEY uk_event_id (event_id)
);`
用条件更新完成原子扣减
库存扣减不要采用“先查询库存,再在业务代码里判断,最后执行更新”的流程。多个请求同时查询到库存为 1 时,都可能继续执行更新。更稳妥的做法是把库存是否足够写进 UPDATE 条件,并依据受影响行数判断结果。
`UPDATE inventory
SET available_stock = available_stock – :quantity,
locked_stock = locked_stock + :quantity,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE sku_id = :sku_id
AND available_stock >= :quantity;`
影响行数为 1,说明数据库完成了满足条件的扣减;影响行数为 0,说明库存不足、SKU 不存在或条件未满足。实际项目中还要检查索引是否命中、锁等待是否过长、事务隔离级别是否符合预期,不能只看 SQL 语句表面上“用了条件更新”。
状态和流水必须处于同一个事务边界
库存表更新成功而流水插入失败,会形成“有结果、无依据”;流水写成功而库存更新失败,则会形成“有依据、无结果”。这两种情况都会破坏对账,因此库存状态变化和对应流水通常应该在同一个数据库事务中完成。
在事务内部,还应先检查幂等键。幂等键可以由订单号、SKU 和动作类型组合而成,例如 ORDER20260916001:SKU-A:LOCK。如果同一个请求因为客户端重试、网关重试或消费者重试再次到达,系统应返回第一次处理结果,而不是重复扣减。
使用 Outbox 避免“数据库成功、消息没发出去”
库存事务提交后再发送消息,看起来代码简单,但中间存在进程崩溃窗口。更稳妥的方式是在同一个事务中写入库存状态、库存流水和 Outbox 事件。后台投递器不断扫描未发送事件,成功投递后更新状态,失败则按照退避策略重试。
`BEGIN;
— 1. 幂等检查
— 2. 条件扣减库存
— 3. 写入库存流水
— 4. 写入 Outbox 事件
INSERT INTO outbox_event (
event_id,
aggregate_type,
aggregate_id,
event_type,
payload,
status,
created_at
) VALUES (
:event_id,
'SKU',
:sku_id,
'InventoryChanged',
:payload,
'PENDING',
CURRENT_TIMESTAMP
);
COMMIT;`
Outbox 不是为了让消息队列变得“绝对可靠”,而是把业务事实和待传播事实放在同一个事务里。它解决的是生产端缺口,消费者侧仍然需要处理重复、乱序、消费失败和死信。
缓存优先采用删除,而不是盲目覆盖
在普通读多写少的库存系统中,我更常建议数据库事务成功后删除缓存,而不是直接把一个计算出来的库存值写入缓存。删除的好处是逻辑简单,下次读取时从权威存储重新加载;同时也避免旧事件把新值覆盖。
删除缓存也不是无条件安全。删除失败时要进入重试队列,超过重试次数进入死信,并由对账任务发现。对于热点 SKU,可以采用延迟重试、主动刷新和短 TTL 组合,避免一个偶发网络错误让旧值长期留在缓存里。
`数据库事务提交成功
v
记录 CacheInvalidate 事件
v
消费者删除 inventory:{sku_id}
成功 失败
结束 指数退避重试
超过阈值进入死信
对账修复`
必须更新缓存时,加上版本保护
有些场景不适合删除缓存,例如库存展示链路要求持续返回最新快照,或者缓存中包含复杂的预计算结构。这时可以采用带版本的更新策略。事件中携带数据库版本,消费者读取当前缓存版本,仅当新事件版本更大时才更新。
-- 伪代码:只接受更高版本的事件
current = redis.get("inventory:" + skuId)
if current == null or event.version > current.version:
redis.set(
"inventory:" + skuId,
{
"available": event.availableStock,
"version": event.version,
"updatedAt": event.updatedAt
}
)
else:
ignore(event)版本号应来自能够保证单调变化的地方,例如库存表的版本字段或同一 SKU 的逻辑序列。不要直接使用应用服务器时间作为唯一排序依据,因为多台服务器的时钟偏差和网络延迟都可能导致时间判断错误。

下面用一个情景模拟说明流水、状态和缓存如何配合。SKU-A 初始可售库存为 100。订单 A 锁定 3 件,订单 B 锁定 2 件,订单 C 因库存不足失败,订单 A 支付成功后转为已售,订单 B 超时取消并释放 2 件。
| 顺序 | 业务动作 | 变更数量 | 可售库存 | 锁定库存 | 流水幂等键 |
|---|---|---|---|---|---|
| 初始 | 期初库存 | 0 | 100 | 0 | INIT:SKU-A |
| 1 | 订单 A 锁定 | -3 | 97 | 3 | A:SKU-A:LOCK |
| 2 | 订单 B 锁定 | -2 | 95 | 5 | B:SKU-A:LOCK |
| 3 | 订单 A 支付确认 | 0 | 95 | 2 | A:SKU-A:PAY |
| 4 | 订单 B 取消释放 | +2 | 97 | 0 | B:SKU-A:RELEASE |
这个例子最终可售库存是 97,而不是简单地把支付确认再次减 3。因为库存口径采用了“下单时锁定、支付时状态转换”的模型。若团队采用“支付时才扣库存”,流水过程会不同,但必须从一开始统一口径,不能让订单服务和仓储服务各自解释。
这正是库存流水的价值:它不只是日志,而是能让团队知道每一次变化的业务原因。对账时,可以把不同动作分开统计,快速判断是锁定未释放、回补重复,还是仓储入库延迟。

在实际排查中,我发现缓存差异通常不会均匀分布在所有商品上。活动期间,少数热点 SKU 承担了大量并发请求和库存事件,它们更容易遇到锁竞争、消费积压、缓存击穿和事件乱序。
因此,对账不能只做全量定时扫描,也应该对热点 SKU 提高检查频率。可以根据近一小时的库存变更次数、消息数量和缓存访问量动态划分风险等级。热点商品采用分钟级对账,普通商品采用小时级或日级对账,通常比所有 SKU 一刀切更节省资源。
假设一天出现 1 万次缓存删除失败,但每次都在 2 秒内重试成功,业务风险可能低于只出现 10 次、每次持续 30 分钟的缓存错误。后者更可能让用户长期看到错误库存,甚至造成订单转化损失。
所以我会重点关注三个指标:差异数量、差异持续时间和差异影响的请求量。差异数量用于判断链路稳定性,持续时间用于判断补偿速度,影响请求量用于判断实际业务损失。只看失败次数,无法体现这三者的差别。

如果对账只告诉你“数据库库存是 97,缓存库存是 95”,它只能发现问题,不能帮助解决问题。对账结果至少要关联最近一批库存流水、事件 ID、缓存版本和业务单号,回答差异是由哪一次动作产生的。
我建议对账任务输出三类结果。第一类是缓存落后,可以直接重建;第二类是库存流水缺失,需要阻断自动修复并进入人工审核;第三类是重复动作或反向动作错误,需要根据订单状态和幂等记录判断。不同类型使用同一个“刷新缓存”按钮,容易把业务问题掩盖掉。
如果系统日常流量平稳,库存写入并不集中,缓存主要服务商品展示和查询,我建议从最小可靠方案开始:数据库保存库存状态和流水,使用条件更新完成扣减,事务成功后删除缓存,删除失败进入重试队列,定期执行数据库与缓存对账。
这个方案的优点是链路短、团队容易理解、故障恢复成本较低。它不适合极端秒杀流量,但适合绝大多数还在建立库存治理能力的业务。很多团队的问题不是方案不够先进,而是基础流水、幂等和对账都没有完成,就急着引入复杂组件。
当一个或少数 SKU 在短时间内承受大量请求时,数据库行锁可能成为瓶颈。此时可以让缓存承担令牌预扣或快速失败,只有拿到库存令牌的请求才进入后续订单流程。
但这套方案不能只写成“Redis 扣减成功就下单”。必须配套处理令牌扣减成功后订单创建失败、消息投递失败、服务重启、缓存主从切换和订单超时释放。每一次令牌变化都要有可追踪的请求 ID,最终必须能与订单结果或补偿结果对应。
| 必须处理的情况 | 不能只依赖的做法 | 建议补充的机制 |
|---|---|---|
| 令牌扣减成功,订单创建失败 | 等待缓存过期 | 补偿队列、令牌释放、异常流水 |
| 消息发送失败 | 仅在应用日志打印错误 | 可靠事件表、投递重试、死信告警 |
| 缓存节点故障 | 直接从缓存恢复库存 | 权威快照、流水重放、分批重建 |
| 重复请求 | 依赖客户端不重试 | 业务幂等键、结果缓存、唯一约束 |
我的判断是,热点系统可以把缓存放在更靠前的位置,但不能让“速度”取消“可审计性”。如果团队暂时没有可靠事件和补偿能力,就不应贸然把缓存扣减结果当成最终库存。
如果库存变化需要同步给商品、订单、仓储、营销和分析系统,业务代码逐个调用会快速膨胀。此时可以使用 Outbox 加消息队列,或者使用 Binlog、CDC 将数据库变更传播给下游。
两者不能简单按“谁更先进”来选择。Outbox 事件包含明确业务语义,例如“订单取消释放库存”;CDC 更擅长捕获数据库行变化,但不一定知道这个变化为什么发生。对于需要业务动作的下游,事件语义通常更清晰;对于通用数据同步和分析订阅,CDC 可能更合适。
发生库存差异时,第一步不是立即重构同步方案,而是确认是否存在持续性业务风险。可以暂时关闭高风险 SKU 的缓存写入,强制下单走权威校验;也可以把展示口径改为“库存紧张”而不是展示精确数字,避免错误数据扩大影响。
如果问题只发生在缓存层,重建缓存通常可以恢复;如果数据库状态本身和流水对不上,就不能用刷新缓存掩盖问题。后者需要进入业务事故或数据治理流程。
库存越接近 0,单次错误的相对影响越大。1000 件库存出现 1 件展示误差,可能只影响一笔订单;库存剩 1 件时,任何旧值都可能直接导致超卖或错误承诺。
对于低库存 SKU,可以设置更严格的策略:展示层不显示精确数量,下单时强制走数据库条件扣减,缓存只做无库存快速拦截;当缓存与数据库出现差异时,优先保证不超卖,而不是优先保证用户看到的数字足够漂亮。

删除缓存的逻辑更简单,适合缓存可重建、读请求远多于写请求、业务允许短暂旧值的场景。它的主要问题是删除失败或缓存重建瞬间出现并发读取时,仍可能返回旧数据,因此需要 TTL、重试和对账共同兜底。
更新缓存适合对展示延迟要求更高、缓存结构稳定、事件顺序可控的场景。它减少了下一次读取时的回源,但必须处理乱序和重复事件。只要系统存在多个写入方,更新缓存前就应该问一句:所有写入方是否都能提供统一版本号。
| 方案 | 用户可见延迟 | 实现复杂度 | 故障恢复能力 | 适合判断 |
|---|---|---|---|---|
| 事务内同步更新缓存 | 低 | 中 | 较弱 | 链路简单、数据量小、可接受强耦合 |
| 提交后删除缓存 | 低至中 | 低 | 中 | 缓存可重建、短暂旧值可接受 |
| Outbox 加消息队列 | 中 | 中至高 | 强 | 需要可靠传播和多下游订阅 |
| CDC 同步 | 中 | 高 | 强 | 已有数据平台和统一订阅基础设施 |
同步方式没有绝对优劣。同步更新减少了最终一致窗口,但会把缓存故障带进主交易链路;异步消息提高了系统隔离性,却必须接受传播延迟,并投入成本建设重试、死信、监控和对账。
数据库原子扣减的优点是业务事实清楚,扣减和流水容易放入事务中,缺点是热点行可能出现锁竞争。缓存令牌扣减的优点是吞吐高、快速失败和削峰能力强,缺点是订单结果、库存落账和回补机制更加复杂。
选择时可以用一个简单公式判断:如果一次库存错误会直接造成不可接受的履约或财务损失,而当前数据库容量仍能承受峰值,那么优先保证事实清楚;如果流量峰值会让数据库在几秒内失去响应,且团队具备可靠事件和补偿能力,再考虑缓存令牌。

引入消息队列、Outbox、CDC、分区顺序消费和多级缓存后,系统的组件数量会增加。每个组件都带来新的监控、发布、扩容、权限和故障演练成本。技术负责人需要估算的不是开发工作量,而是系统进入长期运行后的维护工作量。
如果这些问题没有答案,复杂架构只是把风险从数据库锁竞争转移到消息积压、死信堆积和人工运维上。真正成熟的方案不是组件最多,而是每个失败状态都有对应的发现、处理和验证机制。
基础设施监控只能告诉我们 Redis 是否存活、数据库 CPU 是否过高。库存治理还需要业务监控。例如负库存数量、超卖订单数、库存扣减失败率、订单取消回补成功率和重复操作比例。
我建议把库存监控分成三层。第一层是交易结果,关注有没有错误承诺;第二层是数据链路,关注事件是否及时传播;第三层是治理结果,关注差异是否被发现和修复。三层指标同时正常,才能说明系统真正稳定。
对账不应只是把两张表导出后用表格软件比较。对于每个 SKU,可以根据期初快照和流水计算理论库存。基础公式可以写成:期末理论可售库存等于期初可售库存,加上入库和释放,减去锁定、出库及其他经过定义的扣减动作。
理论可售库存 =
期初可售库存
+ 入库数量
+ 取消释放数量
+ 售后回补数量
下单锁定数量
其他有效扣减数量
缓存差异 =
数据库可售库存 – 缓存可售库存
公式本身不复杂,难点在于动作定义必须统一。例如支付确认是状态转换还是再次扣减,必须在数据字典中固定下来。否则不同团队各自统计,会得到多个“理论库存”,对账就失去了权威性。
我会把差异分为四类:缓存落后、缓存超前、流水缺失、业务状态冲突。缓存落后通常可以删除后重建;缓存超前需要检查旧事件覆盖或错误写入;流水缺失需要暂停自动修复;业务状态冲突则要检查订单与库存动作是否重复。
对账系统最好提供从 SKU 到事件、从事件到订单、从订单到操作人的跳转路径。这样值班人员不必在多个日志平台之间手工拼接时间线,能够把人工处理耗时从几个小时压缩到几十分钟。
很多团队做过 Redis 宕机,却没测试“数据库已提交、缓存删除请求刚好超时”的窗口;做过消息消费者重启,却没测试“消费者重启后重复处理上一条成功消息”;做过数据库主从切换,却没测试切换期间事件版本是否继续单调。
每次演练都应记录发现时间、止损时间、修复时间和验证时间。只有把这些时间纳入稳定性指标,团队才知道补偿机制是真能运行,还是仅仅存在于设计文档里。

如果团队当前没有完整流水,第一阶段不要急于做缓存架构升级。先建立库存状态表、库存流水表和业务幂等约束,明确每种库存动作的枚举和状态转换规则。
这一阶段的验收标准不是 QPS 提高了多少,而是任意一个库存数字都能回溯到对应流水。无法解释的库存变化数量,应成为第一阶段最重要的质量指标。
当状态和流水稳定后,再引入缓存删除或更新机制。缓存只保存可重建数据,所有缓存变更失败都必须进入重试或对账流程。重建缓存时要限制并发,避免大量 SKU 同时失效导致数据库回源洪峰。
这时可以建立缓存同步面板,至少展示待处理事件数、平均消费延迟、最大消费延迟、删除失败数、死信数和数据库与缓存差异数。面板必须支持按 SKU、事件 ID 和业务单号查询,否则出了问题仍然只能看聚合数字。
如果库存变化需要通知多个下游,可以引入 Outbox 和消息队列。先把消息生产端做可靠,再处理消费者幂等和顺序。不要同时上线十几个消费者,否则出现问题时很难判断是库存服务、消息系统还是下游逻辑造成的。
建议按照“一个事件、一个消费者、一个修复路径”的方式逐步扩展。每新增一个下游,都要明确它是否需要全量库存快照、增量变更数量,还是只需要业务动作通知。不同需求不应共用一个含义模糊的事件格式。
只有当数据库原子扣减在真实压测和线上指标中证明已经成为瓶颈,才有必要考虑缓存令牌、分片库存或事件流削峰。否则,复杂方案带来的恢复成本可能超过性能收益。
进入这一阶段后,需要补充压测和故障演练数据,包括峰值请求数、库存冲突率、数据库锁等待、消息延迟、缓存重建时间和异常回补成功率。没有这些数据,架构升级很容易变成凭经验堆组件。


数据库、缓存和消息队列都只是实现手段。技术负责人真正要定义的是业务事实、库存口径、一致性等级和故障责任边界。没有这些定义,任何组件组合都可能在正常路径上运行,却在取消、回补、重复和故障场景中失效。
我最看重的不是系统能否在演示环境里把库存减得很快,而是出现差异后,团队能否在短时间内回答三个问题:差异从哪一次业务动作开始,当前哪个数字可以作为事实,下一步怎样修复并证明修复有效。
如果只能记住一句话,请记住:库存状态是结果,库存流水是依据,缓存是副本,消息是传播机制,对账是恢复能力。当这五个角色被清楚分开,技术负责人才能在性能、一致性和复杂度之间做出可解释的选择,而不是在事故发生后临时争论哪个系统“看起来更像真相”。

我负责过一个促销库存服务,最初把 Redis 里的可用库存当成下单判断依据,平时看起来很快,但订单取消和缓存重启后经常出现数字对不上。我想知道,技术负责人应该如何划分数据库、库存流水和缓存的职责,才能既保证性能,又不会让异常变成“查不清、修不了”?
我的判断是:库存不能只看成一个数字,而要拆成“当前状态、变化依据、读取副本”三层。数据库中的库存状态回答“现在剩多少”,库存流水回答“为什么变成这个数”,缓存则只是为了缩短读取路径的副本。三者职责混在一起,系统短期可能很快,长期一定难以对账。
在我复盘过的一次促销系统中,团队只保留了 inventory 表里的 available_stock,没有记录每次扣减的业务单号和变更类型。一次订单超时回补失败后,大家只能通过订单表猜测库存去向,最终花了近两个小时才定位到问题。
后来我们把库存流水设为必选记录,所有扣减、锁定、释放和回补都必须带业务单号与幂等键。
数据形态主要职责能否作为最终依据 库存状态表保存当前可用、锁定、已售等状态可以,需配合事务和原子更新 库存流水表记录库存每次变化的原因和业务上下文用于审计、对账和恢复 Redis 缓存承接高频读取和快速预校验通常不应单独作为最终依据 因此,建议先确定一个简单边界:数据库事务负责确认库存变化,库存流水负责解释变化,缓存负责加速读取。
下单时可以先用缓存拦截明显无库存请求,但最终扣减仍要经过具备条件判断的原子更新,例如“available_stock >= quantity”时才允许扣减。如果业务确实要求 Redis 先扣库存,也必须补齐持久化、失败转储、流水落库、恢复重放和对账机制。
技术负责人不应只问“哪个方案性能最高”,而应该先问:缓存丢失后能否重建,库存异常后能否解释,扣减结果能否被业务单号唯一追溯。
我测试过“写数据库后直接更新缓存”和“写数据库后删除缓存”两种方案,发现前者在并发乱序时可能把新值覆盖掉,后者又会遇到删除失败和短暂旧值问题。面对普通商品、热点商品和秒杀商品,技术负责人应该怎样按风险和成本做选择,而不是机械套用一种方案?
我不会把“更新缓存”或“删除缓存”当成普适答案,而会先看缓存数据能否重建、业务能容忍多久的不一致,以及同一商品是否存在高频并发写入。缓存能够从数据库快速重建,并且业务允许短暂旧值时,写库后删除缓存通常更容易控制;缓存值复杂、重建成本高且必须尽快展示新状态时,才考虑事件驱动更新。
我曾在一个库存查询接口中做过对比测试:直接更新缓存的链路平均延迟更低,但并发压测时出现过旧事件覆盖新事件的问题。两个消费者分别处理库存 98 和库存 97 的事件,97 先到、98 后到时没有问题;一旦 98 先到、97 后到,缓存就会回退。问题不在 Redis,而在事件没有版本号。
方案优点主要风险更适合 写库后删除缓存逻辑简单,缓存可自然重建删除失败或并发读到旧值普通读多写少业务 写库后更新缓存读取更快看到新值事件乱序、重复覆盖缓存结构稳定且有版本控制 消息队列异步同步解耦,可扩展多个消费者延迟、重复消费、消息积压多系统订阅库存变化 Binlog 或 CDC业务代码侵入较少,可追踪数据库变化不一定等于业务事件已有数据订阅基础设施的团队 对于大多数中小型库存系统,我建议从“数据库原子扣减 + 事务内写流水 + 提交后删除缓存 + 删除失败重试”开始。
这里的关键不是删除动作本身,而是删除失败必须可观测、可重试、可对账,不能把缓存删除当成一次不允许失败的同步调用。当同一 SKU 的变更频繁到缓存删除和重建形成竞争时,再引入可靠事件表或 Outbox,将事务提交和待发送事件绑定起来。缓存消费者需要按事件 ID 幂等,并使用库存版本号拒绝旧事件;
否则,消息队列只是把缓存问题从同步接口转移到了异步链路。选型时可以用一个实际标准:如果缓存错一次只会导致页面短暂显示旧库存,优先选择简单的删除缓存;如果错误会直接造成超卖或错误履约,就不能让缓存承担最终扣减责任,必须把权威校验放在具备事务语义的库存服务中。
我以前只在流水表里记录 SKU、变更数量和创建时间,后来遇到重复消费时,发现同一个订单可能被扣减两次,却没有足够信息判断哪一条是重复操作。库存流水到底应该怎样设计,才能在出现回补失败、消息重放或人工修复时,快速回答“谁在什么时候因为什么改了库存”?
库存流水不是操作日志的放大版,它应该成为库存变化的业务证据。最容易被忽略的是“变更前”和“变更后”两个快照:只有变更数量时,理论上可以累加,但遇到并发、乱序或历史数据修复,很难判断某一步是否已经发生。记录前后值后,单条流水就具备了基本的可验证性。
我在一次重复消费排查中发现,消息体只有 sku_id 和 quantity,消费者重试时无法区分“同一订单的再次投递”和“新的库存扣减”。后来把 order_id、operation_type 和 idempotency_key 纳入唯一约束,重复消息不再依赖应用层猜测,而是由数据库明确拒绝。
字段类别建议字段解决的问题 业务身份业务单号、SKU、操作类型知道是哪笔订单、哪个商品、什么动作 幂等控制幂等键、事件 ID、唯一约束防止重复扣减和重复回补 库存结果变更前库存、变更数量、变更后库存验证变化是否符合预期 链路追踪来源服务、请求 ID、操作者、重试次数定位调用方和异常重试 时间与顺序业务发生时间、落库时间、库存版本识别延迟、乱序和旧事件 修复信息补偿单号、修复原因、修复人区分正常业务与人工调整 流水写入最好和库存状态更新处于同一个数据库事务中。
典型过程是:先执行带库存条件的原子扣减,再插入库存流水;如果任一步失败,整个事务回滚。这样可以避免库存已经减少,但没有任何流水解释这次变化。库存流水还需要区分操作类型,例如下单锁定、支付扣减、订单取消释放、售后回补和人工盘点调整。
不要把所有动作都写成“库存减少”或“库存增加”,因为不同动作的补偿方式和责任边界完全不同。最后要注意流水表的可维护性。高频库存业务不适合无限制地把所有字段塞进一张宽表,可以按时间分区或归档,但归档不能破坏对账链路。技术负责人应保留从库存当前值追溯到业务单号,再追溯到原始请求和事件的完整路径。
我参与过一次线上故障演练,正常下单、正常支付都没有问题,但一旦模拟缓存删除失败和消息重复投递,数据库与 Redis 的差异就持续扩大。除了看缓存命中率和接口延迟,技术负责人还应该建立哪些监控、对账和恢复机制,才能知道系统是否正在悄悄失真?
可靠性不能用“接口没有报错”证明。库存同步系统至少要同时观察业务结果、消息链路和数据差异三类指标,因为缓存命中率很高,反而可能意味着大量请求命中了错误的旧值。我的经验是,库存系统最有价值的监控不是单一延迟曲线,而是“差异数量 + 差异持续时间 + 自动修复结果”。
在一次演练中,我们故意让缓存删除接口连续失败 5 分钟。系统表面上仍能正常返回,但对账任务发现 1,200 个热点 SKU 的缓存版本落后于数据库。真正帮助定位问题的不是 Redis 命中率,而是每个 SKU 都带有库存版本,监控可以直接统计版本差值和持续时间。
监控层关键指标建议动作 业务层扣减成功率、回补成功率、负库存数、重复操作数发现超卖、漏回补和幂等缺陷 数据库层原子更新耗时、锁等待、事务回滚数定位热点 SKU 和写冲突 消息层发布失败、消费延迟、重试次数、死信数量触发重试、限流或人工介入 缓存层删除失败、版本落后、重建次数、命中率判断缓存是否可继续承接流量 对账层数据库与缓存差异、差异持续时间、修复成功率自动修复并升级长期异常 对账不能只比较“当前库存数”,还应利用库存流水重新计算理论库存。
基本公式可以是:期初库存 + 入库流水 + 回补流水 – 锁定扣减 – 出库扣减 = 理论库存。理论库存、库存状态表和缓存值三者出现差异时,先判断差异属于缓存延迟、业务状态未完成,还是确实发生了漏记或重复记账。恢复机制建议分成三个等级。短暂删除失败由自动重试处理;
超过重试阈值的事件进入死信并触发告警;无法自动判断的库存差异进入人工修复台,要求填写修复原因和关联单号。人工修复不能直接改一个数字,应该生成一条明确的库存调整流水。在上线前至少演练五类故障:数据库成功但缓存删除失败、消息重复消费、消息乱序、订单取消回补失败、缓存整体丢失。
每类故障都要记录发现时间、自动恢复时间、是否需要人工介入以及最终对账结果。能否完成这套演练,比“压测时跑了多少 QPS”更能说明库存同步方案是否成熟。


读者评论
文章把库存状态、库存流水和缓存副本区分得很清楚,这个边界对排查数据不一致很有帮助。尤其是强调流水要记录业务动作,比单纯记录增减数量更具备审计价值。
缓存同步部分比较贴近实际,版本控制、消息乱序和失败重试都是容易被忽略的问题。不过不同业务对延迟的容忍度不同,落地时还需要结合交易规模和恢复成本评估。
对延迟双删的分析比较客观,没有把它当成万能方案。对于库存这类关键数据,Outbox、可靠事件表和对账机制确实比单纯缩短缓存过期时间更稳妥。
文章提到监控缓存命中率不能代表库存正确,这一点很有价值。补充监控差异持续时间、消息积压和删除失败数,能更准确判断补偿链路是否有效。
库存动作拆分得较完整,涵盖锁定、支付确认、取消释放和售后回补。实际实施中还应明确各动作的幂等键、状态流转规则及人工调整权限,避免重复回补或越权修改。