仓储系统里最难排查的缓存问题,往往不是“Redis 删除失败”,而是库存表已经更新、缓存也删过一次,几百毫秒后缓存却又被旧数据重新写回。问题通常发生在数据库事务、主从复制、并发回填和异步消息的交界处。我的判断是:缓存同步不是 Redis 命令设计,而是表结构能否表达业务事实、数据版本和变更事件的问题。如果表里没有版本号、流水和可重试的事件记录,延时双删只能减少部分概率,无法让团队真正做到可追踪、可补偿、可验收。
数据库存:仓储系统团队操作手册:表结构设计中的缓存同步怎么落地
在仓储系统中,数据库应该保存“库存事实”,缓存只负责缩短查询路径。可用库存到底是多少,必须由库存主表、库存流水、扣减事务或库存服务来定义,而不能由某个缓存 Key 的当前值来定义。
这条边界看似基础,实际却是很多项目的分水岭。有些团队为了提高扣减性能,直接读取缓存中的库存数量,再把扣减后的结果写回缓存。只要发生缓存淘汰、服务重启、消息乱序或并发扣减,缓存就可能从“加速层”变成新的事实来源,最终出现超卖、少卖或库存回滚。
我通常要求团队先回答三个问题,再讨论缓存策略:
如果这三个问题没有明确答案,就不建议直接上线延时双删、异步刷新或缓存预热。技术方案可能暂时降低接口延迟,却会把不一致问题推迟到盘点、对账或订单履约环节。
商品名称、图片、包装规格和计量单位出现几秒钟旧值,通常不会导致履约错误;可用库存、预占库存和已分配库存则不同,它们直接影响订单能否继续、拣货任务是否创建以及是否发生超卖。
| 数据对象 | 典型字段 | 旧值容忍度 | 推荐缓存策略 | 最终判断依据 |
|---|---|---|---|---|
| SKU 基础资料 | 名称、条码、规格、图片 | 通常允许短暂旧值 | Cache-Aside、TTL、异步失效 | 商品主数据表 |
| 库位库存 | 库位、批次、数量、状态 | 较短容忍窗口 | 写库后失效,必要时读主库回填 | 库存主表与库存流水 |
| 可用库存 | 可用量、预占量、锁定量 | 不应依赖旧缓存扣减 | 原子更新数据库或库存服务,缓存只读 | 扣减事务或库存服务 |
| 库存报表 | 日结、周转率、出入库汇总 | 通常允许分钟级延迟 | 异步汇总、定时刷新、查询缓存 | 报表计算结果与流水 |
表格中的“旧值容忍度”不是数据库技术参数,而是业务规则。比如拣货员看到的库位可用量可以接受短暂延迟,但订单服务在确认扣减时不能把缓存里的旧库存当成授权依据。读请求可以追求最终一致,写请求必须先守住库存事实。

一张能支撑缓存同步的库存主表,不只是存数量。它至少要表达业务唯一性、当前状态、数据版本和最后变更时间。同步系统还需要知道这次变化由什么业务动作触发,以及变化是否已经成功传播到缓存。
因此,我会把字段分成四组:
没有排序字段时,消费者无法判断旧消息是否晚到;没有追踪字段时,排查人员只能看到“现在是多少”,却不知道“为什么变成这样”;没有业务唯一键时,同一个 SKU 在同一个仓库里可能被不同代码路径写成两条库存记录。
假设仓库 1 中 SKU-A 的可用库存原本是 100。缓存中也是 100。此时订单服务扣减 20,查询服务同时收到一个库存查询请求。看起来只是一次更新和一次查询,但实际可能形成以下时序:
这里真正的问题不是某一条 Redis 删除命令写错,而是读请求在缓存重建时没有确认数据来源和版本。即使再补一次删除,也只能覆盖部分时序。如果副本延迟超过延迟删除窗口,第二次删除完成后仍可能有旧值回填。
我在评审这类链路时,不会先问“延时多久合适”,而会先看四个时间:数据库事务提交时间、主从追平时间、缓存回填时间和消息消费时间。只有知道这四个时间的分布,才能决定延时双删是否有价值。

