📌 写在前面
在大部分项目中,缓存(Redis)和数据库(MySQL)并用已经成为标配。但有一个经典问题始终绕不开:如何保证缓存和数据库的数据一致性?
刚开始写代码时,我采用“先更新数据库,再删除缓存”的简单方案,感觉没啥问题。直到有一天,并发请求导致缓存中读到了旧数据,线上出现了一闪而过的“脏数据”。后来学了“延迟双删”,又学了订阅binlog的Canal方案,还听说过“双写”+消息队列……方案越来越多,反而越来越不知道该怎么选。
这篇笔记,我想把常见的缓存一致性方案放在一起对比:它们的原理、优缺点、适用场景,以及终极方案到底是什么。希望能帮你理清思路,不再被“一致性问题”困扰。
1️⃣ 为什么缓存和数据库会不一致?
根本原因:更新数据库和更新缓存不是原子操作。在高并发下,两个操作之间可能穿插其他请求,导致数据错乱。
典型不一致场景(Cache Aside 模式)
2️⃣ 方案一:先更新数据库,再删除缓存(Cache Aside Pattern)
这是最经典、最推荐的方案。读逻辑:先读缓存,未命中则读数据库并写缓存。写逻辑:先更新数据库,再删除缓存。
代码示例:
@Transactional public void updateProduct(Product product) { productMapper.updateById(product); // 1. 更新数据库 redisTemplate.delete("product:" + product.getId()); // 2. 删除缓存 }为什么删除而不是更新缓存?
更新缓存代价高(可能涉及复杂计算),且容易发生并发覆盖。删除后,下次读时会重建缓存,简单可靠。
优点:
实现简单
并发下不一致概率较低
缓存中不会出现脏数据(因为删了)
缺点:
删除缓存失败会导致数据不一致(需要重试)
在读写并发时仍可能短暂不一致(见上表场景,但窗口极小)
适用场景:大多数业务,可容忍毫秒级的不一致。
改进:删除失败时,将key发送到消息队列异步重试。
3️⃣ 方案二:延迟双删
为了解决“读请求在写请求删除缓存前写入了旧数据”的问题,在删除缓存后,休眠一段时间再删一次。
@Transactional public void updateProduct(Product product) { productMapper.updateById(product); redisTemplate.delete(key); Thread.sleep(500); // 休眠,等待可能发生的并发读将旧数据写入 redisTemplate.delete(key); }原理:休眠时间 > 并发读请求从数据库读取+写入缓存的时间。第二次删除确保旧缓存被清除。
优点:进一步降低不一致窗口。
缺点:
休眠时间难以确定(依赖业务耗时)
降低了写性能
分布式环境下,不同节点的时钟差异可能导致无效
适用场景:对一致性要求稍高,且写并发不高的场景。
4️⃣ 方案三:先删除缓存,再更新数据库
流程:写请求先删缓存,再更新数据库。读请求:读缓存未命中,读数据库,写缓存。
问题:更新数据库期间,其他读请求会读到旧数据并写入缓存,导致缓存长期为脏数据。
5️⃣ 方案四:双写 + 消息队列
写操作同时更新数据库和缓存,并通过消息队列保证最终一致性。
流程:
更新数据库
发送一条“更新缓存”的消息到MQ
消费者消费消息,更新缓存
若更新失败,MQ重试
优点:
解耦,保证最终一致性
可以批量处理,提高性能
缺点:
实时性差(取决于MQ消费速度)
复杂度高,需要处理消息重复、顺序等问题
缓存更新可能滞后,读请求可能读到旧数据
适用场景:对实时性要求不高,但需要保证最终一致(如计数、评论数)。
6️⃣ 方案五:订阅binlog(Canal)
Canal是阿里开源的MySQL binlog解析工具。它伪装成MySQL从库,接收binlog变更事件,然后推送给消费者(如消息队列或直接更新缓存)。
架构:
MySQL → (binlog) → Canal → Kafka/RocketMQ → 消费者 → 更新Redis缓存
流程:
业务直接更新数据库(无需操作缓存)
Canal监听binlog,解析出变更的数据
将变更事件发送到MQ
消费者根据事件更新或删除缓存
优点:
完全解耦:业务代码无需关心缓存操作
高可靠性:基于binlog,不会丢失变更
实时性高:binlog秒级延迟
支持多下游:可同时更新缓存、同步ES、刷新CDN等
缺点:
引入新组件(Canal、MQ),运维复杂
顺序消费问题:同一key的变更乱序可能导致缓存脏数据(需用分区键保证顺序)
缓存操作失败需要重试机制
适用场景:大型系统,需要强最终一致性,且希望业务代码无侵入。
7️⃣ 方案六:最终一致性的终极形态——分布式事务
对于强一致性要求(如金融、库存扣减),缓存一致性方案不够,需要分布式事务。
常见方案:
TCC(Try-Confirm-Cancel):业务层面预留资源,适合高并发
Seata AT模式:自动回滚,侵入小但性能较低
但注意:分布式事务通常不用在缓存,而是用在数据库与数据库之间。缓存更适合“最终一致”。如果业务要求强一致,建议直接读数据库,不要走缓存。
8️⃣ 方案对比总结
9️⃣ 终极方案推荐:不同场景的最佳实践
📌 写在最后
缓存一致性没有银弹。所谓的“终极方案”,其实是根据业务对一致性、性能、复杂度的权衡。
如果能接受毫秒级不一致,先DB后删缓存最香。
如果希望业务无侵入且可靠,Canal+MQ是终极形态。
如果需要强一致,请放弃缓存,直接读数据库。
记住:缓存是用来扛并发的,不是用来保证一致性的。一致性靠数据库事务保证,缓存只需要做到最终一致即可。
下一篇,我计划写一篇关于Canal实战:从搭建到同步Redis的教程,敬请期待。