阅读时间:40-50 分钟 | 难度:中级偏上 | 前置知识:Redis 基础、MySQL 事务、消息队列基础
一、为什么缓存一致性是道"必考题"
面试官问这个问题的底层逻辑只有一条:在真实生产环境中,你不可能只用缓存或者只用数据库。一旦引入缓存,就必然面临"两份数据"的同步问题。而这个问题没有银弹——CAP 定理在缓存层面同样适用:你必须在一致性、可用性、性能之间做权衡。
先建立直觉:缓存不一致的根源是什么?是并发。如果所有读写都是串行的,缓存和数据库永远一致。但高并发场景下,多个线程同时读写,就会出现"数据库写完了,缓存还是旧值"的情况。
为什么不能"写完数据库顺手更新缓存"?
很多初学者第一反应是:写数据库的同时更新缓存不就行了?这个方案有两个致命问题:
问题一:并发写覆盖。 线程 A 先写 DB=1,线程 B 后写 DB=2,但由于网络或调度原因,B 的缓存更新先到达 Redis,A 的缓存更新后到达,最终 Redis 里是 1,而 DB 是 2——数据不一致。
问题二:写放大。 如果一个数据写了 100 次,但只被读了 1 次,你更新了 100 次缓存,其中 99 次是浪费。缓存最宝贵的资源是内存,不应该为"冷数据"买单。
所以,正确策略不是"更新缓存",而是删除缓存——让下一次读请求触发缓存重建。

二、Cache Aside 模式:最经典的"旁路缓存"
Cache Aside 是实践中使用最广泛的缓存策略,几乎所有后端项目的第一版缓存方案都是它。它的核心思想很简单:缓存是数据库的"旁路",数据以数据库为准。
2.1 读流程
1. 读请求 → 先查缓存
2. 缓存命中 → 直接返回
3. 缓存未命中 → 查数据库 → 将结果写入缓存 → 返回

这一步没什么争议。真正容易出问题的是写流程。
2.2 写流程
1. 写请求 → 先更新数据库
2. 数据库更新成功后 → 删除缓存
为什么是先更新数据库、再删除缓存,而不是反过来?
假设采用"先删缓存,再更新数据库":

时序如下:
- 线程 A 删除缓存
- 线程 B 读缓存 miss,查数据库得到旧值
- 线程 B 把旧值写入缓存
- 线程 A 更新数据库为新值
结果:缓存里是旧值,数据库里是新值,不一致。而且这个不一致会持续到缓存自然过期或被再次删除。
那"先更新数据库,再删除缓存"就没有问题吗? 理论上也有,但概率极低:
- 缓存刚好过期(或首次没有缓存)
- 线程 A 读缓存 miss,查数据库,得到旧值
- 线程 B 更新数据库为新值,删除缓存
- 线程 A 把旧值写入缓存
这个时序需要读操作在"查 DB"和"写缓存"之间被写操作插入,而且缓存刚好 miss。在真实场景中概率极低,而且即使发生,也只会持续到缓存下一次过期。
2.3 删除失败怎么办?——重试机制
Cache Aside 的另一个隐患是:删除缓存可能失败。如果数据库更新成功,但 Redis 删除失败(网络抖动、Redis 宕机),就会导致缓存持续脏数据。
解决方案:删除重试 + 消息队列。
DB 更新成功 → 删除缓存失败 → 发送删除消息到 MQ → 消费者重试删除
可以用一个单独的"重试表"或直接利用 MQ 的消费重试机制。关键是要保证删除操作最终成功。
2.4 Cache Aside 的适用边界
| 场景 | 适用? | 说明 |
|---|---|---|
| 读多写少 | ✅ 最佳 | 缓存命中率高,删除成本低 |
| 读写相当 | ✅ 可用 | 注意缓存穿透和击穿 |
| 写多读少 | ❌ 不推荐 | 频繁删除缓存,缓存价值低 |
| 强一致性要求 | ⚠️ 需增强 | 需要配合其他方案 |
三、延迟双删:解决"先删后更"的并发窗口
3.1 问题回顾
前面提到"先删缓存,再更新数据库"有并发问题。但在某些场景下,我们必须先删缓存——比如缓存数据量很大,更新数据库可能耗时较长,如果不先删缓存,这段时间内的所有读请求都会命中旧缓存。
延迟双删就是为了解决这个场景而生的。
3.2 延迟双删的核心流程
1. 先删除缓存
2. 更新数据库
3. 延迟 N 毫秒后,再次删除缓存