当出现缓存与数据库不一致时,团队需要按顺序还原:库存主表什么时候变更、哪一张流水记录触发变更、哪一条同步事件负责删除缓存、消费者是否成功处理、缓存最后一次写入来自哪个请求。
如果团队只有一张库存主表,就只能看到最终结果。即使发现缓存不对,也很难判断是数据库更新错误、异步事件丢失、消费者重复执行,还是某个查询接口用旧数据回填。
因此,建议把三张表视为一个最小闭环:
这三张表不代表所有系统都必须完全照搬,但它们分别承担“现在是什么”“怎么变的”“还要传播什么”三个问题。只要缺少其中一环,排查和补偿成本就会明显增加。
访问日志只能说明某个接口被调用过,不能证明缓存写入的是哪个版本。很多团队看到日志中有一次查询,就认为缓存应该已经更新,实际上查询可能读取了从库旧值,也可能在缓存写入前被连接中断。
更可靠的做法是在缓存值或缓存元数据中保存版本号、更新时间和数据来源。例如:
{
"warehouseId": 1,
"skuId": 1001,
"availableQty": 80,
"reservedQty": 12,
"version": 2058,
"source": "warehouse_stock",
"updatedAt": "2026-09-16T10:20:30Z"
}
当缓存出现异常时,运维人员可以直接比较主表版本、事件版本和缓存版本。如果缓存版本小于数据库版本,问题是同步滞后;如果缓存版本大于数据库版本,问题可能是错误写入、测试数据污染或事件模型设计不完整。
先写缓存、后写数据库的方案响应速度快,但它把缓存暴露在数据库事务之外。只要数据库更新失败,缓存就可能提前展示一个不存在的库存状态;如果后续没有回滚缓存,脏数据会持续到 TTL 到期。
这种顺序尤其不适合库存扣减。库存更新失败可能是版本冲突、库存不足、数据库连接异常或事务回滚,缓存却没有能力判断这些业务结果。缓存应该接收数据库已经确认的变更,而不应该提前宣告变更成功。
“更新数据库,然后删除缓存”是 Cache-Aside 中常见的基础路径,但它不能覆盖所有并发情况。删除动作可能失败,删除后可能有读请求从旧数据源回填,跨服务场景还可能因为消息投递失败而没有完成失效。
这并不意味着基础方案不能用,而是要明确它的适用边界。低并发、单库、主库读取、缓存只读且 TTL 较短的商品资料,可以采用这种简单方案。库存、价格、额度和订单状态则应增加版本校验、事件记录或同步巡检。
延时双删通常是第一次删除缓存,等待一段时间后再次删除。它的价值是降低“旧值在删除后被并发读请求回填”的概率,但它不是事务协议,也没有办法天然感知数据库副本是否已经追平。
如果延迟时间设置为 500 毫秒,而实际副本延迟在高峰期达到 2 秒,第二次删除完成后仍然可能有旧数据回填。如果延迟任务执行失败,方案还会退化为一次删除。因此,延时双删应被视为补偿动作,而不是一致性的唯一保障。
TTL 只能限制脏数据最长存活时间,不能阻止错误数据在 TTL 期间被反复读取。对库存接口而言,哪怕脏数据只存活 10 秒,也可能在高并发下影响大量订单判断。
TTL 过短还会带来缓存命中率下降、数据库回源增多和热点 Key 击穿。真正合理的做法是根据业务容忍窗口设置 TTL,再用主动失效、版本校验和异常巡检缩短错误数据的发现与修复时间。
消息队列能提升解耦、削峰和重试能力,但不会自动解决消息丢失、重复、乱序和积压。尤其是数据库事务提交成功后,如果消息发送发生在事务之外,服务进程可能在两者之间宕机,形成“库存已经扣减、同步事件没有产生”的空洞。
更稳妥的方式是采用 Outbox 思路:库存更新、流水写入和同步事件写入放在同一个数据库事务里,再由独立投递程序发送消息。这样即使消息队列短暂不可用,事件仍然留在数据库中,可以继续重试。
刷新缓存看起来更快,但它要求消费者准确构造缓存值,还要处理事件乱序和局部字段更新。如果一个缓存对象由多个表聚合而成,事件消费者可能只拿到部分信息,直接刷新反而会写入不完整快照。
删除缓存通常更保守:数据库变更确认后删除,下一次读取再从可靠数据源重建。对高频更新的库存数据,我更倾向于“写库加事件、异步失效、读主库回填、缓存携带版本”的组合,而不是让多个服务直接写复杂缓存结构。

库存主表最重要的不是字段数量,而是同一个业务对象能否被唯一定位。对于仓库级库存,常见唯一键是 warehouse_id 加 sku_id;如果库存按批次、货主或库位隔离,则必须把这些维度纳入唯一键,否则缓存 Key 和数据库记录会产生映射歧义。
CREATE TABLE warehouse_stock (
id BIGINT PRIMARY KEY,
owner_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
location_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
batch_no VARCHAR(64) NOT NULL DEFAULT '',
on_hand_qty DECIMAL(18, 4) NOT NULL DEFAULT 0,
available_qty DECIMAL(18, 4) NOT NULL DEFAULT 0,
reserved_qty DECIMAL(18, 4) NOT NULL DEFAULT 0,
locked_qty DECIMAL(18, 4) NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
last_request_id VARCHAR(64) NOT NULL DEFAULT '',
last_biz_no VARCHAR(64) NOT NULL DEFAULT '',
updated_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_stock_scope (
owner_id,
warehouse_id,
location_id,
sku_id,
batch_no
),
KEY idx_stock_updated_at (updated_at),
KEY idx_stock_sku (sku_id, warehouse_id)
);
这里的 version 不是装饰字段。它可以通过乐观锁避免并发更新覆盖,也可以作为事件顺序和缓存版本判断依据。last_request_id 用于识别请求重试,last_biz_no 用于关联订单、入库单、出库单或盘点单。
库存数量建议拆成 on_hand_qty、available_qty、reserved_qty 和 locked_qty,而不是只保存一个 total_qty。因为不同业务动作对数量的影响不同:预占可能减少可用量但不减少账面量,出库确认才会减少账面量,盘点调整则可能同时改变多个字段。
流水表的职责不是复制主表,而是记录变化过程。它应能回答“本次变化由什么单据触发、变化前是多少、变化后是多少、哪个请求提交、是否成功”。对于库存争议,流水往往比当前库存值更有解释力。
CREATE TABLE stock_change_log (
id BIGINT PRIMARY KEY,
request_id VARCHAR(64) NOT NULL,
biz_no VARCHAR(64) NOT NULL,
biz_type VARCHAR(32) NOT NULL,
owner_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
location_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
batch_no VARCHAR(64) NOT NULL DEFAULT '',
before_available_qty DECIMAL(18, 4) NOT NULL,
change_available_qty DECIMAL(18, 4) NOT NULL,
after_available_qty DECIMAL(18, 4) NOT NULL,
before_version BIGINT NOT NULL,
after_version BIGINT NOT NULL,
operator_id BIGINT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_request_biz (request_id, biz_no, biz_type),
KEY idx_stock_change (warehouse_id, sku_id, created_at)
);request_id 和业务单号不应混为一谈。业务单号可能在多个接口重试,request_id 更适合识别一次调用。两者同时保留,可以区分“同一张出库单的重复请求”和“不同业务动作碰巧使用相似参数”。
唯一索引 uk_request_biz 是幂等保护的一部分,但不能替代业务层校验。对于一次重试请求,服务仍应返回第一次处理结果或当前状态,而不是因为唯一键冲突后简单返回失败。
直接在事务代码中调用消息队列,最容易出现数据库与消息不一致。库存事务提交后,服务可能在发送消息之前崩溃;如果事务回滚而消息已经发出,消费者又可能误删或刷新缓存。
同步事件表的目标是把“应该传播的变更”持久化。事件表和库存主表在同一个数据库事务中提交,随后由投递器负责发送。这样消息队列短暂不可用时,事件不会凭空消失。
CREATE TABLE cache_sync_event (
id BIGINT PRIMARY KEY,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(160) NOT NULL,
event_type VARCHAR(64) NOT NULL,
event_version BIGINT NOT NULL,
payload JSON NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'PENDING',
retry_count INT NOT NULL DEFAULT 0,
next_retry_at TIMESTAMP NULL,
locked_at TIMESTAMP NULL,
sent_at TIMESTAMP NULL,
last_error VARCHAR(512) NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_aggregate_version (
aggregate_type,
aggregate_id,
event_version
),
KEY idx_pending_retry (status, next_retry_at),
KEY idx_event_created (created_at)
);
aggregate_id 要能稳定对应一个缓存聚合,例如“货主-仓库-SKU”,而不是使用容易变化的页面参数。event_version 必须来自库存主表的版本变化,不能在消息发送时临时生成,否则无法证明事件与数据库事实处于同一顺序。
一次库存扣减的推荐事务边界如下:
示例 SQL 可以这样表达乐观锁约束:
UPDATE warehouse_stock SET available_qty = available_qty - :deductQty, reserved_qty = reserved_qty + :deductQty, version = version + 1, last_request_id = :requestId, last_biz_no = :bizNo, updated_at = CURRENT_TIMESTAMP WHERE owner_id = :ownerId AND warehouse_id = :warehouseId AND location_id = :locationId AND sku_id = :skuId AND batch_no = :batchNo AND version = :oldVersion AND available_qty >= :deductQty;
如果影响行数为 0,不能直接认为数据库异常。它可能代表库存不足,也可能代表版本冲突。两种情况的处理方式不同:库存不足应返回业务失败,版本冲突则可以重新读取并有限重试。
Outbox 设计只有在事件表被持续消费时才有价值。投递器应按状态、重试时间和创建时间扫描待发送事件,发送成功后更新状态;发送失败则记录错误、增加重试次数并计算下一次重试时间。
重试不建议无限快速循环。可以采用递增退避,例如 1 秒、5 秒、30 秒、2 分钟、10 分钟,并设置最大次数。超过上限后进入死信状态,由告警系统通知负责人。
事件投递器还要处理并发抢占。多个实例同时扫描同一事件时,应通过数据库行锁、租约字段或状态条件更新避免重复投递。即使投递仍可能重复,消费者也必须幂等,因为分布式系统中“恰好只投递一次”通常不是一个可以轻易依赖的前提。

