☠博主专栏 : <mysql高手> <elasticsearch高手> <源码解读> <java核心> <面试攻关>
一、缓存一致性问题的本质
缓存一致性问题的核心在于数据库更新和缓存更新/删除两个操作不是原子操作。在高并发场景下,这两个操作的先后顺序和执行时间差异会导致不一致。
CAP理论视角:缓存系统属于AP系统,追求的是最终一致性而非强一致性。追求绝对一致性的业务场景,不适合引入缓存。
二、常见缓存更新策略对比
| 策略 | 流程 | 一致性风险 | 推荐度 |
|---|---|---|---|
| 先更新缓存,再更新数据库 | 1. 更新Redis 2. 更新MySQL |
⚠️ 高风险:缓存更新成功,数据库更新失败 → 缓存新值,数据库旧值 | ❌ 不推荐 |
| 先更新数据库,再更新缓存 | 1. 更新MySQL 2. 更新Redis |
⚠️ 高风险:数据库更新成功,缓存更新失败 → 数据库新值,缓存旧值 | ❌ 不推荐 |
| 先删除缓存,再更新数据库 | 1. 删除Redis 2. 更新MySQL |
⚠️ 高并发下风险高:读请求可能读到旧值并写入缓存 | ❌ 不推荐 |
| 先更新数据库,再删除缓存 | 1. 更新MySQL 2. 删除Redis |
⚠️ 风险低:理论上存在不一致,但概率极小 | ✅ 推荐 |
三、关键并发场景分析及解决方案
场景1:先删除缓存,再更新数据库(高并发不一致)
问题描述:
- 线程A:删除Redis缓存
- 线程B:读缓存未命中 → 查询MySQL(旧值)→ 写入Redis
- 线程A:更新MySQL(新值)
结果:Redis存储旧值,MySQL存储新值
流程图:
解决方案:延时双删策略
- 删除Redis缓存
- 更新MySQL
- 延迟一段时间(根据业务评估)
- 再次删除Redis缓存
为什么有效:确保在数据库更新后,所有可能因缓存未命中而读取旧数据并写入缓存的请求都已执行完毕。
流程图:
关键点:延迟时间需根据业务读请求平均耗时评估(而非固定时间),一般为"读请求平均耗时 + 几百毫秒"。
场景2:先更新数据库,再删除缓存(理论不一致)
问题描述:
- 缓存刚好失效
- 线程A:查询MySQL(旧值)
- 线程B:更新MySQL(新值)并删除Redis
- 线程A:将旧值写入Redis
结果:Redis存储旧值,MySQL存储新值
流程图:
为什么概率低:需要同时满足:
- 缓存恰好失效
- 线程A读数据库耗时 > 线程B更新数据库+删除缓存耗时
结论:这是推荐的策略,理论不一致概率极低,实际应用中可接受。
场景3:删除缓存失败(缓存更新失败)
问题描述:更新数据库成功,但删除Redis缓存失败 → 数据库新值,缓存旧值
解决方案:删除缓存重试机制
- 更新MySQL
- 尝试删除Redis缓存
- 如果删除失败,将删除请求放入消息队列
- 消费者从消息队列获取请求,重试删除缓存
流程图:
关键点:避免"无脑sleep 500ms",使用MQ重试机制更可靠。
场景4:MySQL主从同步延迟
问题描述:使用MySQL读写分离,主库更新后,从库同步需要时间。如果读请求从从库读取,可能读到旧数据。
解决方案:
- 延时双删时间 = 读请求平均耗时 + 主从同步延迟 + 几百毫秒
- 或:更新缓存时强制走主库查询(避免从库延迟)
注意:主从同步延迟不稳定,推荐使用第一种方案。
四、高并发场景的终极解决方案:Binlog监听
原理:通过监听MySQL Binlog,自动触发缓存删除。
流程图:
优点:
- 业务代码无侵入
- 保证最终一致性
- 解决主从同步延迟问题
- 适合大规模系统
实现步骤:
- 部署Canal监听MySQL Binlog
- Canal将变更事件发送到MQ
- 消费者从MQ获取事件,删除对应Redis Key
关键点:需考虑消息顺序性(使用Kafka等有序队列)和幂等性。
五、最佳实践建议
| 方案 | 适用场景 | 一致性强度 | 复杂度 | 优点 | 缺点 |
|---|---|---|---|---|---|
| 先更新DB,再删缓存 | 通用场景 | 最终一致(短窗口) | 低 ✅ | 简单、通用、性能好 | 理论上存在不一致 |
| 延时双删 | 高并发场景 | 最终一致(窗口更小) | 中 | 有效解决并发问题 | 需准确评估延迟时间 |
| 删除缓存重试 + MQ | 高可靠性场景 | 最终一致 | 中高 | 保证删除可靠性 | 引入MQ复杂度 |
| Binlog监听 | 大型系统 | 最终一致 | 高 | 无业务侵入、自动同步 | 架构复杂度高 |
| 缓存过期时间 | 所有场景 | 最终一致 | 低 | 简单兜底 | 可能导致缓存命中率下降 |
重要提醒:缓存过期时间是兜底方案,不是解决方案。不要依赖过期时间解决一致性问题。
六、避免缓存不一致的实用技巧
-
缓存击穿处理:
- 使用互斥锁:查询缓存未命中时,加锁查询DB并写入缓存
- 代码:
public String getFromCacheOrDB(String key) { String value = redis.get(key); if (value != null) { return value; } // 加锁,防止缓存击穿 synchronized (key.intern()) { value = redis.get(key); if (value == null) { value = db.query(key); redis.set(key, value, 300); // 设置300秒过期 } } return value; }
-
缓存Key设计:
- 使用唯一索引作为缓存Key
- 避免多表查询导致的缓存Key设计复杂
-
缓存更新策略:
- 读请求:Cache-Aside(先读缓存,未命中则查DB)
- 写请求:先更新DB,再删除缓存
七、总结
-
首选策略:先更新数据库,再删除缓存(Cache-Aside模式)
- 理论不一致概率极低
- 简单、通用、性能好
-
高并发场景:采用延时双删策略
- 第一次删除缓存
- 更新数据库
- 延迟一段时间(根据业务评估)
- 再次删除缓存
-
高可靠性场景:采用删除缓存重试机制 + MQ
- 确保缓存删除操作的可靠性
-
大规模系统:采用Binlog监听方案(Canal + MQ)
- 无业务侵入,保证最终一致性
-
终极兜底:设置合理的缓存过期时间(如5-30分钟)
- 作为数据不一致的最后保障
关键认知:缓存系统追求的是最终一致性,不是强一致性。在大多数业务场景下,"短暂不一致"是可以接受的,且比不使用缓存带来的性能提升更有价值。
缓存一致性方案选择决策树
记住:没有100%完美的方案,只有最适合业务场景的方案。根据业务特点选择合适的一致性策略,是缓存系统设计的关键。