在现代高并发系统的设计中,引入缓存(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 的旧值——脏数据产生了。直接删除缓存可以规避这种复杂的并发覆盖问题。
二、 核心争议:先操作数据库,还是先操作缓存?
在写操作中,"更新数据库"和"删除缓存"这两个动作的顺序至关重要。
场景一:先删缓存,再更新数据库
这种方案在低并发下没问题,但在高并发读写场景下有巨大隐患:
- 线程 A 发起写操作,先删除了缓存。
- 线程 B 发起读操作,发现缓存缺失。
- 线程 B 读取数据库,此时线程 A 还没来得及更新数据库,所以 B 读到的是旧值。
- 线程 B 将旧值写入缓存。
- 线程 A 更新数据库为新值。
后果:缓存中存储了旧值,而数据库是新值。在缓存过期前,业务读到的永远是脏数据。
解决方案:延时双删(Delayed Double Delete)
为了解决上述问题,可以使用延时双删策略:
- 先删缓存。
- 更新数据库。
- 休眠 N 毫秒。
- 再次删除缓存。
休眠的时间通常需要略大于“读请求读取数据库 + 写入缓存”的时间。这确保了在休眠期间产生的脏数据缓存能被第二次删除操作清理掉。
场景二:先更新数据库,再删缓存(推荐)
这是业界更为推荐的标准做法。 虽然理论上它也存在并发问题(缓存刚好失效 -> 线程 A 读旧库 -> 线程 B 更新库 -> 线程 B 删缓存 -> 线程 A 写旧缓存),但因为“数据库写操作”通常比“读操作 + 写缓存”慢得多,这种极端情况发生的概率极低。
主要风险: 如果数据库更新成功,但删除缓存失败(如 Redis 网络抖动),则会出现数据不一致。
解决方案:消息队列重试机制
- 更新数据库成功。
- 删除缓存失败。
- 将需要删除的 Key 发送到消息队列(MQ)。
- 消费端接收消息,不断重试删除操作,直到成功。
三、 架构进阶:基于 Binlog 的异步同步架构
随着系统规模的扩大,在业务代码中显式地调用 redis.delete() 或处理 MQ 重试显得越来越“笨重”。大型互联网系统通常会引入**CDC(Change Data Capture)**技术,利用 MySQL 的 Binlog 来驱动缓存更新。
1. 为什么要引入 Binlog 同步?
- 彻底的关注点分离(解耦) : 业务代码只负责写数据库(Source of Truth),不需要关心缓存、ES、大数据分析等下游组件。代码更纯粹,维护成本更低。
- 多端同步的复用性: 一次数据变更,往往不仅仅需要更新 Redis。可能还需要同步到 Elasticsearch 做索引,同步到数仓做报表。基于 Binlog 的中间件(如 Canal)可以作为单一数据源,分发给多个消费者。
- 更高的可靠性: Binlog 是数据库的物理日志,是严格有序且持久化的。即使应用宕机,Binlog 依然存在,同步组件重启后可以从断点继续消费,保证了“至少一次”的执行语义,极大降低了数据丢失风险。
2. 架构流程
- 业务应用:执行 SQL 更新 MySQL,不操作 Redis。
- MySQL:生成 Binlog 日志。
- 同步组件(Canal / Maxwell / Debezium) :伪装成 MySQL Slave,实时订阅并解析 Binlog。
- 消息队列(Kafka / RocketMQ) :将解析后的变更数据写入 MQ,保证顺序性。
- 消费者服务:订阅 MQ,根据变更类型(Insert/Update/Delete)异步处理 Redis 缓存(删除或重建)以及 ES 索引同步。
四、 总结
缓存与数据库的一致性问题,本质上是在性能与一致性之间做权衡(Trade-off)。
- 对于一般业务:使用 Cache Aside Pattern(先更库,后删缓存) 配合 TTL(超时时间) 兜底,已能满足 99% 的需求。
- 对于高并发读写:若采用先删缓存策略,务必加上 延时双删。
- 对于大型分布式系统:为了代码解耦和数据同步的可靠性,基于 Binlog 的异步同步架构 是最佳实践。
记住,技术服务于业务。不存在绝对完美的架构,只有最适合当前业务阶段的架构。