Cache-Aside 的读取流程通常是先查缓存,未命中后查数据库,再把结果写入缓存。但对仓储库存而言,真正关键的问题是:未命中时查询主库、只读副本,还是专用库存服务。
如果缓存回填使用只读副本,需要把复制延迟纳入一致性设计。商品基础资料可以接受这种做法;库存查询则应结合业务容忍度。如果查询结果会直接参与订单确认,建议读取主库或调用已经具备原子扣减能力的库存服务。
我会把“展示库存”和“授权库存”分成两个接口。展示库存允许使用缓存并标注更新时间,授权库存必须由事实源重新确认。这样可以避免页面为了追求低延迟,把缓存查询结果直接当成扣减许可。
缓存失效后,大量请求同时回源,会出现缓存击穿;如果回源数据来自延迟副本,还可能把同一个旧值批量写入缓存。常用的处理方式包括互斥锁、请求合并、热点 Key 预热和短暂空值缓存。
互斥锁并不是越重越好。对于单个 SKU 的短时热点,可以让第一个请求负责回源,其余请求等待;对于库存列表或大范围查询,则更适合采用分片缓存、异步刷新或限制单次查询范围。
缓存回填代码至少应带上版本判断。伪代码如下:
cached = cache.get(key) if cached exists: return cached lock(key) try: cached = cache.get(key) if cached exists: return cached record = loadFromReliableSource() value = buildCacheValue(record) current = cache.get(key) if current is empty or value.version >= current.version: cache.set(key, value, ttl) return value finally: unlock(key)
代码中的版本判断不能替代数据库事务,但可以阻止迟到的回源结果覆盖缓存中已经存在的新版本。对于复杂聚合缓存,还需要判断参与聚合的多张表是否来自同一个一致性快照。
推荐写入顺序是:数据库事务更新主表,写入流水,写入同步事件,事务提交,异步删除或刷新缓存。缓存操作不应阻塞库存主事务太久,否则 Redis 的短暂波动可能扩大数据库事务持锁时间。
但异步失效会带来一个可见窗口:数据库已经是新值,缓存暂时仍是旧值。这个窗口必须被业务接受或被读取策略补偿。对可用库存,可以在下单授权环节再次查事实源;对商品资料,则可以接受短暂延迟。
| 判断维度 | 更适合删除缓存 | 更适合刷新缓存 |
|---|---|---|
| 缓存结构 | 结构简单,读取时容易重建 | 聚合计算成本高,重建耗时明显 |
| 更新频率 | 更新频繁,刷新容易产生写放大 | 更新较少,刷新成本可控 |
| 事件顺序 | 无法严格保证顺序时更保守 | 有版本校验或同一聚合顺序消费 |
| 数据来源 | 读主库或可靠服务可快速重建 | 多个表聚合且已有稳定快照机制 |
| 故障恢复 | 删除后重新读取即可修复 | 刷新失败有持久化任务和重试能力 |
删除是把错误状态清空,刷新是主动写入新状态。如果团队没有版本校验和完善重试,我通常优先选择删除;如果缓存重建很重、查询链路复杂,再考虑基于事件版本的刷新。

