本文目录导读:

缓存一致性是分布式系统、数据库以及多核CPU架构中的一个核心难题,要理解如何保证它,首先需要明确矛盾点:数据在缓存(速度快)和主存(速度慢)之间有两份拷贝,当一方修改了数据,另一方就变得“不一致”了。
针对不同的应用场景,保证缓存一致性的策略完全不同,下面从业务系统(如Redis与MySQL) 和计算机系统(如CPU多核缓存) 两个层面来详细说明。
业务系统层面:Redis与数据库(MySQL)的一致性
这是日常开发中最常遇到的问题,目标是让Redis中的缓存数据和MySQL中的数据最终一致(甚至强一致)。
核心原则:先更新数据库,再删除(或更新)缓存
这几乎是业界公认的最佳实践“套路”,但需要配合正确的策略。
最常用策略:Cache-Aside Pattern + 延迟双删
-
读操作:
- 先读缓存,命中则直接返回。
- 缓存未命中,则读数据库。
- 将读取的数据写入缓存(设置过期时间)。
- 返回数据。
-
写操作:
- 更新数据库(第一步,关键)。
- 删除缓存(第二步,而非更新缓存)。
为什么是“删除”而不是“更新”?
- 避免并发写:如果同时有两个写请求,先更新缓存A,再更新缓存B,但数据库更新顺序相反,会导致缓存数据是脏的。
- 懒加载:缓存只会在下次被读取时才加载,减少了不必要的写缓存开销,如果数据很少被读取,删掉它非常划算。
-
问题与解决:并发读写的“脏读”问题
- 场景:线程A更新数据库,随后准备删除缓存,在线程A删除缓存之前,线程B读取缓存(发现还有旧数据)并返回,这导致了短暂的不一致。
- 解决方案:延迟双删
- 先删除缓存(预删除)。
- 更新数据库。
- 休眠一小段时间(如几百毫秒)。
- 再次删除缓存。
- 原理:首次删除确保后续读请求会去读库,第二次删除是为了“消灭”在更新数据库期间,恰好有读请求写入缓存的旧数据,休眠时间需要大于一次读请求 + 写缓存的时间。
强一致性方案:读写锁(分布式锁)
如果业务不能接受任何不一致(例如交易、库存),可以使用读写锁。
- 写锁:在更新数据库和删除缓存之前,先获取写锁,其他所有读写请求都要等待。
- 读锁:读数据时,获取读锁,多个读锁可以共存,但写锁需要排他。
- 代价:会极大地降低系统的并发性能,将“缓存”降级为“同步队列”,通常只在极端场景(如并发写冲突极高)下使用。
终极方案:缓存和数据库同步写入(2PC / TCC)
- 2PC(两阶段提交):数据库和缓存都作为一个参与者,要么都写成功,要么都回滚,但实现复杂,性能损失大,且Redis原生不支持2PC。
- TCC(Try-Confirm-Cancel):尝试(预留资源)、确认(写入)、取消(回滚),适用于复杂的跨服务事务。
兜底方案:设置过期时间
- 所有缓存数据都应该设置合理的TTL(过期时间),即使因为并发问题出现了短暂的不一致,一旦缓存过期,下一次读请求就会从数据库拉取最新数据,自动修复不一致。
计算机系统层面:CPU多核缓存一致性
当多个CPU核各自拥有自己的L1/L2缓存时,需要保证它们看到同一份内存数据的“视图”是一致的。
核心技术:缓存一致性协议(MESI及其变种)
MESI是经典的缓存一致性协议,它定义了缓存行(Cache Line)的四种状态:
- M(Modified,修改):该缓存行数据是脏的,尚未写回主存,且仅此CPU拥有。
- E(Exclusive,独占):该缓存行数据与主存一致,且仅此CPU拥有。
- S(Shared,共享):该缓存行数据与主存一致,且多个CPU拥有该副本。
- I(Invalid,失效):该缓存行无效,不能使用。
工作原理(通过总线嗅探实现):
- CPU A 读取数据 x:如果x不在缓存中,从主存加载,状态为
E(独占),如果其他CPU也有,则变为S(共享)。 - CPU B 读取同一数据 x:CPU A 嗅探到总线上有读请求,将自身状态从
E或S变为S,CPU B 加载后状态也是S。 - CPU A 修改数据 x:
- CPU A 必须先发送一个“读独占(Read For Ownership,RFO)”信号到总线。
- 其他CPU(如CPU B)嗅探到这个信号,将自身状态改为
I(失效)。 - 之后CPU A 修改数据,状态变为
M(修改)。
- CPU B 再次读取数据 x:
- CPU B 发现缓存状态为
I(失效),于是发起读请求。 - CPU A 嗅探到读请求,将自身状态从
M写回主存,并变为S或E(取决于是否保留副本)。 - CPU B 从主存(或直接从CPU A的缓存)读取最新的数据。
- CPU B 发现缓存状态为
现代CPU的优化:
MESI协议性能有瓶颈(总线压力大,写操作需要等待其他核心失效),现代CPU引入了写缓冲(Store Buffer)、失效队列(Invalidation Queue) 等机制,这导致了内存可见性问题(一个线程的写操作对其他线程不可见),需要通过内存屏障(如Java中的volatile关键字的实现)来强制执行顺序和可见性。
如何选择合适的策略?
| 场景 | 策略 | 特点 | 适用场景 |
|---|---|---|---|
| 业务系统(高并发) | Cache-Aside + 延迟双删 | 最终一致性,性能最高 | 绝大多数业务系统,如商品详情页、用户信息 |
| 业务系统(强一致) | 读写锁 / 分布式锁 | 强一致性,性能中等 | 库存扣减、账户余额、有严格限制的配置 |
| 业务系统(要求极高) | 2PC / TCC + 缓存旁路 | 强一致性,性能最低 | 支付结算、核心交易链路 |
| CPU多核缓存 | MESI / MOESI / 各种变体 | 硬件级别保证一致性 | 所有现代多核CPU的透明执行 |
最终的“保命”经验:
- 不管用什么方案,缓存一定要设置过期时间。 这是最终一致性最坚固的防线。
- 写操作永远先更新数据库,再操作缓存。 (不要反过来,否则并发问题极难处理)。
- 能删缓存就别更新缓存。 删除是原子操作(清除一个键),而更新需要复杂的并发控制。
- 如果流量极低且允许不一致,可以完全不使用缓存。 不是所有系统都需要缓存一致性。
- 实际生产中最稳妥的方案: 更新数据库 -> 删除缓存 -> (可选)短暂延时后再次删除缓存。 配合一个兜底的异步补偿任务(如监听数据库Binlog,发现有变更后刷新缓存),可以在99.99%的情况下达到“准强一致”的效果。