为什么第二次删除能解决问题?
回到刚才的并发问题:线程 B 在"线程 A 删缓存"和"线程 A 更新数据库"之间,读到了旧值并写入了缓存。如果线程 A 在更新完数据库后,再删一次缓存,就能把线程 B 写入的脏数据清除。
延迟时间怎么定? 一般设置为"读请求查数据库 + 写缓存"的耗时,通常在 200ms-500ms 左右。但这不是一个精确值,实际生产中需要根据业务 RT 分布来设定。
3.3 延迟双删的局限性
- 延迟时间难以精确确定:设短了可能没覆盖到脏数据,设长了影响用户体验。
- 第二次删除同样可能失败:需要和 Cache Aside 一样的重试机制。
- 引入了额外的复杂度:异步延迟删除通常需要线程池或定时任务支持。
- 在极高并发下仍有理论风险:如果延迟还没到,又有一个读请求命中旧缓存并写入,且写请求的第二次删除又在读请求写缓存之前完成——但这种情况概率极低。
3.4 延迟双删 vs Cache Aside 选型
| 对比维度 | Cache Aside(先更后删) | 延迟双删 |
|---|---|---|
| 一致性风险 | 理论存在,概率极低 | 更低,但延迟窗口有不确定性 |
| 实现复杂度 | 低 | 中(需要异步延迟删除) |
| 适用场景 | 通用场景 | 写操作较重、需要先删缓存的场景 |
| 对业务的影响 | 无额外延迟 | 第二次删除异步执行,不阻塞主流程 |
四、订阅 binlog:从"补偿"到"准实时同步"
Cache Aside 和延迟双删本质上是补偿思路——出现不一致后去修复。而订阅 binlog 方案更进一步,把缓存更新变成数据同步管道的一部分。
4.1 核心思想

MySQL 的 binlog 记录了所有数据变更,Canal(阿里开源)把自己伪装成 MySQL 的 Slave,消费 binlog 并解析出数据变更事件。缓存更新服务订阅这些事件,据此更新或删除缓存。
MySQL 写入 → binlog 产生 → Canal 解析 → 发送到 MQ(Kafka/RocketMQ)→ 缓存更新服务消费 → 更新/删除缓存
4.2 为什么 binlog 方案更可靠?
- 数据源头是数据库:binlog 是数据库的权威记录,只要数据库写成功了,binlog 就一定有记录。
- 解耦业务代码:业务代码不需要关心缓存操作,只需要正常写数据库,缓存的更新由独立服务负责。
- 可回溯:binlog 是顺序日志,可以重放,可以排查。
- 天然支持最终一致性:即使缓存更新服务挂了,MQ 里的消息不会丢,恢复后可以继续消费。
4.3 Canal 的工作原理