延时双删最适合处理的是一个窄场景:写请求删除缓存后,读请求在数据库或副本中读到旧值并回填,随后第二次删除把这个错误回填清掉。它对“删除命令失败”“事件没有产生”“消费者长期积压”并没有直接解决能力。
延迟时间不应照搬网上的固定数字。可以先观察数据库复制延迟的 P99、缓存回填耗时 P99 和业务读写并发窗口,再设置初始值。比如复制延迟 P99 为 180 毫秒、回填耗时 P99 为 60 毫秒,初始延迟可以覆盖这两个时间的组合,并通过压测验证,而不是简单写死为 1 秒。
需要注意,复制延迟是动态值。促销、批量入库、夜间盘点和数据库扩容都可能改变延迟分布。因此延时双删任务最好记录计划执行时间、实际执行时间和删除结果,以便判断它是否真的覆盖了风险窗口。
消息队列的定位应该是“把数据库变更传播给缓存及其他下游”,而不是替代数据库事务。一个库存变更事件可以包含聚合主键、版本号、变更类型和必要的业务上下文,但不建议让消费者完全相信消息里的数量字段。
如果消费者的任务只是删除缓存,事件中带主键和版本即可;如果消费者要刷新缓存,则需要更多字段,并且必须确认这些字段来自同一事务快照。对于库存这种高风险数据,我更倾向于事件负责通知失效,缓存重建时再从可靠数据源读取。
假设版本 101 的事件先产生,版本 102 的事件后产生,但由于网络或队列调度,102 先消费、101 后消费。如果消费者没有版本判断,101 可能覆盖 102,系统就发生了“时间倒流”。
消费者的基本规则可以写成:
删除动作本身没有“缓存版本”可比较,但可以把版本写入缓存元数据,或者在刷新前查询数据库当前版本。对于只做删除的消费者,版本主要用于监控和补偿判断;对于直接刷新缓存的消费者,版本是必须条件。

| 策略 | 主要解决的问题 | 额外成本 | 不适合的场景 |
|---|---|---|---|
| 单次删除 | 正常写库后的缓存失效 | 最低 | 高并发回填、跨服务传播 |
| 延时双删 | 部分并发回填旧数据 | 延时任务、监控和重试 | 复制延迟不可控、消息经常丢失 |
| Outbox 加消息失效 | 可靠传播、异步补偿 | 事件表、投递器、死信处理 | 团队没有运维和告警能力 |
| 版本化刷新 | 乱序消息和旧值覆盖 | 缓存版本、消费者逻辑、回源校验 | 缓存对象无法稳定定义版本 |
下面用一个常见的仓储场景说明落地方式。某仓库中 SKU-A 的账面库存为 500,可用库存为 420,已预占库存为 80。订单确认需要预占 30 件,成功后可用库存应变成 390,预占库存变成 110,账面库存仍为 500。
这次操作不是简单地把一个 total_qty 减去 30。它包含库存状态迁移,也必须保留订单号、请求号、旧版本和新版本。缓存如果只存一个数量,后续很难判断它代表账面库存、可用库存还是订单可预占库存。
| 字段 | 变更前 | 变更量 | 变更后 | 业务解释 |
|---|---|---|---|---|
| on_hand_qty | 500 | 0 | 500 | 预占尚未形成出库事实 |
| available_qty | 420 | -30 | 390 | 可继续分配的数量减少 |
| reserved_qty | 80 | +30 | 110 | 订单占用增加 |
| version | 2057 | +1 | 2058 | 本次变更的新版本 |
服务收到 request_id 为 R-20260916-0001、业务单号为 O-10086 的预占请求。第一步不是写缓存,而是根据仓库、SKU、批次和库位定位库存记录,并获取当前版本 2057。
如果同一请求因为网络超时重试,服务应先根据 request_id 和业务单号查询流水表。如果第一次事务已经成功,就返回第一次处理结果;如果第一次事务处于处理中,则根据状态继续查询,而不是再次扣减。
随后执行带版本条件的更新。如果更新影响行数为 1,说明版本匹配且可用库存足够;如果影响行数为 0,则分别判断库存不足和版本冲突。这个判断要写进团队规范,不能统一返回“库存更新失败”,否则重试策略会变得不可控。
库存主表更新成功后,服务在同一事务中写入库存流水,并生成版本为 2058 的同步事件。事件的 aggregate_id 可以是 owner_id、warehouse_id、location_id、sku_id 和 batch_no 的稳定组合。
事件 payload 不应只放一个“删除 Key”的字符串,还应包含事件类型、聚合主键、版本号和业务单号。这样出现异常时,运营人员可以根据事件记录定位具体库存,而不是依赖日志中一段难以检索的拼接文本。
{
"eventType": "STOCK_RESERVATION_CHANGED",
"aggregateId": "owner-7/warehouse-1/location-18/sku-1001/batch-B202609",
"eventVersion": 2058,
"bizNo": "O-10086",
"requestId": "R-20260916-0001",
"cacheKeys": [
"stock:warehouse:1:sku:1001",
"stock:location:18:sku:1001"
]
}
事务提交成功后,投递器发送事件。消费者收到事件后,可以删除仓库级库存缓存和库位级库存缓存。如果某个 Key 删除失败,不应影响其他 Key 的处理结果,也不应把整条事件直接标记为成功。
建议为每个缓存目标记录处理状态,或者把一个聚合事件拆成可独立重试的缓存任务。否则一个缓存 Key 持续失败会掩盖其他 Key 已经成功的事实,重试时也无法准确判断从哪里继续。
如果业务采用直接刷新缓存,消费者应先检查当前缓存版本。只有事件版本大于等于当前版本时才允许写入;如果当前缓存版本更高,则把本次事件记录为迟到事件,不要强制覆盖。
即使消息系统有重试和死信,也建议对高价值库存做抽样巡检。巡检不需要每秒扫描全部库存,可以按仓库、SKU 热度、最近变更时间和异常订单进行采样。
巡检逻辑可以比较数据库主表的 version 与缓存中保存的 version。发现缓存版本低于数据库版本时,记录同步延迟;发现缓存数量与数据库数量不一致时,先判断缓存是否属于允许的延迟窗口,再决定自动删除还是人工介入。

