Note
MySQL 与 Redis 如何保证缓存一致性?
面试视角拆解 Cache Aside、并发脏写、删除失败补偿、延迟双删、Outbox 与 Binlog CDC,以及强一致性的边界。
省流
MySQL 和 Redis 无法靠本地事务实现原子更新,所以通常不追求任意时刻的强一致,而是以 MySQL 为真实数据源,通过 Cache Aside 保证最终一致性。
标准写法是:读路径负责回填,写路径只失效。读请求先查缓存,未命中再查数据库并
SET回填;写请求在数据库事务提交后DEL删除缓存,不在写路径上更新或重建缓存值。选删除而不是写时更新,是因为缓存本就可以从数据库重建,也能避免并发写各自更新缓存时因完成顺序颠倒而留下旧值。选“先更新数据库,再删缓存”,是因为“先删缓存,再更新数据库”更容易让并发读在数据库更新前读到旧值,并把旧值重新写回缓存。但这套方案不是绝对安全的。第一,数据库提交成功后,删除缓存可能失败;第二,在缓存恰好失效时,一个慢读可能先从数据库读到旧值,等写请求提交并删掉缓存后,再把旧值回填进去(这种情况不常见,因为一般写数据库远慢于读缓存,所以综合而言先写数据库更安全)。工程上会给缓存设置 TTL 作为兜底,并通过可持久化的消息重试删除。可靠性要求更高时,可以使用事务 Outbox 或订阅 MySQL Binlog 的 CDC,在数据库提交后异步、幂等地失效缓存。延迟双删能缩小并发脏数据窗口,但延迟时间依赖业务耗时,只是补偿手段,不能证明强一致。
如果业务要求严格的读后写一致性或强一致,就不能只依赖旁路缓存:可以让关键读取绕过缓存、携带数据版本校验,或者对同一 Key 的读写进行串行化。系统首先要明确允许多长时间的不一致,再选择相应成本的方案。
标准读写流程
读请求:GET cache
├─ 命中:返回缓存
└─ 未命中:SELECT DB -> SET cache with TTL -> 返回
写请求:BEGIN -> UPDATE DB -> COMMIT -> DEL cache
必须在数据库事务 提交成功之后 再删缓存。若在提交前删除,其他请求可能在事务尚未提交时读到数据库旧值并回填;如果事务最终回滚,这次删除也没有意义。
这套方案的目标是:缓存失效后,由下一次读请求以数据库的已提交数据重建缓存。Redis 不是第二个事实来源,因此缓存数据必须允许丢失和重建。
为什么删除缓存,而不是更新缓存?
直接更新缓存主要有三个问题。
第一,数据库与缓存之间没有天然的原子性。无论先更新哪一边,都可能出现一边成功、另一边失败。
第二,并发写可能发生顺序倒置。假设两个请求依次把数据库更新为 v1、v2,但更新缓存的耗时相反:
W1:DB = v1 ---------------------- cache = v1
W2: DB = v2 -> cache = v2
最终:DB = v2,cache = v1
第三,更新缓存可能做无用功。某些冷数据写入后根本不会被读取;删除缓存可以把重建成本延迟到真正发生读取时。此外,聚合结果可能由多张表计算,写请求未必能低成本、无遗漏地构造出新缓存值。
所以一般采用“更新数据库 + 失效缓存”,把缓存更新统一收敛到读路径。
为什么不先删缓存,再更新数据库?
如果采用“删除缓存 -> 更新数据库”,一次普通的读写并发就可能留下旧缓存:
初始状态:DB = old,cache = old
W:DEL cache
R:cache miss -> SELECT DB,得到 old
W:UPDATE DB = new,并提交
R:SET cache = old
最终状态:DB = new,cache = old
这个竞态的窗口覆盖了整个数据库更新过程,实际并不苛刻。
采用“更新数据库并提交 -> 删除缓存”后,仍然存在理论竞态,但触发条件更严格:缓存要先自然失效或被淘汰;读请求还要在写事务提交前读到旧值,并且直到写请求提交和删除缓存之后才完成回填。
初始状态:DB = old,cache 已失效
R:cache miss -> SELECT DB,得到 old,但暂未回填
W:UPDATE DB = new -> COMMIT -> DEL cache
R:SET cache = old
最终状态:DB = new,cache = old
因此,“先更新数据库,再删缓存”不是数学意义上的零风险,而是竞态窗口更小、失败语义更清晰的默认方案。
两类问题要分别解决
1. 删除缓存失败
数据库已提交,但 DEL 因超时、Redis 故障或进程退出而没有成功,旧缓存会继续存在。仅在当前请求中重试几次并不可靠,因为应用可能在数据库提交后、发起删除前崩溃。
常见措施如下:
| 措施 | 作用 | 局限 |
|---|---|---|
| TTL | 给脏数据设置最长存活上限 | 过期前仍可能读到旧值 |
| 内存重试 | 处理短暂网络抖动 | 进程崩溃会丢任务 |
| 消息队列重试 | 异步、重复执行删除 | 若“提交 DB”和“发消息”仍是双写,消息也可能丢 |
| 事务 Outbox | 在同一个 MySQL 事务中写业务数据和事件,再异步投递 | 要维护投递器、去重、积压监控 |
| Binlog CDC | 只消费已提交的数据库变更并失效缓存 | 存在消费延迟,需要处理顺序、重放和积压 |
删除操作天然幂等:同一个 Key 删除一次或多次,最终状态相同。因此消费者应采用 至少一次投递 + 幂等删除,而不是为追求“恰好一次”增加不必要的复杂度。
如果业务已经有可靠消息基础设施,事务 Outbox 的典型链路是:
同一个 MySQL 事务:UPDATE business_data + INSERT outbox_event
|
v
后台投递 Outbox -> MQ -> 消费者 DEL cache -> ACK
如果多个应用都可能修改数据库,Binlog CDC 更容易覆盖所有写入口;但它只能提供最终一致性,延迟取决于 Binlog 产生、消费和缓存删除的整条链路。
2. 旧读请求晚回填
即使删除成功,前面的慢读仍可能在删除之后把旧值写回来。常见补偿是:数据库提交后立即删一次,再异步延迟一段时间删第二次。
UPDATE DB -> COMMIT -> DEL cache -> 延迟 -> DEL cache
第二次删除用于清理由并发慢读回填的旧值。延迟应覆盖“读取旧数据 + 构造缓存 + 网络抖动”的高分位耗时,不能随手写一个固定秒数;等待也不应阻塞业务线程,而应由延迟队列或调度任务执行。
延迟双删仍然只是概率性补偿:延迟太短清不掉慢请求,延迟太长则扩大陈旧窗口,任务本身也可能丢失。对一致性要求高时,应使用版本化方案,例如缓存值携带数据库版本,读取方发现版本低于本次会话已知版本时绕过缓存;或者维护独立的 Key 世代号,使旧读取只能写入旧世代的缓存 Key,不能覆盖新版本。
容易遗漏的工程细节
缓存回填要防击穿
热点 Key 被删除后,大量请求可能同时穿透到 MySQL。可以按 Key 使用 singleflight、互斥锁或逻辑过期,让一个请求负责重建,其余请求等待或在允许范围内读取旧值。
这解决的是数据库压力问题,不等于解决一致性问题。若负责重建的请求本身读到了旧数据,它仍可能把旧值写回缓存。
回填不要读取落后的从库
数据库刚在主库提交,缓存也被删除,如果回填请求立即读取存在复制延迟的从库,就可能再次缓存旧值。常见选择是写后一段时间读主库、等待副本追上指定的 GTID/LSN,或让读取携带最低版本并在版本不足时回源主库。
缓存 Key 可能不止一个
一次数据库修改可能影响对象缓存、列表缓存、统计缓存等多个 Key。必须维护清晰的“数据变更 -> 缓存失效”映射;对于难以精确枚举的查询缓存,可以使用命名空间版本号批量失效,而不是依赖模糊扫描。
TTL 是最后防线,不是主方案
所有可重建缓存都应该有合理 TTL,避免异常情况下永久脏读。但 TTL 越短,数据库压力和重建频率越高;TTL 越长,故障时允许的陈旧时间越长。它应由业务可接受的不一致窗口和容量共同决定。
需要可观测性
至少监控缓存删除失败率、重试次数、Outbox/MQ/CDC 积压、事件端到端延迟、热点 Key 回源量和数据库主从延迟。没有这些指标,“最终一致”可能只是无法判断何时一致。
能不能做到强一致?
仅靠“更新数据库 + 删除缓存”做不到严格强一致,因为数据库与 Redis 是两个独立系统,中间始终存在故障和并发窗口。分布式锁也不能自动解决这个问题:所有读写入口都必须遵守同一协议,还要处理锁超时、进程暂停和续租失败。
业务真的要求强一致时,可以按成本从低到高考虑:
- 对余额、库存扣减结果等关键读取绕过缓存,直接读权威数据库。
- 提供会话级的 read-your-writes:写接口返回版本号,后续读取只接受不低于该版本的缓存,否则回源主库。
- 对单 Key 的读、写和回填串行化,并对数据库版本做校验。
- 将数据和读取状态收敛到同一存储,避免跨系统维护同一份强一致状态。
两阶段提交等分布式事务理论上可以协调多个资源,但 Redis 并不是典型的 XA 事务资源,而且可用性、延迟与实现复杂度通常不适合普通缓存场景。
扩展问题
删除缓存失败怎么办?
先设置 TTL 限制最坏陈旧时间,再用可靠任务重试。只在业务代码里“提交后发 MQ”仍有丢消息窗口,更可靠的是事务 Outbox 或 Binlog CDC。删除要幂等,并监控重试积压和端到端延迟。
延迟双删能保证一致性吗?
不能。它针对“旧读晚回填”再做一次清理,只能缩小概率和持续时间。延迟值难以覆盖所有长尾请求,第二次删除也可能失败,所以仍需可靠调度、TTL 和监控。
加分布式锁是不是就行了?
只有所有读、写、回填和其他写入口都遵守同一把锁,锁协议本身也正确时,才能把竞态串行化;代价是吞吐下降和更复杂的故障处理。锁通常用于热点 Key 重建或极少数高一致场景,不是默认答案。
到底选择哪一种方案?
普通业务采用 Cache Aside:数据库提交后删除缓存,加 TTL,并对删除失败做可靠重试。高并发热点再增加防击穿;多写入口可以用 Binlog CDC;要求读后写一致时增加版本校验或绕过缓存;要求严格强一致的数据通常不应依赖 Redis 缓存返回最终判断。