缓存一致性的真相:从旁路缓存到 Binlog 异步架构

22 阅读6分钟

在现代高并发系统的设计中,引入缓存(Cache)几乎是提升性能的必经之路。然而, “引入缓存,就是引入了复杂性”

最核心的复杂性在于:如何保证缓存与数据库的一致性?

很多开发者在初次面对这个问题时,往往追求“强一致性”。但正如你所见,由于数据库和缓存是两个独立的组件,涉及网络通信和分布式事务的限制,绝对的数据一致性是不存在的。我们使用缓存的核心目的是减轻数据库压力、加速热点数据读取,而非将其作为“强一致”的数据存储。

因此,我们讨论的目标从未是“完美一致”,而是**“在一个可接受的时间窗口内,通过策略让缓存和数据库达到最终一致”**。

本文将深入探讨业界主流的缓存策略,以及大型系统是如何通过 Binlog 实现架构升级的。

一、 黄金标准:Cache Aside Pattern(旁路缓存模式)

在众多缓存策略(如 Read-Through, Write-Through, Write-Behind)中,Cache Aside Pattern 是最适合业务系统、也是最常用的策略。

1. 读写流程

  • 读操作:应用先读缓存;如果命中则直接返回;如果未命中(Cache Miss),则回源读数据库,并将结果写入缓存,最后返回。
  • 写操作:应用先更新数据库,然后删除缓存

2. 为什么是“删缓存”而不是“更新缓存”?

这里有两个关键理由:

  • 懒加载(Lazy Loading)原则: 有些缓存数据结构复杂(如涉及多表关联计算)。如果每次修改数据库都去重新计算并更新缓存,但该数据短期内并未被访问,那么这次计算资源就是浪费的。**“删除”**意味着将计算推迟到下一次读请求到来时,符合按需加载的理念。
  • 并发写导致的数据脏读: 假设同时有两个写请求 A 和 B。A 先更新了数据库,B 后更新数据库。但在更新缓存时,由于网络延迟,B 先更新了缓存,A 后更新。此时,数据库是 B 的新值,而缓存却是 A 的旧值——脏数据产生了。直接删除缓存可以规避这种复杂的并发覆盖问题。

二、 核心争议:先操作数据库,还是先操作缓存?

在写操作中,"更新数据库"和"删除缓存"这两个动作的顺序至关重要。

场景一:先删缓存,再更新数据库

这种方案在低并发下没问题,但在高并发读写场景下有巨大隐患

  1. 线程 A 发起写操作,先删除了缓存
  2. 线程 B 发起读操作,发现缓存缺失。
  3. 线程 B 读取数据库,此时线程 A 还没来得及更新数据库,所以 B 读到的是旧值
  4. 线程 B 将旧值写入缓存。
  5. 线程 A 更新数据库为新值。

后果:缓存中存储了旧值,而数据库是新值。在缓存过期前,业务读到的永远是脏数据。

解决方案:延时双删(Delayed Double Delete)

为了解决上述问题,可以使用延时双删策略:

  1. 先删缓存。
  2. 更新数据库。
  3. 休眠 N 毫秒
  4. 再次删除缓存

休眠的时间通常需要略大于“读请求读取数据库 + 写入缓存”的时间。这确保了在休眠期间产生的脏数据缓存能被第二次删除操作清理掉。

场景二:先更新数据库,再删缓存(推荐)

这是业界更为推荐的标准做法。 虽然理论上它也存在并发问题(缓存刚好失效 -> 线程 A 读旧库 -> 线程 B 更新库 -> 线程 B 删缓存 -> 线程 A 写旧缓存),但因为“数据库写操作”通常比“读操作 + 写缓存”慢得多,这种极端情况发生的概率极低。

主要风险: 如果数据库更新成功,但删除缓存失败(如 Redis 网络抖动),则会出现数据不一致。

解决方案:消息队列重试机制

  1. 更新数据库成功。
  2. 删除缓存失败。
  3. 将需要删除的 Key 发送到消息队列(MQ)。
  4. 消费端接收消息,不断重试删除操作,直到成功。

三、 架构进阶:基于 Binlog 的异步同步架构

随着系统规模的扩大,在业务代码中显式地调用 redis.delete() 或处理 MQ 重试显得越来越“笨重”。大型互联网系统通常会引入**CDC(Change Data Capture)**技术,利用 MySQL 的 Binlog 来驱动缓存更新。

1. 为什么要引入 Binlog 同步?

  • 彻底的关注点分离(解耦) : 业务代码只负责写数据库(Source of Truth),不需要关心缓存、ES、大数据分析等下游组件。代码更纯粹,维护成本更低。
  • 多端同步的复用性: 一次数据变更,往往不仅仅需要更新 Redis。可能还需要同步到 Elasticsearch 做索引,同步到数仓做报表。基于 Binlog 的中间件(如 Canal)可以作为单一数据源,分发给多个消费者。
  • 更高的可靠性: Binlog 是数据库的物理日志,是严格有序且持久化的。即使应用宕机,Binlog 依然存在,同步组件重启后可以从断点继续消费,保证了“至少一次”的执行语义,极大降低了数据丢失风险。

2. 架构流程

  1. 业务应用:执行 SQL 更新 MySQL,不操作 Redis。
  2. MySQL:生成 Binlog 日志。
  3. 同步组件(Canal / Maxwell / Debezium) :伪装成 MySQL Slave,实时订阅并解析 Binlog。
  4. 消息队列(Kafka / RocketMQ) :将解析后的变更数据写入 MQ,保证顺序性。
  5. 消费者服务:订阅 MQ,根据变更类型(Insert/Update/Delete)异步处理 Redis 缓存(删除或重建)以及 ES 索引同步。

四、 总结

缓存与数据库的一致性问题,本质上是在性能一致性之间做权衡(Trade-off)。

  1. 对于一般业务:使用 Cache Aside Pattern(先更库,后删缓存) 配合 TTL(超时时间) 兜底,已能满足 99% 的需求。
  2. 对于高并发读写:若采用先删缓存策略,务必加上 延时双删
  3. 对于大型分布式系统:为了代码解耦和数据同步的可靠性,基于 Binlog 的异步同步架构 是最佳实践。

记住,技术服务于业务。不存在绝对完美的架构,只有最适合当前业务阶段的架构。