一、为什么会出现数据不一致?
根本原因在于:这是一个涉及两个独立存储系统的数据更新操作,它无法被包装成一个原子操作(分布式事务)。更新数据库和更新缓存是两个独立的步骤,无论在代码中如何排列这两个步骤,都可能因为并发、失败重试等原因导致不一致。
主要的不一致场景可以归结为以下两类顺序问题:
-
先更新数据库,再删除缓存
- 成功:
更新 DB -> 删除 Cache
-> 数据一致(下次读取会回种缓存)。 - 失败:
- 更新 DB 成功,删除 Cache 失败:数据库是新数据,缓存是旧数据,发生不一致。
- 并发读写:在更新 DB 后、删除 Cache 前,另一个线程来读取,发现缓存是旧的并加载,随后 Cache 被删除,但里面已经是脏数据了。
- 成功:
-
先删除缓存,再更新数据库
- 成功:
删除 Cache -> 更新 DB
-> 数据一致。 - 失败:
- 删除 Cache 成功,更新 DB 失败:缓存是空的,数据库是旧数据,数据一致(但缓存miss,会从DB读旧数据回种,仍是旧数据)。
- 并发读写(更严重):
- 线程A
删除缓存
。 - 线程B 发现缓存不存在,
从数据库读取旧数据
。 - 线程B
将旧数据回种到缓存
。 - 线程A
更新数据库为新数据
。
- 结果:数据库是新数据,缓存是旧数据,发生不一致。
- 线程A
- 成功:
二、常见的解决方案与策略
没有一种完美的银弹方案,需要根据业务场景(对一致性的要求、读写比例、性能要求等)进行权衡和选择。
1. Cache-Aside (旁路缓存) + 延迟双删
这是最常用的模式,其读/写逻辑如下:
-
读流程:
- 从 Redis 读取数据。
- 如果命中,直接返回。
- 如果未命中,从 MySQL 读取数据。
- 将 MySQL 的数据写入 Redis(回种缓存),然后返回。
-
写流程(关键):
- 更新 MySQL 中的数据。
- 删除 Redis 中的对应缓存。
- (延迟双删的关键步骤) 等待一小段时间(如几百毫秒),再次删除 Redis 缓存。
为什么需要“延迟双删”?
它旨在解决上述“先更新数据库,再删除缓存”模式下的并发问题。第二次删除是为了清除在“更新DB”和“第一次删除Cache”这个时间间隙内,可能被其他读请求回种的旧数据。
优点:
- 实现相对简单,适用性广。
- 延迟双删能解决大部分并发导致的不一致。
缺点:
- 等待时间(延迟)需要估算,不好设置。
- 第二次删除可能仍会失败(需要重试机制)。
- 在延迟期间,可能仍有短暂的不一致。
改进:为第二次删除增加重试机制。可以将失败的删除操作写入一个消息队列,由专门的服务消费重试,确保最终一定删除成功。
2. Write-Through (穿透写) / Write-Behind
这类方案通常需要依赖一个独立的服务或中间件来统一管理缓存和数据库的写入。
-
Write-Through:
- 应用层只写入缓存(由一个中间件来管理)。
- 中间件同步地写入缓存和数据库。
- 保证了强一致性,但性能很差,因为每次写操作都要等待两个存储系统都完成。
-
Write-Behind (也叫Write-Back):
- 应用层只写入缓存(由一个中间件来管理)。
- 中间件先写缓存,然后异步地批量更新到数据库。
- 性能极高,但存在数据丢失风险(如果缓存宕机,未持久化的数据会丢失)。一致性最弱。
优点:
- Write-Through 强一致。
- Write-Behind 性能极高。
缺点:
- 架构复杂,需要引入和维护额外的中间件。
- Write-Through 性能低。
- Write-Behind 有丢失数据风险。
3. 基于 MySQL Binlog 的最终一致性方案(推荐)
这是目前最流行、最可靠的大型项目方案。其核心是利用 MySQL 的二进制日志(Binlog)进行增量数据同步。
工作原理:
- 业务代码正常更新 MySQL。
- 一个数据同步服务(如 Canal, Maxwell, Debezium)伪装成 MySQL 的从库,订阅并解析 Binlog。
- 同步服务获取到数据的变更事件(增、删、改)后,发送到一个消息队列(如 Kafka/RocketMQ)。
- 一个缓存更新服务消费 MQ 中的消息,然后删除 Redis 中对应的缓存。
优点:
- 彻底解耦:业务代码只关心数据库,完全不知道缓存的存在。代码简洁。
- 高可靠性:Binlog 是 MySQL 自带的高可靠机制,保证了数据变更不会丢失。
- 最终一致性:通过 MQ 的异步消费,保证了数据最终会一致,延迟低。
- 通用性:一套系统可以为多种业务服务。
缺点:
- 架构最复杂,技术门槛高,需要维护多个组件(同步服务、MQ、消费服务)。
三、方案选择与最佳实践总结
策略 | 一致性强度 | 复杂度 | 性能 | 适用场景 |
---|---|---|---|---|
Cache-Aside + 延迟双删 | 最终一致(可能有短暂不一致) | 中等 | 高 | 通用方案,适合大多数中小型项目 |
Write-Through | 强一致 | 高 | 低 | 对一致性要求极高,可接受写性能差的场景 |
Write-Behind | 弱一致(可能丢失数据) | 高 | 极高 | 写入巨大,对性能要求极高,能容忍数据丢失的场景(如计数、日志) |
Binlog 同步 | 最终一致(可靠性高) | 非常高 | 高 | 大型互联网项目,架构完善,需要高可靠性和解耦 |
通用最佳实践建议:
-
优先选择删除缓存,而不是更新缓存。
- 更新缓存可能带来并发问题、浪费资源(多次更新可能只有最后一次被读到)。直接删除让下一次读请求来回种缓存,是更 lazy 和高效的做法。
-
Key 的过期时间:
- 即使一切正常,也一定要给 Redis 的 Key 设置一个过期时间。这是最后一道防线,即使同步失败,旧缓存也会自动失效,最终达到一致。
-
保证删除操作的重试:
- 无论是哪种方案,删除缓存都可能失败。必须要有重试机制(如通过消息队列),确保删除最终成功。
-
读操作是否回种缓存?
- 在高并发场景下,如果缓存缺失(Cache Miss),可能会导致大量请求穿透到数据库(缓存击穿)。可以考虑使用互斥锁(Mutex Lock),只让一个请求去数据库回种缓存,其他请求等待。
-
根据业务容忍度选择策略:
- 对于用户信息、商品价格等对一致性要求较高的数据,推荐使用 Binlog 同步方案 或 延迟双删。
- 对于点赞数、浏览量等对一致性要求不高的数据,甚至可以设置短一点的过期时间,容忍短暂不一致。
结论:
对于追求稳定和可靠性的大型项目,基于 Binlog 的异步同步方案是最佳选择。对于中小型项目,从简单有效出发,Cache-Aside + 延迟双删 + 失败重试机制 是一个不错的起点,同时务必为缓存设置过期时间。