缓存删除失败怎么补偿:从提交窗口到旧值回填
先提交数据库,再删除缓存,可以让后续未命中读取已提交的新值,但提交与删除之间仍有故障窗口。 要在进程退出后继续补偿,失效意图必须能够持久恢复;本文采用与业务修改同事务写入的 outbox,数据库变更捕获(CDC)也是可选路径。可靠补偿解决“删除没有可靠完成”,仍不能单独解决并发读把旧值重新写回。Cache-Aside 提供的是有条件的最终一致性,不能据此承诺每次读取都是最新值。
适用版本与边界
本文面向 Redis Open Source 7.2/7.4、关系数据库与应用自行维护的 Cache-Aside。只使用已有的 GET、SET EX、DEL 语义,不依赖新版本命令。数据库是事实来源;订单扣款、库存条件更新等正确性判断应落在数据库约束或事务中。
Redis 官方 Jedis 示例说明了写后失效。下文的 outbox 和故障处理是基于这些命令语义作出的工程设计推导,不是 Redis 自动提供的跨库事务。
1. 为什么写后删除仍可能读到旧值?
设商品原值为 v1,写请求准备更新为 v2。要区分以下路径,不能都归结为“Redis 删除失败”。
| 失败时序 | 直接后果 | 优先处理 |
|---|---|---|
| 数据库提交 v2 → 进程退出 → 尚未删除缓存 | v1 继续命中,内存重试也丢失 | 提交前持久化失效待办 |
| 提交 v2 → 发出 DEL → 响应丢失 | 客户端不知道删除是否完成 | 按结果未知处理,允许重复删除 |
| 读 A 未命中并取得 v1 → 写 B 提交 v2 并删除 → A 回填 v1 | 删除成功,缓存仍再次变旧 | 约束回填顺序或版本,另设过期兜底 |
| 写 B 提交并删除 → 读 A 查询延迟副本并回填 | 缓存保存副本旧数据 | 需要新值的链路读主库或校验复制进度 |
先删缓存再提交也有风险:并发读可在两步之间取出数据库旧值。把删除放在提交后只是减少一种窗口,不会让数据库和 Redis 原子变化。Redis 事务文档中的隔离保证针对 Redis 命令组,不能覆盖外部 SQL。
2. 补偿任务应该何时落盘?
如果提交后才向消息队列发送任务,进程仍可能在发送前退出。可在业务数据库建立 outbox 表,把待办和业务行一起提交;事务回滚,两者一起撤销。AWS 官方 outbox 模式说明给出了同事务写表、仅转发已提交记录的机制,也列出 CDC 路径与重复消费风险。这里将该机制用于缓存失效:代价是每次业务写入增加 outbox 写入,以及扫描、积压治理和已完成记录清理的维护成本;扫描间隔与重试等待也会延长陈旧窗口。因此它保证失效意图可恢复,不保证提交后立即读到新值。以下是说明性伪代码,未实际执行,省略表结构、领取任务和异常处理实现。
同一个数据库事务:
更新 product,写入新值 v2
插入 cache_outbox(event_id, cache_key, state=PENDING)
提交事务
后台扫描已提交的待办,领取 product:42 的失效任务
执行 DEL product:42
收到成功响应后,在数据库中将任务标记为 DONE
连接超时则保留待办,退避后重试消费端应先删除、后确认完成;若两步之间退出,恢复后会重复删除。DEL 文档规定不存在的键会被忽略,因此返回删除数为 0 也可视为该次失效操作成功,不能无限重试到返回 1。重复删除可能清掉刚重建的新缓存,增加一次回源,但不会把旧业务值写回。
多消费者用数据库条件更新或可过期租约领取任务,防止退出后永久占用。记录事件 ID、键、尝试次数、下次执行时间和错误;失败重试采用有上限的退避并加抖动。达到自动重试上限后仍保留可人工重放的记录并告警,不能直接当作完成。一次事件若影响详情、列表等多个键,必须列全依赖或分别保存失效进度。
3. 延时双删、TTL、版本号各补什么?
延迟再删一次能覆盖部分慢回填,但固定等待无法覆盖所有停顿、网络延迟和副本滞后;延迟任务本身也需要可靠保存。它是降低竞态影响的措施,不能作为一致性证明。
每次回填使用带过期参数的写入,避免“先 SET、后 EXPIRE”中途退出留下永久键。SET 官方文档确认 EX 可在设置值时指定秒级过期。TTL 从本次写入计时;如果旧读稍后才回填,陈旧窗口也从那时重新开始。只有停止旧回填、读取源足够新且不再续期时,TTL 才能成为可靠的兜底边界。
版本校验必须覆盖所有回填路径,并在 Redis 内原子比较后写入;应用先 GET 版本、再 SET 值,两步之间仍可能插入失效操作。Redis 7.2/7.4 可用 Lua 脚本实现比较与写入,Redis 脚本文档说明脚本执行期间不会与其他命令交错;这是基础 GET、SET EX、DEL 流程之外的可选增强。脚本本身不能感知数据库提交,版本门槛还必须随失效推进,并在删除数据键后保留。下面是设计时序对照,假定回填携带数据库行的单调版本,失效已将门槛推进到 2:
| 时刻 | 只有删除数据键 | 保留版本门槛并原子校验 |
|---|---|---|
| t1:A 未命中,读出 v1 后暂停 | 尚未回填 | 尚未回填 |
| t2:B 提交 v2,执行失效 | 删除数据键 | 门槛推进为 2,并删除数据键 |
| t3:A 恢复,尝试回填 v1 | 普通 SET 接受旧值 | 脚本发现 1 < 2,拒绝回填 |
若门槛随数据一起删除、提前过期或被淘汰,t3 就失去了拒绝旧值的依据;保留期限必须覆盖仍可能恢复的旧读,无法约束该期限时不能声称版本方案已消除竞态。只给写请求加锁挡不住未加锁的读回填。要求严格读己之写时,可对相应读取绕过缓存并访问主库,不应仅靠缩短 TTL。
4. 怎样检查补偿真的能恢复?
以下是建议验证步骤,未在本文环境执行:
- 在测试库暂停消费者,提交一次业务修改,检查业务行和待办同时存在;主动回滚一次,检查两者均未提交。
- 在提交后、删除前终止应用,再启动扫描器,检查遗留任务能被领取;记录缓存版本和完成时间。
- 模拟删除响应丢失与“删除后、标记完成前退出”,检查重复消费及返回 0 都能正确收敛。
- 用门闩让读请求停在“已读旧值、未回填”处,完成写入和删除后再放行,单独暴露旧回填竞态。
监控最老待办年龄、积压、重试与回源压力,不能只看删除成功率。Redis 的 WAIT 文档还明确说明复制确认不等于强一致;它也不能替代数据库与缓存之间的失效补偿。
相邻阅读:原站 Redis 问答、Spring 提交边界。学习资料统一进入 资料入口。
