📌 写在前面

        在大部分项目中,缓存(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️⃣ 方案四:双写 + 消息队列

写操作同时更新数据库和缓存,并通过消息队列保证最终一致性。

流程

  1. 更新数据库

  2. 发送一条“更新缓存”的消息到MQ

  3. 消费者消费消息,更新缓存

  4. 若更新失败,MQ重试

优点

  • 解耦,保证最终一致性

  • 可以批量处理,提高性能

缺点

  • 实时性差(取决于MQ消费速度)

  • 复杂度高,需要处理消息重复、顺序等问题

  • 缓存更新可能滞后,读请求可能读到旧数据

适用场景:对实时性要求不高,但需要保证最终一致(如计数、评论数)。

6️⃣ 方案五:订阅binlog(Canal)

Canal 是阿里开源的MySQL binlog解析工具。它伪装成MySQL从库,接收binlog变更事件,然后推送给消费者(如消息队列或直接更新缓存)。

架构

MySQL → (binlog) → Canal → Kafka/RocketMQ → 消费者 → 更新Redis缓存

流程

  1. 业务直接更新数据库(无需操作缓存)

  2. Canal监听binlog,解析出变更的数据

  3. 将变更事件发送到MQ

  4. 消费者根据事件更新或删除缓存

优点

  • 完全解耦:业务代码无需关心缓存操作

  • 高可靠性:基于binlog,不会丢失变更

  • 实时性高:binlog秒级延迟

  • 支持多下游:可同时更新缓存、同步ES、刷新CDN等

缺点

  • 引入新组件(Canal、MQ),运维复杂

  • 顺序消费问题:同一key的变更乱序可能导致缓存脏数据(需用分区键保证顺序)

  • 缓存操作失败需要重试机制

适用场景:大型系统,需要强最终一致性,且希望业务代码无侵入。

7️⃣ 方案六:最终一致性的终极形态——分布式事务

对于强一致性要求(如金融、库存扣减),缓存一致性方案不够,需要分布式事务。

常见方案:

  • TCC(Try-Confirm-Cancel):业务层面预留资源,适合高并发

  • Seata AT模式:自动回滚,侵入小但性能较低

        但注意:分布式事务通常不用在缓存,而是用在数据库与数据库之间。缓存更适合“最终一致”。如果业务要求强一致,建议直接读数据库,不要走缓存。

8️⃣ 方案对比总结

9️⃣ 终极方案推荐:不同场景的最佳实践

📌 写在最后

缓存一致性没有银弹。所谓的“终极方案”,其实是根据业务对一致性、性能、复杂度的权衡。

  • 如果能接受毫秒级不一致,先DB后删缓存最香。

  • 如果希望业务无侵入且可靠,Canal+MQ 是终极形态。

  • 如果需要强一致,请放弃缓存,直接读数据库

记住:缓存是用来扛并发的,不是用来保证一致性的。一致性靠数据库事务保证,缓存只需要做到最终一致即可。

下一篇,我计划写一篇关于Canal实战:从搭建到同步Redis的教程,敬请期待。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