缓存命中率高,只说明请求找到了缓存,并不说明缓存内容是最新的。一个被错误数据长期填充的缓存,命中率可能非常漂亮,但业务结果完全不可靠。
仓储系统至少要同时看四类指标:性能、时效、正确性和可恢复性。性能包括命中率、接口延迟和数据库回源量;时效包括事件消费延迟和副本追平延迟;正确性包括抽样比对差异和旧版本覆盖次数;可恢复性包括重试成功率、死信量和人工修复耗时。
| 指标类别 | 关键指标 | 建议观察方式 | 异常含义 |
|---|---|---|---|
| 性能 | 缓存命中率、回源 QPS、P95 延迟 | 按接口、仓库和热点 SKU 分组 | 可能存在 TTL 过短、热点击穿或缓存容量不足 |
| 同步时效 | 事件消费延迟、主从延迟 | 关注 P95、P99,而不是平均值 | 平均值正常但高峰期仍可能大量读旧值 |
| 正确性 | 缓存与数据库抽样差异、旧版本拒绝次数 | 按版本和业务对象抽样比对 | 可能存在乱序消费或错误回填 |
| 可恢复性 | 重试成功率、死信数、人工修复耗时 | 按事件类型和失败原因统计 | 说明链路虽有告警,但补偿能力不足 |
平均延迟适合观察整体性能,不能用来确定一致性补偿窗口。比如平均主从延迟只有 40 毫秒,但少量批量任务可能把 P99 推到 1.5 秒。如果延时双删只等待 100 毫秒,覆盖的只是大多数正常请求,最危险的高峰请求仍然暴露在窗口之外。
建议至少连续观察一周,分别记录主从复制、消息消费、缓存回填和删除任务的 P95、P99。然后在压测环境模拟批量入库、促销扣减、盘点调整和 Redis 重启,确认补偿策略不会因为单一延迟峰值失效。

正常链路测试只能证明“服务都正常时能跑通”。缓存同步最容易出问题的时刻,恰恰是数据库提交成功后服务重启、消息重复、消费者暂停、只读副本延迟或缓存集群短暂不可用。
我建议上线前至少做以下故障注入:
测试结果不要只记录“通过”或“失败”。应记录触发条件、异常持续时间、自动修复时间、是否需要人工操作,以及数据库、缓存和事件表最终是否恢复一致。

如果系统是单体应用,商品资料主要由一个服务维护,数据库没有明显主从延迟,缓存只用于商品名称、规格和图片,可以使用 Cache-Aside 加写库后删除缓存。
这类场景不必一开始就引入复杂消息队列。建议保留更新时间字段、删除失败日志和短周期巡检,先把缓存写入权限收敛到一个服务。TTL 可以作为兜底,但不要把 TTL 当成唯一同步方案。
当仓储服务、订单服务、采购服务和报表服务分别维护自己的读模型时,建议采用库存主表加流水表加 Outbox 事件表。数据库事务只负责确认库存事实,缓存和下游读模型通过事件异步更新。
此时应把库存聚合主键设计清楚,避免订单服务按 SKU 缓存、仓储服务按库位缓存、报表服务按仓库缓存,却没有统一的失效关系。一次库位库存变化,可能影响 SKU 总库存、仓库总库存和可用库存列表,事件应明确哪些读模型需要失效。
如果缓存未命中后的回源请求默认走只读副本,必须先确认业务是否允许读到旧值。商品资料和报表通常问题不大,库存可用量则要更谨慎。
可以采用分层策略:普通展示请求读缓存或副本;订单确认、库存扣减和拣货分配请求读取主库或调用专用库存接口;缓存重建敏感数据时优先使用主库。这样不必让所有查询都承担主库压力,也不会把高风险写操作交给旧缓存。
这类业务的特点是短时间内大量更新同一批库存记录,消息积压、数据库锁等待和缓存热点会同时出现。此时应优先保证库存事务的正确性,再通过批量失效、事件合并和热点保护降低下游压力。
同一库存聚合在极短时间内连续产生多个版本时,可以在投递层做有限合并:只保留尚未投递的最新事件。但合并必须谨慎,不能跳过需要记录的业务流水,也不能让下游误以为中间的库存动作没有发生。
如果团队没有可靠的消息队列、事件监控和死信处理能力,不建议为了“架构完整”强行引入异步链路。一个没有运维闭环的复杂方案,可能比简单但可观测的同步删除更危险。
可以先采用数据库事务加同步事件表,再由定时任务扫描失败和未处理事件,执行缓存删除。等事件量、跨服务需求和故障频率达到一定规模后,再替换为正式消息队列。关键不是一开始选最复杂的组件,而是先把事件记录和补偿思路建立起来。
写库后删除缓存的优势是代码少、链路短、故障面小。对于更新频率低、读请求不参与关键业务决策的数据,它往往足够可靠。
它的边界也很明显:删除失败需要额外记录,并发回填可能带来短暂旧值,跨服务同步需要新增通知机制。如果系统未来会拆分订单、仓储和库存服务,早期没有事件模型,后续补建会比一开始保留事件字段更困难。
延时双删不需要改变所有读取逻辑,适合在已有 Cache-Aside 体系上做增量加固。它能覆盖一部分旧值回填时序,部署成本相对较低。
但它依赖延时任务调度、数据库复制延迟和回填窗口判断。它不能证明数据库和缓存已经一致,也不能代替删除失败重试。对于库存核心链路,延时双删应该与事件表、版本号和巡检配合使用。
Outbox 的最大价值是把“数据库已经发生的变更”持久化下来。消息队列不可用时,事件还可以在数据库中等待;消费者失败时,也能看到重试次数和错误原因。
代价是需要维护事件表、投递器、消息确认、幂等消费者、死信队列和监控告警。团队必须接受至少一次投递的现实,并在业务代码中处理重复和乱序,而不是把可靠性完全寄托给中间件。
直接刷新缓存可以减少缓存未命中和回源压力,适合读模型稳定、聚合计算昂贵、事件顺序可控的场景。比如库存列表经过多表聚合且计算成本高,异步刷新可能比每次删除后重建更省数据库资源。
但刷新要求消费者掌握完整的数据构造逻辑。如果事件只携带部分字段,消费者可能写入不完整对象;如果多个事件乱序到达,旧结果可能覆盖新结果。因此,刷新方案必须配合版本号、完整快照或可靠回源。
| 方案 | 研发投入 | 数据库压力 | 故障可追踪性 | 推荐应用 |
|---|---|---|---|---|
| 写库后单次删除 | 低 | 低到中 | 低 | 低风险基础资料 |
| 延时双删 | 中 | 低到中 | 中 | 并发回填明显但事件体系尚未完善的系统 |
| Outbox 加异步删除 | 中到高 | 低到中 | 高 | 多服务库存和订单协同 |
| 版本化事件刷新 | 高 | 较低 | 高 | 聚合缓存、查询成本高且版本稳定的读模型 |

