一、问题定义
在读写分离或缓存加速的架构中,数据库(DB)与缓存(Cache)之间的数据一致性是一个经典的工程难题。当数据发生变更时,如何确保缓存中的数据与数据库保持一致,是每一个使用缓存的系统都必须面对的问题。
严格意义上,在分布式系统中实现 DB 与 Cache 的强一致性(Strong Consistency)代价极高,绝大多数业务场景追求的是最终一致性(Eventual Consistency)------即数据变更后,经过一个可接受的时间窗口,缓存最终会与数据库保持一致。
本文系统梳理五种主流的缓存更新策略,分析各自的一致性保证、故障场景和适用边界,帮助在不同业务约束下做出合理的工程选型。
二、方案一:Cache Aside Pattern(旁路缓存模式)
2.1 读写流程
读流程:
应用 → 查缓存 → 命中 → 返回
→ 未命中 → 查DB → 写入缓存 → 返回
写流程(先更新DB,再删除缓存):
应用 → 更新DB → 删除缓存
这是业界使用最广泛的缓存模式。核心原则是:读时回填缓存,写时删除缓存(而非更新缓存)。
2.2 为什么是"删除"而非"更新"缓存
如果写操作选择"更新缓存",在并发写场景下可能出现以下问题:
线程A:更新DB为 x=1
线程B:更新DB为 x=2
线程B:更新缓存为 x=2
线程A:更新缓存为 x=1 ← 缓存中的值与DB不一致
由于网络延迟或线程调度,两个写操作的"更新DB"和"更新缓存"可能交叉执行,导致缓存中的值与DB不一致。而"删除缓存"策略下,下一次读请求会从DB重新加载最新值,避免了这个问题。
2.3 一致性分析:先更新DB,再删除缓存
正常情况: 更新DB成功 → 删除缓存成功 → 下次读请求从DB加载最新值写入缓存 → 一致。
故障场景分析:
场景一:删除缓存失败
更新DB成功 → 删除缓存失败(网络抖动、Redis超时等)
此时缓存中仍然是旧值,后续读请求命中旧缓存,导致不一致。
缓解措施:
- 重试机制:删除缓存失败后进行有限次重试;
- 消息队列异步删除:将删除操作投递到消息队列,由消费者保证最终删除成功;
- 设置合理的TTL:即使删除失败,缓存过期后也会自动从DB重新加载。
场景二:先删缓存,再更新DB(反面案例)
线程A(写):删除缓存
线程B(读):缓存未命中 → 查DB得到旧值 → 写入缓存
线程A(写):更新DB为新值
此时DB是新值,缓存是旧值,且缓存没有过期,导致长时间不一致。这就是为什么推荐"先更新DB,再删除缓存"而非"先删缓存,再更新DB"。
场景三:先更新DB,再删除缓存的极端并发场景
线程B(读):缓存未命中 → 查DB得到旧值(此时DB还未被更新)
线程A(写):更新DB为新值
线程A(写):删除缓存
线程B(读):将旧值写入缓存 ← 缓存中是旧值,DB中是新值
这个场景要求线程B的"查DB"发生在线程A的"更新DB"之前,而"写缓存"发生在线程A的"删除缓存"之后。在实际中,一次读请求的"查DB+写缓存"耗时通常在毫秒级,要跨越整个写操作的耗时窗口,概率极低。
进一步降低概率的措施: 使用延迟双删策略------更新DB后先删除缓存,等待一小段时间(如500ms)后再删除一次缓存,覆盖上述极端场景。
2.4 适用场景
- 绝大多数通用的缓存加速场景;
- 对一致性要求为"最终一致",允许秒级的不一致窗口;
- 读多写少的业务(如商品详情、文章内容等)。
三、方案二:Read/Write Through
3.1 模型设计
与 Cache Aside 不同,Read/Write Through 模式下,应用只与缓存交互,缓存层负责同步与DB的数据:
Read Through:
应用 → 查缓存 → 命中 → 返回
→ 未命中 → 缓存层自动查DB → 写入缓存 → 返回
Write Through:
应用 → 写缓存 → 缓存层同步写DB → 返回
3.2 一致性分析
Write Through 保证了写操作的原子性------缓存和DB的更新由缓存层在同一个操作内完成,应用层无需关心一致性问题。
优势:
- 应用代码简洁,无需手动管理缓存更新逻辑;
- 写操作的一致性由缓存层保证,不会出现"DB更新了但缓存没更新"的情况。
局限性:
- 写延迟增加:每次写操作都需要等待DB写入完成才能返回,写性能受限;
- 缓存层复杂度高:需要缓存组件原生支持 Write Through 语义(如 Ehcache 的 CacheWriter),Redis 原生不支持;
- 单点风险:缓存层承担了数据持久化的职责,如果缓存层故障,写操作不可用。
3.3 适用场景
- 使用支持 Write Through 语义的缓存组件(如 Ehcache、Hazelcast);
- 对写一致性要求较高,且写操作频率不高的场景;
- 本地缓存场景(如应用内嵌的 Guava Cache、Caffeine)。
四、方案三:Write Behind(异步写回)
4.1 模型设计
Write Behind(也称 Write Back)是 Write Through 的异步变体:
应用 → 写缓存 → 立即返回 → 缓存层异步批量写DB
写操作只更新缓存即返回,缓存层在后台异步将变更批量刷写到DB。
4.2 一致性分析
优势:
- 写性能极高:写操作只涉及内存操作,无DB I/O等待;
- 合并写:对同一Key的多次更新可以合并为一次DB写入,减少DB压力。
风险:
- 数据丢失风险:如果缓存在异步刷写完成前宕机,未刷写的数据将永久丢失;
- 不一致窗口大:DB中的数据可能滞后缓存数秒甚至更久;
- 故障恢复复杂:缓存宕机后需要额外的机制来恢复未刷写的数据。
4.3 适用场景
- 写操作极其频繁且允许少量数据丢失的场景(如日志聚合、实时统计计数);
- 对写延迟极度敏感,但对数据持久性要求不高的场景;
- 不适用于:支付、订单等对数据一致性要求高的核心业务。
五、方案四:基于 Binlog 的异步订阅
5.1 模型设计
通过监听数据库的 Binlog(如 MySQL 的 binlog),捕获数据变更事件,异步更新或删除缓存:
应用 → 更新DB → DB写入Binlog
↓
Binlog监听组件(如 Canal、Debezium)
↓
消息队列(如 Kafka、RabbitMQ)
↓
缓存更新消费者 → 删除/更新缓存
5.2 一致性分析
优势:
- 应用代码零侵入:业务代码只需操作DB,缓存更新完全由旁路组件负责,解耦彻底;
- 不会漏更新:只要DB写入成功,Binlog就一定存在,缓存更新事件不会丢失;
- 天然保证"先更新DB"的顺序:因为监听的是DB已经提交的变更,不存在"先删缓存后更新DB"的时序问题。
风险与缓解:
- 消息消费失败:消费者处理删除缓存操作失败时,需要重试机制保证最终成功;
- 消息积压:高并发场景下 Binlog 事件量大,消费者需要足够的吞吐能力;
- 延迟:从DB变更到缓存更新之间存在消息队列的处理延迟,通常为百毫秒到秒级。
5.3 适用场景
- 对应用代码侵入性要求极低的场景;
- 微服务架构中,多个服务共享同一数据源,需要统一处理缓存失效;
- 数据变更频率适中,消息队列能够承载的场景。
六、方案五:分布式事务保证强一致
6.1 模型设计
通过分布式事务框架(如 Seata、XA事务),将DB写入和缓存更新纳入同一个事务:
begin transaction
→ 更新DB
→ 更新/删除缓存
commit(两阶段提交,同时成功或同时回滚)
6.2 一致性分析
优势:
- 强一致性保证:DB和缓存的变更在同一事务中完成,要么同时成功,要么同时回滚,不存在中间状态。
局限性:
- 性能代价极高:两阶段提交涉及多次网络往返和锁等待,写操作的延迟和吞吐量会受到严重影响;
- 可用性降低:任一参与者(DB或缓存)故障都会导致事务无法提交,影响整体可用性;
- Redis的事务支持有限:Redis不支持标准的XA协议,需要额外的适配层。
6.3 适用场景
- 对一致性有强要求的核心业务(如资金、库存扣减等);
- 写操作频率不高,能够承受分布式事务的性能开销;
- 拥有成熟的分布式事务基础设施。
七、五种方案横向对比
| 维度 | Cache Aside | Read/Write Through | Write Behind | Binlog订阅 | 分布式事务 |
|---|---|---|---|---|---|
| 一致性级别 | 最终一致 | 强一致(写) | 弱一致 | 最终一致 | 强一致 |
| 不一致窗口 | 秒级 | 无 | 秒~分钟级 | 百毫秒~秒级 | 无 |
| 应用侵入性 | 中 | 低 | 低 | 极低 | 中 |
| 写性能影响 | 低 | 中 | 极低 | 低 | 高 |
| 数据丢失风险 | 低 | 低 | 高 | 低 | 无 |
| 实现复杂度 | 低 | 中 | 中 | 中高 | 高 |
| 基础设施依赖 | 无 | 缓存组件支持 | 缓存组件支持 | Binlog+MQ | 事务框架 |
八、工程落地建议
8.1 大多数场景的推荐方案
对于 80% 以上的业务场景,Cache Aside Pattern(先更新DB,再删除缓存)+ TTL兜底 + 重试机制 是性价比最高的选择:
java
public void updateData(String key, Object newValue) {
// 1. 更新数据库
db.update(key, newValue);
// 2. 删除缓存(带重试)
int retryCount = 3;
while (retryCount > 0) {
try {
redis.del(key);
break; // 删除成功,退出重试
} catch (Exception e) {
retryCount--;
if (retryCount == 0) {
// 重试耗尽,投递到消息队列异步删除
mq.send(new CacheDeleteMessage(key));
}
}
}
}
配合合理的 TTL(如 30 分钟),即使极端情况下删除失败,缓存也会在 TTL 到期后自动从 DB 重新加载,保证最终一致。
8.2 对一致性要求更高的场景
如果业务对一致性的要求超出了 Cache Aside 的能力范围(如不允许秒级的不一致窗口),建议引入 Binlog 订阅方案:
- 使用 Canal 或 Debezium 监听 MySQL Binlog;
- 将变更事件投递到 Kafka;
- 消费者从 Kafka 消费事件,执行缓存删除操作;
- 消费失败时通过 Kafka 的重试机制保证最终成功。
该方案对应用代码零侵入,且能够保证"只要DB写入成功,缓存一定会被更新"。
8.3 关键兜底机制
无论选择哪种方案,以下兜底机制都是必要的:
- TTL 过期:为所有缓存Key设置合理的过期时间,作为一致性的最后一道防线;
- 监控告警:对缓存删除失败、消息消费积压等异常建立监控和告警;
- 手动刷新入口:提供运维工具,允许在发现不一致时手动触发缓存刷新。
九、总结
Redis 缓存一致性没有"银弹"方案。五种策略分别对应不同的业务约束:
- Cache Aside 是通用场景的默认选择,简单可靠,配合 TTL 和重试即可覆盖绝大多数需求;
- Write Through 适合使用本地缓存或对写一致性要求较高的低频写场景;
- Write Behind 适合写极频繁且允许丢数据的统计类场景;
- Binlog 订阅 适合对代码侵入性敏感、需要解耦缓存管理的中大型系统;
- 分布式事务 是强一致性需求的终极方案,但性能代价最高。
选型的核心原则:先评估业务对不一致窗口的容忍度,再选择满足该约束的最简方案。 过度设计带来的复杂性,往往比不一致本身带来的风险更大。