数据库是事实来源,Redis 是加速副本。只要两边都保存同一份业务状态,就会遇到更新顺序、并发覆盖、删除失败和消息丢失。所谓"强一致缓存"往往是没有定义清楚一致性窗口。
目录
- 一致性到底要求什么
- [Cache Aside 的正确顺序](#Cache Aside 的正确顺序)
- 并发更新与延迟双删
- [消息驱动与 binlog](#消息驱动与 binlog)
- 失败重试与兜底
- 一致性验证
一、一致性到底要求什么

先定义目标:允许读到旧值多久?更新成功后是否必须立刻可见?哪些接口能接受最终一致?没有时间窗口和异常语义,"保证一致"无法测试。
二、Cache Aside 的正确顺序
读路径是先查缓存,未命中查库后回填。更新路径通常是先更新数据库,再删除缓存:
text
UPDATE database → DELETE redis
不要先删缓存再写库,否则并发读可能把旧数据库值重新回填。删除失败要进入可靠重试,而不是吞掉异常。
三、并发更新与延迟双删
线程 A 更新数据库后准备删缓存,线程 B 在删除前读到旧值并回填,可能造成短暂旧读。延迟双删通过等待并发读完成后再次删除降低窗口,但它依赖合理延迟,不能从理论上消灭竞态。
更严格的方案是给缓存值携带版本号,回填时只接受不低于当前版本的值;或者让更新事件按 Key 有序消费。
四、消息驱动与 binlog

事务提交后发送失效事件,消费者删除缓存。直接在业务事务中发消息会遇到"数据库成功、消息失败",可使用 Outbox 表、CDC/binlog 订阅或带重试的消息系统。
事件消费必须幂等,删除不存在的 Key 也应视为成功;按 Key 分区可以保持同一实体的事件顺序。
五、失败重试与兜底
缓存删除失败进入重试队列并告警,不能无限重试拖垮 Redis。重建时从数据库读取最新值,必要时携带版本检查。对极端故障可短暂绕过缓存,但要有限流和熔断。
六、一致性验证
记录数据库版本、缓存版本和删除事件时间,构造并发更新、进程崩溃、消息重复、Redis 重启四类测试。监控"缓存版本落后时长",比单看命中率更能发现一致性问题。
6.1 场景:运营改价后,用户仍看到旧价格
运营把商品 1001 的价格从 99 改为 79。服务先删缓存、再更新数据库,恰好有一个详情读请求发生在两步之间:它缓存 miss,读到了数据库中的旧价格 99,并把它回填为五分钟 TTL。此后即使数据库已经是 79,用户仍会看到旧价。
基础改法是固定为"先提交数据库事务,再删除缓存"。删除要放在事务提交成功之后,不能在事务中途执行;否则事务回滚而缓存已经删掉,也会造成不必要回源。对于价格这类不能长期旧读的字段,缓存值里可以携带数据库的 version 或 updated_at,回填时只允许版本不落后的值覆盖。
注意,先写库后删缓存仍有极短竞态:读线程可能先读旧缓存或旧库,再在删除后回填。延迟双删能降低发生概率,但延迟值依赖查询耗时和调度,不能当作强一致协议。页面价格可以接受短暂旧读时,靠短 TTL 与删除重试即可;下单时必须以数据库或价格服务的最新版本校验。
6.2 场景:数据库已提交,缓存删除事件却丢了
业务事务提交后直接调用消息队列发送"删除 product:detail:1001"事件。进程在数据库提交成功、消息发送前崩溃,缓存永远不删。这个窗口不能靠 try/catch 修复。
可把业务更新和 Outbox 记录放入同一个数据库事务:Outbox 保存实体 ID、版本、事件类型和状态;独立投递器可靠扫描并投递,消费者幂等删除缓存。也可由 CDC 订阅 binlog 产生变更事件。两种方式都必须处理重复消息、乱序和积压:删除是天然幂等的,但"回填缓存"需要版本约束。
落地后应有一条对账任务:抽样比较数据库版本与缓存版本,超过约定窗口就删除缓存并告警。不要试图让缓存和数据库每一毫秒完全一致,而要把允许窗口、补偿动作和关键写路径的最终校验写进契约。
6.3 版本号如何阻止旧回填
缓存值可以设计成 {"version":1082,"payload":{...}}。读线程查库得到版本 1081 时,即使它晚于版本 1082 的更新事件,也不能覆盖已经存在的 1082;写入时用 Lua 或带版本条件的存储操作比较当前版本。这样处理的是"旧读晚到"的竞态,而不是简单延长 TTL。
版本必须来自同一个事实源,不能用各应用机器的本地时间代替。多数据中心场景应使用数据库递增版本、变更序号或带分区语义的事件 offset,并明确跨地域复制延迟是否属于允许窗口。
记住:先写数据库再删缓存是基础顺序,延迟双删只是降低窗口,可靠消息与版本号才是故障场景下的工程保障。