不要直接从 Redis Key 开始设计。先列出库存的业务维度:货主、仓库、库区、库位、SKU、批次和库存状态,再确认每个查询接口使用哪些维度。
例如,warehouse:1:sku:1001 代表仓库级聚合,location:18:sku:1001 代表库位级明细,二者的数据来源不同、失效范围也不同。库位库存发生变化时,仓库级聚合缓存也可能需要失效。如果 Key 关系没有文档,新增接口很容易漏删某个聚合缓存。
表结构评审时,优先检查 version、updated_at、request_id、biz_no 和变更前后数量。如果这些字段缺失,先不要讨论消息顺序,因为系统根本没有足够信息判断顺序和重复。
版本号的生成方式要统一。可以采用数据库行版本递增,也可以使用业务序列,但必须保证同一库存聚合的版本单调递增。不同服务各自生成版本号,容易出现同一对象出现两个无法比较的版本。
库存更新成功、流水写入成功、同步事件写入成功,三者必须作为一个事务结果对外可见。任何一个失败,都应该回滚整个库存变更,而不是让部分状态提交。
如果历史原因导致事件表无法与库存表放在同一个数据库中,应明确接受更高风险,并增加定时对账任务。不要把跨库写入包装成“已经具备事务一致性”,这会让团队在故障时产生错误判断。
消费者文档中至少要写清楚四件事:如何识别重复事件、如何拒绝旧版本、删除失败如何重试、连续失败何时进入死信。只有写了这些规则,消息消费才算可运营。
删除缓存的消费者可以把“缓存不存在”视为成功,因为目标状态已经达到;刷新缓存的消费者则必须先比较版本。重复事件不应持续报高优先级错误,否则真正的删除失败会被大量幂等日志淹没。
自动化系统也需要人工修复入口。修复命令至少要支持按照仓库、SKU、库位、批次和版本查询,并能执行定向删除或重新回填。修复操作必须记录操作者、原因、时间和修复前后版本。
不要允许运维人员直接在缓存服务器上手工删除一串 Key,却不在业务系统留下记录。直接删 Key 可能暂时解决展示问题,但无法说明为什么异常、影响了哪些订单、后续是否还会再次出现。
| 验收项 | 最低要求 | 验证方式 | 不通过时的处理 |
|---|---|---|---|
| 库存扣减幂等 | 同一请求重复提交不重复扣减 | 重复请求、网络重试测试 | 检查请求唯一键和流水唯一索引 |
| 事件可靠投递 | 事务成功后事件可追踪 | 提交后杀进程、停止消息服务 | 检查 Outbox 状态和重试逻辑 |
| 乱序防护 | 旧版本不能覆盖新版本 | 反向投递多个版本事件 | 增加消费者版本判断 |
| 缓存删除失败 | 失败可重试并告警 | 模拟缓存不可用 | 检查重试、死信和人工补偿 |
| 异常可发现 | 可定位到仓库、SKU 和版本 | 执行抽样巡检和故障复盘 | 补齐事件、流水和监控字段 |

收到“页面库存不对”的反馈后,第一步不是清空全部缓存,而是查询库存主表、库存流水和最近一次同步事件。先判断数据库事实是否正确,避免把业务扣减错误误判成缓存问题。
需要核对的字段包括仓库、SKU、库位、批次、available_qty、reserved_qty、version、updated_at 和 last_biz_no。随后查找最近一次库存流水的 before、change 和 after 数量,确认数量变化是否符合业务动作。
如果数据库版本是 2058,而缓存版本是 2057,说明缓存只是同步滞后;如果缓存版本是 2059,但数据库仍是 2058,就需要检查是否有绕过数据库的缓存写入、测试脚本或乱序事件。
如果缓存没有保存版本,至少要从缓存写入日志、事件消费日志和请求链路日志中还原。没有版本字段时,故障排查会明显依赖时间戳,而时间戳在多机房、时钟偏差和异步重试场景下并不总是可靠。
删除失败通常表现为缓存长期保留旧版本,且没有看到成功删除记录;旧值回填则常表现为缓存先被删除,随后又出现旧版本写入。两者的修复方向不同:前者要重试删除,后者要检查回源数据源、并发窗口和版本校验。
可以在缓存写入日志中记录 source、event_version、request_id 和 caller。这样可以知道究竟是事件消费者写入了缓存,还是某个查询接口直接回填了缓存。缓存写入来源不唯一,是很多系统难以治理的根因。
如果缓存版本低于数据库版本,且数据库事实已经确认无误,通常可以自动删除缓存并重新回填。如果缓存版本高于数据库版本,则不应盲目覆盖数据库或直接接受缓存,需要先检查非法写入和事件乱序。
如果库存已经影响订单履约,应把缓存修复和业务补偿分开处理。清理缓存只能修复读模型,不能自动修正已经错误确认的订单、拣货任务或出库单。涉及业务结果时,必须回到库存流水和业务单据进行核查。