Canal 的核心流程:
- Canal Server 向 MySQL 发起 dump 请求
- MySQL 开始推送 binlog 事件
- Canal 解析 binlog 事件,提取出 INSERT/UPDATE/DELETE 操作
- 过滤出关注的表和数据
- 将变更事件发送到下游(MQ 或直接调用)
解析出的数据包含:
- 数据库名、表名
- 事件类型:INSERT、UPDATE、DELETE
- 变更前后的数据:UPDATE 会同时包含 before 和 after 行数据
4.4 缓存更新策略
收到 binlog 事件后,缓存更新服务可以采取不同策略:
策略一:直接删除缓存(推荐)
收到 UPDATE 事件 → 删除对应 key 的缓存 → 下次读请求重建
最简单,和 Cache Aside 的删除逻辑一致,避免了写放大。
策略二:更新缓存
收到 UPDATE 事件 → 从 binlog 中拿到新数据 → 直接写入缓存
减少了缓存 miss,但需要处理并发更新顺序问题,且存在写放大。
策略三:更新缓存 + 版本号
收到 UPDATE 事件 → 检查版本号 → 只有新版本才写入缓存
解决了并发覆盖问题,但复杂度更高。
4.5 延迟与一致性
订阅 binlog 方案不是"强一致"的,而是最终一致。从数据库写入到缓存更新,中间经过:
- binlog 写入延迟(MySQL 的 sync_binlog 参数决定)
- Canal 拉取延迟(通常毫秒级)
- MQ 投递延迟(毫秒到秒级视负载而定)
- 缓存更新服务消费延迟
总延迟通常在 几十毫秒到几秒 之间。对于绝大多数业务场景,这个延迟是可以接受的。
4.6 生产中的坑
坑一:Canal 单点故障。 Canal Server 需要做主备部署,否则挂掉后缓存将不再更新,最终所有缓存过期后数据不一致。
坑二:消息顺序性。 同一个 key 的 binlog 事件必须顺序消费,否则可能出现"先消费后一条、后消费前一条"导致缓存是旧数据。解决办法:对同一个 key 的事件路由到 MQ 的同一个分区,保证分区内有序。
坑三:全量 vs 增量。 存量数据怎么办?Canal 只处理增量变更。存量数据需要特殊处理:要么让缓存自然过期重建,要么写一个全量同步脚本。
坑四:binlog 格式。 必须使用 ROW 格式的 binlog,STATEMENT 格式无法获取具体的数据变更。
五、三种方案的对比与选型

| 维度 | Cache Aside | 延迟双删 | 订阅 binlog |
|---|---|---|---|
| 一致性强度 | 弱(可能脏读) | 中(窗口期更小) | 强(最终一致) |
| 实现复杂度 | 低 | 中 | 高 |
| 运维成本 | 低 | 低 | 中(需维护 Canal 集群) |
| 对业务代码的侵入 | 高(需手动操作缓存) | 高 | 低(binlog 自动触发) |
| 延迟 | 毫秒级 | 毫秒级 + 延迟窗口 | 几十毫秒到秒级 |
| 能否处理"直接写 DB" | 不能 | 不能 | 能(只要产生了 binlog) |
| 适用场景 | 中小规模、业务简单 | 写操作较重的场景 | 大规模、微服务、多数据源 |
选型决策树

