在现代高并发系统中,数据库(如 MySQL)和缓存(如 Redis)的双写一致性是一个经典难题。首先,我们需要明确一个核心观点:在分布式环境下,绝对的强一致性(任何时刻数据都完全一致)极难实现且代价高昂。因此,业界通常追求的是"最终一致性",即通过一系列策略,保证数据在短暂的不一致窗口后,最终能达到一致状态。
下面为你系统梳理保证数据库和缓存一致性的主流方案、进阶策略及最佳实践。
🥇 主流方案:Cache Aside Pattern (旁路缓存模式)
这是目前业界最常用、性价比最高的方案,核心思想是"读走缓存,写走数据库,更新后删除缓存"。
-
读操作流程:
1,应用先读取缓存。
2,如果缓存命中,直接返回数据。
3,如果缓存未命中,则查询数据库。
4,将数据库查询结果写入缓存(并设置合理的过期时间),再返回数据。
-
写操作流程:
1,先更新数据库。
2,然后删除缓存(推荐),而不是直接更新缓存。
-
为什么推荐"删除缓存"而不是"更新缓存"?
避免并发覆盖:假设有两个线程同时更新数据,一个更新为A,一个更新为B。如果顺序不当,可能导致缓存最终为A,而数据库实际为B,造成不一致。
减少无效操作:如果更新的数据后续没有被读取,直接更新缓存就是一种资源浪费。
-
存在的问题
尽管是主流方案,但在高并发下仍存在不一致窗口。例如:
线程A更新了数据库。
线程B的读请求此时到来,发现缓存未删除(或已过期),从数据库读取到了旧数据。
线程B将旧数据写回缓存。
线程A删除缓存的操作才执行(或已执行,但B又写回了)。
此时,缓存中是旧数据,数据库是新数据,产生了脏读。
🛠️ 进阶策略:解决不一致窗口
为了应对上述问题,可以采用以下进阶方案:
- 延迟双删 (Delayed Double Delete)
在 Cache Aside 模式的基础上,在写操作时进行两次缓存删除,以清除可能由并发读请求写入的旧数据。
流程:
删除缓存。
更新数据库。
等待一段预估的"安全时间"(如几百毫秒)。
再次删除缓存。
优缺点:
优点:能有效提升数据一致性,减少脏数据出现的概率。
缺点:等待时间难以精确设定,会降低写操作的吞吐量。 - 基于消息队列异步删除
将删除缓存的操作通过消息队列进行异步化和解耦,确保删除操作最终能被执行。
流程:
更新数据库。
将"删除缓存"的消息发送到消息队列(如 RocketMQ, Kafka)。
消费者订阅该消息,并执行删除缓存操作,如果删除失败,可以进行重试。
优缺点:
优点:提高了删除缓存操作的可靠性,解耦了业务逻辑。
缺点:引入了消息队列,系统架构变得更复杂。 - 基于 Binlog 异步监听 (Canal 模式)
这是一种更高级的解耦方案,由一个独立的服务去监听数据库的变更日志(Binlog),然后根据变更去操作缓存。
流程:
业务应用只负责更新数据库。
一个独立的订阅服务(如使用阿里巴巴的 Canal)监听数据库的 Binlog。
当捕获到数据变更时,该服务负责删除(或更新)对应的缓存。
优缺点:
优点:业务代码完全不感知缓存,实现了彻底的解耦,可靠性高。
缺点:架构复杂度最高,运维成本也最高。
📊 方案对比与选择
| 方案 | 一致性强度 | 系统复杂度 | 性能影响 | 适用场景 |
|---|---|---|---|---|
| Cache Aside | 最终一致 | 低 | 小 | 读多写少,对一致性要求不苛刻(如商品详情) |
| 延迟双删 | 较高 | 中 | 中 | 对一致性要求较高,能接受写性能下降 |
| 消息队列异步 | 最终一致 | 较高 | 小 | 对删除可靠性要求高,系统已引入MQ |
| Binlog 异步监听 | 最终一致 | 高 | 小 | 大型系统,要求业务与缓存逻辑彻底解耦 |
✅ 兜底与最佳实践
除了上述核心方案,以下实践是保证一致性的最后一道防线:
1,设置合理的缓存过期时间 (TTL):这是所有方案的兜底策略。即使缓存删除失败或出现脏数据,数据也会在过期时间到达后自动失效,后续请求会重新从数据库加载最新数据。
2,使用分布式锁:在读写并发极高的场景下,可以对热点数据的读写操作加分布式锁,确保同一时间只有一个线程能操作缓存或数据库。但这会严重牺牲性能,应谨慎使用。
3,定期对账与补偿任务:编写定时任务,对比数据库和缓存中的关键数据(如用户余额、库存),一旦发现不一致,立即进行修复。这可以看作是最后一道保险。