缓存适合承载读取压力,不适合独立承担库存扣减授权,除非系统使用的是经过严格设计的原子库存组件,并且该组件本身就是库存事实服务的一部分。普通 Key-Value 缓存没有业务流水、订单幂等和事务回滚能力。
如果为了性能必须在缓存中执行预扣减,也要把它当成一个独立库存服务来设计:需要原子操作、预扣减流水、超时释放、数据库对账、失败补偿和订单状态关联。简单地对一个数字执行减法,不能称为可靠库存方案。
商品基础资料、仓库配置、库位名称、库存展示摘要和权限范围等数据,通常具备高频读取、低频更新和容易重建的特点,更适合缓存。
对库存展示摘要,可以保存最近确认版本和更新时间,并在页面上区分“实时确认库存”和“展示库存”。这不仅有助于用户理解,也能减少业务方把缓存读数误认为扣减授权的风险。
把整个仓库的库存列表放进一个大 Key,更新一个 SKU 就要删除或重建整份列表,会造成严重写放大和缓存抖动。把每个 SKU 的库存放成独立 Key,又可能增加批量查询的网络往返。
合理粒度需要结合访问模式:单 SKU 高频查询可以按 SKU 缓存;库位列表查询可以按仓库和分页维度缓存;报表则适合异步生成结果。表结构中的业务唯一键,应与缓存对象粒度保持可解释的对应关系。