推荐路径:
- 小型项目/单应用:Cache Aside + 删除重试,足够覆盖 90% 的场景。
- 中型项目/读写分离:Cache Aside + 延迟双删兜底,用异步延迟删除作为最终保障。
- 大型项目/微服务:订阅 binlog 方案,业务代码零侵入,缓存作为独立基础设施。
- 超高一致性要求:不走缓存,直接读数据库;或者用分布式锁保证读写互斥。
六、实战案例:一个"缓存脏数据"的排查过程
6.1 现象
某电商系统的商品详情页,用户反馈有时看到的价格和下单价格不一致。监控显示这是一个偶发问题,每天出现 2-3 次,每次持续 1-3 分钟。
6.2 排查过程
- 确认缓存策略:商品详情使用 Cache Aside 模式,写操作是"先更新 DB,再删除缓存"。
- 检查 Redis 删除日志:发现有一次删除失败,原因是 Redis 连接池耗尽。
- 根因:某次大促预热时,大量商品同时更新,Redis 连接池被打满,删除操作超时失败。但 DB 更新成功了,导致缓存中的数据是旧的。
- 为什么持续 1-3 分钟?:缓存的 TTL 是 5 分钟,如果缓存刚好在删除失败前被刷新过,就要等 5 分钟才过期。
6.3 解决方案
- 短期:增加 Redis 连接池大小,设置删除操作超时 + 重试(3 次)。
- 中期:引入删除重试队列,删除失败后异步重试。
- 长期:引入订阅 binlog 方案,从根本上去除"删除可能失败"的风险。
七、深度补充:Cache Aside 在并发下的理论风险再分析
前面提到"先更库后删缓存"的并发风险概率极低,但我们来精确算一下。
7.1 并发窗口的精确分析
即使发生,也需要同时满足:
- 线程 A(读):缓存 miss → 查 DB(读到旧值)
- 线程 B(写):更新 DB 为新值 → 删除缓存(但此时缓存已经 miss 了,删了个空)
- 线程 A(读):把旧值写入缓存
关键条件:线程 B 的"更新 DB + 删除缓存"必须发生在线程 A 的"查 DB"和"写缓存"之间。
这个窗口有多大?假设查 DB 平均 10ms,写缓存 1ms,窗口就是 11ms。在这 11ms 内,恰好有一个写操作完成,概率是多少?取决于写操作的 QPS。如果写 QPS 是 10,那么 11ms 内发生一次写操作的概率约为 0.11(11%)。如果写 QPS 是 100,概率约为 1.1(超过 100%,说明几乎必然发生)。
但等等,这还不够——还需要缓存刚好 miss。如果缓存命中率是 95%,那最终概率再乘以 5%(miss 率)。
所以:写 QPS 100、缓存命中率 95%、DB 查询 10ms 的场景下,不一致概率 ≈ 0.05 × 1.1 ≈ 5.5%。如果写 QPS 1000,这个概率会飙升到 50% 以上。
这就是为什么高并发写场景下,Cache Aside 的"先更后删"策略需要额外保障。
7.2 如何彻底解决?
方案一:加分布式锁。 读写操作都加同一把锁,保证互斥:
读:加锁 → 查缓存 → miss 则查 DB → 写缓存 → 解锁
写:加锁 → 更新 DB → 删除缓存 → 解锁
缺点:性能大幅下降,违背了引入缓存的初衷。
方案二:缓存写入时判断版本号。
读:查 DB(带版本号)→ 写缓存时用 SET NX 或 Lua 脚本判断,只有缓存不存在或版本号更新时才写入
缺点:需要维护版本号,增加复杂度。
方案三:设置合理的缓存过期时间。 即使偶尔不一致,也会在 TTL 后自动恢复。这是最务实的做法——接受"最终一致",用 TTL 兜底。
八、总结
缓存与数据库一致性问题没有"完美方案",只有"适合你业务的方案":
- Cache Aside(先更后删):最通用、最简单,适合 90% 的场景。配合删除重试和合理 TTL,生产可用。
- 延迟双删:解决"先删后更"场景的并发问题,成本不高,适合作为 Cache Aside 的补充。
- 订阅 binlog:最彻底、最解耦,适合大规模微服务架构。一次投入 Canal + MQ 基础设施,后续所有缓存一致性问题都有了统一解决方案。
记住一句话:缓存不是用来"保证一致性"的,而是用来"提升性能"的。如果你的业务对一致性要求极高,那就不要用缓存,或者接受用分布式锁带来的性能损失。
🔥 面试官视角:高频追问
以下问题不代表标准答案,但代表了面试官期望你展现的思考深度。建议先自己思考,再看参考答案。
Q1:如果数据库更新成功,但 Redis 删除失败,缓存里是脏数据,怎么办?
点击展开参考思路
这是 Cache Aside 模式最经典的"删除失败"问题。三个层次回答:
第一层(基础):引入重试机制。删除失败后,将删除操作放入消息队列,异步重试,直到成功。
第二层(进阶):重试也失败了怎么办?可以设置缓存较短的过期时间作为兜底,即使重试失败,脏数据也会在 TTL 后自动失效。同时建立监控告警,持续重试失败需要人工介入。
第三层(架构):根本解决方案是订阅 binlog。不依赖业务代码的删除操作,而是由 Canal 消费 binlog 自动触发缓存更新。这样即使某次删除失败,后续的 binlog 事件也会覆盖。
Q2:为什么很多大厂选择"删除缓存"而不是"更新缓存"?
点击展开参考思路
核心原因有两个:
- 写放大:一个数据可能被写 100 次但只读 1 次,更新缓存 100 次是巨大的资源浪费。缓存的本质是"按需加载",用不到的冷数据不应该占用宝贵的内存。
- 并发安全问题:更新缓存需要处理并发写的顺序问题。两个线程同时更新缓存,后到达的可能覆盖先到达的,导致最终缓存的值和数据库不一致。而"删除缓存"天然避免了这个问题——不管谁先删谁后删,结果都只是缓存为空,下次读操作重建缓存即可。
另外,删除缓存只需要知道 key,不需要知道 value,实现更简单。
Q3:订阅 binlog 方案中,如果 Canal 挂了怎么办?
点击展开参考思路
这是一个典型的"高可用"问题,不应该只回答"做 HA 部署",要展现系统性的思考:
- Canal 高可用:Canal 支持 HA 模式,多个 Canal Server 通过 ZooKeeper 进行主备选举。主节点宕机后,备节点自动接管,从上次消费的 binlog 位点继续消费。
- 监控告警:对 Canal 的消费延迟、binlog 堆积量、Server 存活状态进行监控。延迟超过阈值立即告警。
- 兜底机制:即使 Canal 集群全挂,还有两道防线——缓存自身的 TTL 过期机制,以及业务层的 Cache Aside 删除操作(双保险)。
- 恢复策略:Canal 恢复后,会从记录的 binlog 位点继续消费。但如果宕机时间过长,binlog 可能已被 MySQL 清理,此时需要触发全量缓存重建。
Q4:在微服务架构中,多个服务都操作同一份数据,怎么保证缓存一致性?
点击展开参考思路
这是一个常见的架构问题,考察你对分布式系统的理解:
- 如果每个服务都自己维护缓存:很难保证一致性,因为每个服务都有自己的缓存策略和过期时间。建议通过订阅 binlog 统一管理缓存——所有服务的写操作都产生 binlog,由统一的缓存更新服务消费 binlog 来维护缓存。
- 如果共享同一个 Redis 集群:订阅 binlog 方案天然支持——无论哪个服务写入数据库,binlog 都会被 Canal 消费,然后更新共享的 Redis 缓存。
- 更进一步的架构:引入 BFF(Backend for Frontend)或聚合层,在网关层统一管理缓存,服务层不直接操作缓存。
核心思想:缓存的维护者应该尽可能集中,而不是分散在各个服务中。
Q5:有什么场景是"必须用缓存,但又要求强一致性"的?你怎么设计?
点击展开参考思路
这个问题考察你是否理解"没有银弹"以及如何做权衡:
首先,技术上可以做到"强一致的缓存",但代价是性能。例如:
- 用分布式锁保证读写互斥,但吞吐量会大幅下降。
- 用 Redis 的事务 + Watch 实现乐观锁,但并发冲突时重试成本高。
其次,可以和面试官讨论"这个强一致性要求是否真的必要"。很多业务场景下,用户对"最终一致"是无感的——比如微博的点赞数,差几个点赞对用户来说没有影响。
如果真的必须强一致,我的建议是:
- 用 Redis 作为"主存储"而不是"缓存"——即 Redis 是唯一数据源,异步持久化到 MySQL。
- 或者反过来,不用缓存,直接用 MySQL 扛读——用读写分离 + 分库分表来提升读性能。
下一篇预告:141|热 Key 与大 Key:识别、拆分、复制、限流和删除策略