分布式缓存一致性:从核心争议到企业级解决方案
分布式缓存一致性是高并发架构中最经典的难题之一。它的本质在于:数据库(如 MySQL)和缓存(如 Redis)是两个独立的系统,我们无法通过单一的本地事务来保证它们同时操作成功或同时失败。
当引入并发读写和网络延迟时,数据不一致的风险就会急剧放大。要彻底理解它,我们需要剖析在数据发生变更时,处理数据库和缓存顺序的几种方案及其引发的并发问题。
一、 核心争议:更新还是删除?先操作谁?
当业务数据发生更新时,我们面临两个基本选择:
1. 为什么推荐"删除缓存"而不是"更新缓存"?
- 更新缓存的缺点 :如果并发有多个写请求,容易发生并发写覆盖。例如,线程 A 和 B 先后更新数据库,但在网络抖动下,B 可能比 A 先更新了缓存,导致缓存中保留了 A 的旧数据。此外,如果该缓存数据是一个复杂计算的结果,频繁更新会浪费大量 CPU 资源(且更新后可能很久没人读)。
- 删除缓存(延迟加载) :数据更新时直接将缓存失效。只有当下一个读请求到来时,才去数据库查询并重新构建缓存。这是一种"懒加载"思想,能有效避免并发覆盖和计算资源浪费。
因此,业界普遍确立了**"操作 DB + 删除缓存"**的基调。
2. 为什么推荐"先更 DB,再删缓存"?
我们来推演一下并发场景下的两种顺序:
方案 A:先删缓存,再更新 DB
这种方案会导致严重的脏数据问题。
- 线程 A 准备更新数据,先删除了缓存。
- 线程 B 此时来读取数据,发现缓存为空,去数据库查到了"旧数据"。
- 线程 A 执行数据库更新完成。
- 线程 B 将刚才查到的旧数据写入了缓存 。
结果 :数据库已经是新值,但缓存中永远是旧值,直到缓存过期。这就是为什么通常需要配合"延迟双删"来弥补。
方案 B:先更新 DB,再删缓存
这是业界推荐的标准模式。 - 线程 A 更新数据库。
- 线程 A 删除缓存。
理论上,它也有一种极小概率的脏数据场景: - 缓存刚好失效。
- 线程 B 读数据库,拿到旧值。
- 线程 A 更新数据库,并删除缓存。
- 线程 B 将旧值写入缓存。
为什么说极小概率? 因为第 4 步(写内存)的速度必须慢于第 3 步(写磁盘+删内存)的速度,这在实际工程中极难发生。
二、 企业级一致性解决方案全景图
根据业务对"数据一致性"容忍度的不同,通常有以下几套标准打法:
1. 最终一致性(高性价比选项)
方案:Cache-Aside (先更 DB,再删缓存) + 缓存设置合理的 TTL(过期时间)
- 适用场景:绝大多数非核心计费的互联网业务(如商品详情、文章内容、用户公开信息)。
- 原理:利用 TTL 作为终极的"兜底"机制。即使因为网络抖动导致删除缓存失败,只要到了 TTL 时间,缓存自然失效,下一次读取一定会加载最新数据。
2. 准实时强一致性(解耦与重试)
方案:Binlog + MQ 异步清理
- 痛点解决:在 Cache-Aside 中,如果"更新 DB 成功,但立刻断网导致删缓存失败"怎么办?
- 架构设计 :
- 业务代码只负责更新 MySQL。
- 利用中间件(如 Alibaba Canal)伪装成 MySQL 从节点,监听 MySQL 的 Binlog 变更日志。
- Canal 解析出变更事件后,推送到消息队列(Kafka/RabbitMQ)。
- 独立的缓存同步服务消费 MQ,执行 Redis 的删除操作。
- 优势:业务代码无侵入;即使删除 Redis 失败,MQ 的 ACK 机制会自动重试,确保缓存最终一定被清理。
3. 严格强一致性(高昂的性能代价)
方案:读写串行化 / 分布式读写锁
- 适用场景:对一致性要求达到金融级,绝不容忍哪怕 1 毫秒的脏数据(例如:库存扣减、余额查询)。
- 原理 :
- 读锁(共享锁):只要没有线程在修改数据,大家都可以并发读取缓存。
- 写锁(排他锁):一旦有线程需要更新数据,写锁会阻塞所有的读请求,直到数据库更新完毕且缓存被清理,才允许新的读请求进来。
- 代价:极大地牺牲了并发性能。在真正的金融系统中,这种场景通常会直接放弃缓存,强打数据库主库。
三、 总结
处理分布式缓存一致性,本质上是在做**"可用性(性能)"与"一致性"**之间的权衡(CAP 定理的延伸):
- 如果没有极端的严格要求,先更新 DB,再删除缓存,并配上 TTL 是最稳妥的基石。
- 如果追求高可用且不想在业务代码里写重试逻辑,上 Canal + MQ。
- 不要轻易在读多写多的高并发场景下使用分布式锁来强保一致性,那会让你引入缓存带来的性能优势荡然无存。