仓储系统的缓存同步不可能因为增加一个 Redis 删除命令就变成绝对实时。数据库事务会失败,副本会延迟,消息会重复或乱序,缓存也会短暂不可用。工程设计的重点不是假设这些事情不会发生,而是让它们发生后仍然能够被发现、定位和修复。
我的建议是按风险分层建设:商品资料先采用简单的写库后失效;库存展示增加版本和回源策略;库存扣减以数据库事务或专用库存服务为事实边界;跨服务场景使用 Outbox 和幂等消费者;高并发回填再根据监控决定是否引入延时双删。
不要从“延迟双删还是消息队列”开始做方案,而要从“库存表能否表达版本、变化和待传播事件”开始做设计。当主表、流水表、事件表、缓存版本和补偿任务形成闭环时,缓存不一致就不再是无法解释的线上玄学,而会变成一个有状态、有指标、有处理入口的工程问题。
下一步可以从一个真实库存聚合开始:选定一个仓库和一个 SKU,画出数据库更新、缓存读取、事件投递和异常修复的完整时序;随后补齐 version、request_id、业务流水和同步事件表;最后用重复请求、消息乱序、主从延迟和缓存不可用四类故障注入验证。不要一次改造所有缓存,先把一个高价值库存链路做成可追踪、可补偿的样板,再复制到其他仓储数据对象。
我以前以为缓存同步主要是 Redis 的读写问题,库存表只要保存数量就够了。后来在并发扣减和消息乱序场景里,我发现没有版本号就无法判断缓存里的数据到底是新是旧,也很难解释一次错误回填是怎么发生的。
库存缓存同步的起点不是删除缓存,而是让数据库记录足够完整的业务事实。至少要能回答四个问题:哪个仓库的哪个 SKU 发生了变化、变化前后是多少、这次变化属于哪个版本、缓存同步是否已经处理。我在设计仓储库存表时,通常会把业务唯一键、版本号和更新时间作为基础字段,而不是把这些信息留给应用日志。
示例结构如下: 字段作用缺少后的问题 warehouse_id + sku_id确定库存聚合对象无法准确定位缓存 Key available_qty保存可用库存事实容易把缓存误当成最终数据源 version判断变更的新旧顺序旧消息可能覆盖新缓存 updated_at排查数据延迟和异常回填很难还原故障时间线 版本号的价值尤其容易被低估。
假设库存从版本 101 更新到 102,事件 B 先于事件 A 到达缓存消费者,如果没有版本判断,旧事件 A 可能把已经更新的库存重新写回缓存。
缓存值也建议带上版本信息,而不是只保存一个数量: { warehouseId": 1 skuId": 1001 availableQty": 88 version": 102 updatedAt": "2026-09-16T10:00:00Z }消费者处理事件时,应遵循“新版本才能覆盖旧版本”的规则:事件版本大于缓存版本才更新,等于缓存版本视为重复消费,小于缓存版本则拒绝覆盖。
这样即使消息重复或乱序,系统也有明确的自我保护机制。我的判断是:如果一个团队只讨论“删缓存还是刷新缓存”,却没有在表结构中保存版本和事件信息,那么它解决的只是正常路径,异常路径仍然不可追踪、不可恢复。
我曾经遇到过库存主表已经提交成功,但消息发送因为网络抖动失败的情况。结果数据库里的数量是新的,缓存却一直保留旧值,直到人工删除缓存才恢复,这让我开始把同步事件当成数据库事务的一部分来设计。
直接在业务代码里执行“更新库存、发送消息、删除缓存”,看起来简单,实际上存在明显的事务断裂。数据库提交成功但消息发送失败时,业务事实已经改变,系统却没有留下可靠的同步任务。更稳妥的做法是使用 Outbox 思路,把库存变更和同步事件放进同一个数据库事务: 1. 开启数据库事务。2. 更新库存主表。
写入库存流水,记录业务单号、变更前数量和变更后数量。4. 写入缓存同步事件表。5. 三者一起提交。6. 独立投递程序读取待发送事件,发送到消息系统。7. 消费者执行缓存失效或刷新,并回写处理状态。
方案数据库成功、消息失败时补偿能力适合库存同步吗 事务内直接发消息容易出现事务断裂依赖额外日志或人工排查不建议作为唯一方案 数据库提交后直接删缓存删除失败后缺少任务记录通常只能依赖 TTL适合简单低风险数据 库存表加 Outbox 事件表事件仍保存在数据库中可重试、可查状态、可进死信更适合库存和库位数据 事件表至少应有 aggregate_id、event_version、status、retry_count 和 next_retry_at。
它们分别用于定位业务对象、保证版本顺序、记录处理状态、控制重试次数和安排下一次补偿。需要注意的是,Outbox 只解决“变更事件可靠落库”,并不自动解决消费者幂等、消息乱序和缓存删除失败。消费者仍然要用业务主键加事件版本判断是否已经处理过。我通常把事件表视为同步链路的“工作台”,而不是临时日志。
没有它,团队只能知道缓存错了;有了它,团队才能进一步知道哪条事件没发送、重试了几次、当前卡在哪个环节。
我测试过延时双删后发现,它确实能降低并发读写造成的旧值回填,但并不是加一个定时任务就结束了。特别是数据库存在主从延迟、消息队列有积压时,网上常见的固定延迟值并不能直接套用。
延时双删主要针对这样一条并发链路:写请求更新数据库并删除缓存,读请求恰好从旧数据源读取数据,随后把旧值重新写入缓存,第一次删除因此被错误回填覆盖。第二次删除的作用,是把这次可能产生的旧缓存再次清掉。它可以作为缓存失效机制的一部分,但不能被当成一致性保证。
延迟时间应根据实际系统的复制延迟、缓存回填耗时、消息消费延迟和并发窗口来确定,而不是直接照搬一个固定数字。
影响因素需要观察的指标对延迟配置的影响 数据库主从复制复制延迟 P95、P99从库越慢,安全窗口越长 缓存回填查询和序列化耗时回填越慢,二次删除越晚 消息系统消费延迟和积压量异步失效不能只依赖定时删除 业务并发同一 SKU 的并发读写量热点越明显,越需要版本保护 我的落地顺序通常是:先完成数据库事务,再记录同步事件,随后执行第一次缓存失效;
如果业务确实存在旧值回填风险,再安排第二次删除。延时任务必须具备持久化记录、失败重试和可查询状态,不能只在应用进程内启动一个不可追踪的定时器。对于库存这类敏感数据,缓存重建尽量不要直接读取延迟明显的从库。
如果缓存失效后从库还没有追上主库,读请求可能把旧库存重新写回缓存,延时双删只能降低概率,不能从根源上消除这个问题。更可靠的组合是“数据库版本号加事件表加延时失效”。即使二次删除任务晚到,缓存消费者仍可通过版本号拒绝旧事件覆盖新数据。
上线前建议压测至少四种情况:正常更新、并发读写、从库人为延迟、消息重复或延迟。只有观察到缓存版本落后比例、二次删除成功率和事件积压量,才能决定延迟参数是否合理。
我刚开始做仓储系统时,曾经把商品名称、库存数量和库存报表都按同一种 Cache-Aside 方式处理。后来发现商品名称短暂旧一点通常没有影响,但库存缓存一旦参与扣减判断,就可能造成超卖或错误分配,所以我认为必须先按业务风险分级。
不同数据不能使用“一套策略打天下”。缓存同步的关键不是某种 Redis 技巧,而是业务能容忍多长时间的旧值,以及缓存是否参与最终决策。
数据类型是否允许短暂旧值缓存能否作为最终依据推荐策略 商品名称、图片、规格描述通常允许可以用于展示Cache-Aside 加 TTL,更新后删除缓存 可用库存查询容忍窗口较短不应单独作为扣减依据数据库或库存服务为准,缓存异步失效 库存扣减和预占通常不允许依赖旧值不可以数据库原子更新或专用库存服务处理 日报、库存周转报表通常允许可以延迟计算异步汇总、定时刷新或批量重建 库存扣减的核心判断应该落在数据库原子条件更新或专用库存服务中,例如使用“可用库存大于扣减数量”作为更新条件,而不是先从 Redis 读取一个数字,再由应用层决定是否允许扣减。
UPDATE warehouse_stock SET available_qty = available_qty - :qty version = version + 1 updated_at = CURRENT_TIMESTAMP WHERE warehouse_id = :warehouse_id AND sku_id = :sku_id AND available_qty >= :qty;如果影响行数为 1,说明扣减成功;如果影响行数为 0,则需要区分库存不足、记录不存在或版本冲突。这样即便缓存暂时落后,也不会直接把错误库存写成业务事实。商品资料则可以采用更宽松的策略。更新数据库后删除缓存,读取请求未命中时重新加载;即使缓存短时间显示旧商品名称,也通常不会影响履约结果。
我建议团队在设计评审时增加一列“缓存是否参与最终决策”。如果答案是“是”,就必须重新审查一致性模型;如果答案是“否”,缓存可以只承担加速读取的职责,失效和刷新策略也可以相对简单。这项区分直接影响成本:库存数据需要版本、事件、补偿和更严格监控,商品资料可能只需要失效加 TTL。
先做业务分级,再决定技术复杂度,通常比先选某个缓存方案更省时间。


读者评论
文章把缓存一致性问题归因到表结构和事件链路,而不是简单归咎于删除缓存,这个视角比较准确。库存主表、流水表、事件表分别记录事实、变化和待传播状态,确实有助于故障追踪。
对延时双删局限性的分析很实用,尤其指出副本延迟可能超过删除窗口。实际落地时还需要结合主从延迟监控和业务可接受的旧值时间,不能直接套用固定毫秒数。
Outbox方案适合解决数据库提交成功但消息未发出的场景,不过事件表长期积累、重复消费和失败重试也会增加运维成本,文章如果补充清理策略和幂等示例会更完整。
文章区分了商品资料、库位库存和可用库存的一致性要求,避免所有数据采用同一套缓存策略。对仓储系统来说,这种按业务风险分级的设计比单纯追求高缓存命中率更合理。