文章目录
- [缓存一致性全套解决方案:从 Cache-Aside 到延迟双删、MQ 补偿、binlog 监听与多级缓存](#缓存一致性全套解决方案:从 Cache-Aside 到延迟双删、MQ 补偿、binlog 监听与多级缓存)
-
- 一、讨论背景:为什么缓存一致性这么容易混?
- 二、先给最终结论:不同场景怎么选?
- [三、基础模式:Cache-Aside 模式](#三、基础模式:Cache-Aside 模式)
-
- [1. 读流程](#1. 读流程)
- [2. 写流程](#2. 写流程)
- 四、为什么主流推荐"先更新数据库,再删除缓存"?
-
- [1. 错误方式:先删除缓存,再更新数据库](#1. 错误方式:先删除缓存,再更新数据库)
- [2. 正确方式:先更新数据库,再删除缓存](#2. 正确方式:先更新数据库,再删除缓存)
- [3. 为什么不是"更新数据库,再更新缓存"?](#3. 为什么不是“更新数据库,再更新缓存”?)
- [五、方案一:更新数据库 → 删除缓存 → TTL 兜底](#五、方案一:更新数据库 → 删除缓存 → TTL 兜底)
- 六、方案二:删除缓存失败怎么办?
-
- [1. 本地立即重试](#1. 本地立即重试)
- [2. MQ 异步重试删除](#2. MQ 异步重试删除)
- [3. 本地消息表 / Outbox 模式](#3. 本地消息表 / Outbox 模式)
- [4. binlog / CDC 监听删除缓存](#4. binlog / CDC 监听删除缓存)
- [5. 缓存 TTL 兜底](#5. 缓存 TTL 兜底)
- 七、方案三:延迟双删
-
- [1. 经典延迟双删](#1. 经典延迟双删)
- [2. 经典问题复盘](#2. 经典问题复盘)
- [3. 为什么"更新数据库 → 删除缓存"后也可以用延迟双删?](#3. 为什么“更新数据库 → 删除缓存”后也可以用延迟双删?)
- [4. 延迟多久合适?](#4. 延迟多久合适?)
- [5. 缺点](#5. 缺点)
- [八、方案四:更新数据库 → 更新缓存](#八、方案四:更新数据库 → 更新缓存)
-
- 适用场景
- 最大问题:并发写乱序
- [1. 分布式锁](#1. 分布式锁)
- [2. 版本号控制](#2. 版本号控制)
- [3. CAS 更新缓存](#3. CAS 更新缓存)
- [九、方案五:更新数据库 → 删除缓存 → 异步预热缓存](#九、方案五:更新数据库 → 删除缓存 → 异步预热缓存)
- 十、方案六:互斥锁防缓存击穿
- 十一、方案七:逻辑过期
- [十二、方案八:Read-Through / Write-Through / Write-Behind](#十二、方案八:Read-Through / Write-Through / Write-Behind)
-
- [1. Read-Through](#1. Read-Through)
- [2. Write-Through](#2. Write-Through)
- [3. Write-Behind](#3. Write-Behind)
- 十三、方案九:主从库读写分离场景
-
- 解决方案
-
- [1. 更新后短时间读主库](#1. 更新后短时间读主库)
- [2. 延迟双删](#2. 延迟双删)
- [3. 版本号控制](#3. 版本号控制)
- [4. 关键读请求强制读主库](#4. 关键读请求强制读主库)
- 十四、方案十:多级缓存一致性
- 十五、方案十一:强一致业务不要依赖缓存
- 十六、不同业务场景的完整选型
-
- [1. 普通读多写少业务](#1. 普通读多写少业务)
- [2. 热点商品 / 首页数据](#2. 热点商品 / 首页数据)
- [3. 写并发高的同一个 key](#3. 写并发高的同一个 key)
- [4. 对缓存命中率极高要求,又希望尽量新](#4. 对缓存命中率极高要求,又希望尽量新)
- [5. 多服务共同修改数据](#5. 多服务共同修改数据)
- [6. 本地缓存 + Redis](#6. 本地缓存 + Redis)
- [7. 主从库读写分离](#7. 主从库读写分离)
- [8. 强一致核心业务](#8. 强一致核心业务)
- 十七、企业级分层方案
-
- [Level 1:基础可用](#Level 1:基础可用)
- [Level 2:防删除失败](#Level 2:防删除失败)
- [Level 3:异步补偿](#Level 3:异步补偿)
- [Level 4:热点保护](#Level 4:热点保护)
- [Level 5:多服务一致](#Level 5:多服务一致)
- [Level 6:高并发写一致](#Level 6:高并发写一致)
- [Level 7:强一致](#Level 7:强一致)
- 十八、最终记忆口诀
-
- [1. 普通数据](#1. 普通数据)
- [2. 删除失败](#2. 删除失败)
- [3. 热点数据](#3. 热点数据)
- [4. 更新缓存](#4. 更新缓存)
- [5. 强一致](#5. 强一致)
- 十九、最终推荐组合
- 二十、总结
缓存一致性全套解决方案:从 Cache-Aside 到延迟双删、MQ 补偿、binlog 监听与多级缓存
本文适合 Java 后端、Redis 缓存设计、面试复习和系统设计总结。
核心目标:彻底搞清楚 数据库与缓存一致性 到底有哪些方案、分别解决什么问题、适合什么业务场景。
适合面试补差 和面试官吹水 缓存一致性的全套解决方案
基于本人多次面试的经验 每次都会存在缓存一致性的场景解决问题 + GPT总结 后写下
一、讨论背景:为什么缓存一致性这么容易混?
在实际业务中,我们经常会使用 Redis 缓存数据库中的热点数据。
比如商品详情:
text
数据库:商品价格 100
缓存:商品价格 100
当商品价格更新为 200 时,我们就会面临一个问题:
text
数据库变成 200 之后,缓存里的 100 怎么处理?
常见问题包括:
text
1. 是先更新数据库,还是先删除缓存?
2. 是删除缓存,还是更新缓存?
3. 删除缓存失败怎么办?
4. 并发读写时,旧数据会不会重新写回缓存?
5. 延迟双删到底是解决什么问题?
6. 热点数据删除缓存后,大量请求打到数据库怎么办?
7. 多个服务都能修改数据库,如何避免漏删缓存?
8. 本地缓存 + Redis 多级缓存如何保持一致?
9. 强一致业务,比如余额、支付、库存,到底能不能依赖缓存?
缓存一致性最容易混的原因是:没有一个方案适合所有场景。
我们要先建立一个核心认知:
数据库是"真相",缓存是"副本"。
所有缓存一致性方案,本质都是在处理:真相变了以后,副本如何不要错、不要错太久、不要把系统拖垮。
二、先给最终结论:不同场景怎么选?
| 业务场景 | 推荐方案 |
|---|---|
| 普通 CRUD、读多写少 | 更新数据库 → 删除缓存 → TTL 兜底 |
| 删除缓存可能失败 | 删除失败立即重试 / MQ 重试 / 本地消息表 |
| 多个系统都可能修改数据库 | binlog / CDC 监听数据库变更后删除缓存 |
| 热点数据,删除缓存后不能让大量请求打数据库 | 删除缓存后异步预热 / 互斥锁 / 逻辑过期 |
| 业务对缓存命中率要求极高 | 更新数据库 + 更新缓存,但必须加锁 / 版本号 / CAS |
| 主从库读写分离 | 更新后短时间读主库 / 延迟双删 / 版本号控制 |
| 本地缓存 + Redis 多级缓存 | 删除 Redis + MQ/PubSub 广播删除本地缓存 |
| 点赞数、浏览量、计数器 | Redis 原子操作 + 异步批量落库 |
| 余额、支付、订单、权限、库存扣减 | 核心判断不要依赖缓存,走数据库/事务/强一致模型 |
一句话总结:
普通场景:更新数据库,再删除缓存。
删除失败:靠重试和 MQ 补偿。
热点场景:删除后预热,防击穿。
并发更新缓存:必须加锁或版本号。
多系统修改:用 binlog 统一兜底。
强一致场景:别把缓存当真相。
三、基础模式:Cache-Aside 模式
Cache-Aside 是最常见的缓存使用模式,也叫旁路缓存模式。
1. 读流程
text
先读缓存
↓
缓存命中:直接返回
↓
缓存未命中:查询数据库
↓
把数据库结果写入缓存
↓
返回结果
Java 伪代码:
java
public Product getProduct(Long id) {
String key = "product:" + id;
Product cache = redis.get(key);
if (cache != null) {
return cache;
}
Product db = productMapper.selectById(id);
if (db != null) {
redis.set(key, db, 30, TimeUnit.MINUTES);
}
return db;
}
2. 写流程
推荐流程是:
text
更新数据库
↓
删除缓存
也就是:
java
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
redis.delete("product:" + product.getId());
}
但是更严谨一点,应该是:
text
更新数据库
↓
数据库事务提交成功
↓
删除缓存
因为如果数据库事务还没有提交,你就删除缓存,后面事务又回滚了,就会造成不必要的缓存失效,甚至引入一些并发问题。
Spring 中可以注册事务提交后的回调:
java
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
redis.delete("product:" + product.getId());
}
}
);
}
四、为什么主流推荐"先更新数据库,再删除缓存"?
这个地方最容易忘。
请记住这个比喻:
数据库是户口本,缓存是小区公告栏。
修改信息时,应该先改户口本,再撕掉旧公告。
如果先撕公告,但户口本还没改,别人可能根据旧户口本又贴回一张旧公告。
1. 错误方式:先删除缓存,再更新数据库
假设原来:
text
数据库:100
缓存:100
现在要改成:
text
数据库:200
错误流程:
text
线程 A:删除缓存
此时:数据库 100,缓存空
线程 B:读取数据
B 发现缓存为空,于是查询数据库
此时数据库还没有更新,所以 B 查到旧值 100
B 把旧值 100 写回缓存
线程 A:更新数据库为 200
最终:
数据库:200
缓存:100
这就是旧缓存复活。
所以:
先删缓存的问题是:缓存空了,但数据库还是旧的。
读请求会从旧数据库里读到旧值,并把旧值重新写回缓存。
2. 正确方式:先更新数据库,再删除缓存
流程:
text
线程 A:更新数据库为 200
线程 A:删除缓存
后续读请求:
缓存没有
查询数据库,读到 200
写入缓存 200
这样缓存最终会恢复为新值。
3. 为什么不是"更新数据库,再更新缓存"?
因为更新缓存会遇到并发写乱序。
假设两个线程同时更新同一个商品:
text
线程 A:把价格改成 200
线程 B:把价格改成 300
可能发生:
text
A 更新数据库为 200
B 更新数据库为 300
B 更新缓存为 300
A 更新缓存为 200
最终:
text
数据库:300
缓存:200
数据库最终值是 B 的 300,但缓存被 A 的 200 覆盖了。
所以主流更推荐:
text
更新数据库 → 删除缓存
原因是:
删除缓存不是制造新副本,而是让旧副本失效。
后续谁要读,就自己从数据库重新加载最新值。
五、方案一:更新数据库 → 删除缓存 → TTL 兜底
这是最基础、最常用、最推荐的方案。
适用场景
text
1. 普通商品信息
2. 用户资料
3. 文章详情
4. 店铺信息
5. 业务配置
6. 普通读多写少业务
基本流程
text
读:Cache-Aside
写:更新数据库 → 删除缓存
兜底:缓存设置 TTL
优点
text
1. 实现简单
2. 数据最终一致性容易保证
3. 并发问题相对少
4. 下次读请求会自动加载数据库最新值
缺点
text
1. 每次更新后,下一次读会缓存未命中
2. 热点 key 被删除后,可能大量请求打到数据库
3. 删除缓存失败时,旧缓存可能残留
因此基础方案通常还要配合:
text
1. 缓存 TTL
2. 删除失败重试
3. MQ 异步补偿
4. 热点 key 互斥锁
六、方案二:删除缓存失败怎么办?
这是一个非常核心的问题。
假设:
text
数据库更新成功:200
删除缓存失败
结果:
text
数据库:200
缓存:100
这时用户继续读缓存,会拿到旧值。
所以问题本质是:
真相已经改了,但旧副本还没销毁。
所有解决方案都是围绕一件事:
想办法让旧缓存最终一定被删除。
1. 本地立即重试
最简单方式:删失败就重试几次。
java
boolean deleted = false;
for (int i = 0; i < 3; i++) {
try {
redisTemplate.delete(cacheKey);
deleted = true;
break;
} catch (Exception e) {
log.warn("删除缓存失败,第 {} 次重试,key={}", i + 1, cacheKey, e);
Thread.sleep(100);
}
}
if (!deleted) {
log.error("缓存删除最终失败,key={}", cacheKey);
}
适合处理:
text
1. Redis 网络抖动
2. Redis 短暂超时
3. 连接池瞬时不可用
优点:
text
简单直接
缺点:
text
1. 会阻塞当前业务线程
2. 重试几次后仍可能失败
3. 服务宕机后,后续没人继续重试
形象理解:
撕公告失败,我站在公告栏前面马上再撕几次。
2. MQ 异步重试删除
更工程化的方案是:删除失败后发送 MQ 消息,让消费者异步重试删除。
流程:
text
更新数据库成功
↓
删除缓存失败
↓
发送 MQ 消息:请删除 product:1
↓
消费者异步删除缓存
↓
失败则继续重试 / 进入死信队列
业务代码:
java
try {
redisTemplate.delete(cacheKey);
} catch (Exception e) {
cacheDeleteProducer.send(new CacheDeleteMessage(cacheKey));
}
消费者:
java
@RabbitListener(queues = "cache.delete.queue")
public void consume(CacheDeleteMessage message) {
redisTemplate.delete(message.getKey());
}
优点:
text
1. 不阻塞主流程太久
2. 可以异步重试
3. Redis 短暂故障恢复后还能继续删除
4. 删除缓存天然幂等
因为:
text
DEL key 一次和 DEL key 十次,效果一样。
缺点:
text
如果删除缓存失败后,发送 MQ 消息也失败,就可能丢补偿任务。
所以更可靠的方案是本地消息表。
3. 本地消息表 / Outbox 模式
这是更可靠的补偿方案。
在同一个数据库事务里做两件事:
text
1. 更新业务数据
2. 插入一条"待删除缓存"的消息记录
例如:
text
product 表更新成功
cache_delete_task 表插入:
id = 1
cache_key = product:1
status = INIT
retry_count = 0
事务提交后,后台任务扫描本地消息表:
text
扫描待处理任务
↓
删除 Redis 缓存
↓
删除成功:标记 DONE
↓
删除失败:增加重试次数,稍后继续
优点:
text
1. 不怕 MQ 发送失败
2. 不怕服务中途宕机
3. 补偿任务持久化
4. 可观测、可重试、可人工干预
适合:
text
1. 订单
2. 商品
3. 支付相关非核心展示缓存
4. 用户关键资料
5. 对一致性要求更高的业务
形象理解:
我撕公告失败了,不是口头喊一句,而是在系统里登记一张工单。
这张工单不完成,就会一直有人处理。
4. binlog / CDC 监听删除缓存
这是大型系统中非常常用的解耦方案。
流程:
text
业务系统更新数据库
↓
MySQL 产生 binlog
↓
Canal / Debezium / Maxwell 监听到数据变更
↓
缓存同步服务删除对应 Redis key
例如:
text
update product set price = 200 where id = 1;
binlog 记录 product 表 id=1 被修改
缓存同步服务收到事件:
删除 Redis key:product:1
适合:
text
1. 多个服务都可能修改同一张表
2. 后台管理系统会修改数据
3. 定时任务会修改数据
4. DBA 脚本可能修改数据
5. 业务代码中容易漏删缓存
优点:
text
1. 对业务代码侵入小
2. 数据库变化能统一感知
3. 不依赖每个服务都记得删缓存
4. 多服务修改数据时更可靠
缺点:
text
1. 架构复杂度更高
2. CDC 服务本身需要高可用
3. 存在一定延迟
4. 需要维护表和缓存 key 的映射关系
形象理解:
不是靠每个业务员自己撕公告,而是派一个监督员盯着户口本。
只要户口本变了,监督员就去公告栏撕掉对应旧公告。
5. 缓存 TTL 兜底
不管采用哪种方案,缓存都建议设置过期时间。
java
redisTemplate.opsForValue().set(
cacheKey,
value,
30,
TimeUnit.MINUTES
);
作用是:
text
即使数据库更新成功后缓存删除失败,
即使 MQ 补偿也失败,
即使 binlog 监听也出问题,
旧缓存最多存活到 TTL 过期。
比如:
text
TTL = 30 分钟
最坏情况:
text
用户最多读到 30 分钟旧数据。
注意:
TTL 不能保证立即一致,只能限制不一致持续时间。
形象理解:
就算没人撕公告,这张公告过一会儿也会自己掉下来。
建议加随机过期时间,防止缓存雪崩:
java
long ttl = 30 * 60 + RandomUtil.randomInt(0, 300);
也就是:
text
30 分钟 + 0 到 5 分钟随机值
七、方案三:延迟双删
延迟双删的核心是:
缓存删两次,中间故意等一会儿。
1. 经典延迟双删
经典版本是:
text
删除缓存
↓
更新数据库
↓
延迟一段时间
↓
再次删除缓存
它主要针对:
text
先删除缓存,再更新数据库
这个顺序下的旧缓存复活问题。
2. 经典问题复盘
假设原来:
text
数据库:100
缓存:100
线程 A 要更新为 200:
text
A:删除缓存
此时:
数据库:100
缓存:空
线程 B 来读:
text
B:发现缓存为空
B:查询数据库,查到旧值 100
B:把 100 写回缓存
线程 A 继续:
text
A:更新数据库为 200
最终:
text
数据库:200
缓存:100
旧缓存复活。
加延迟双删后:
text
A:第一次删除缓存
B:读旧数据库,并把 100 写回缓存
A:更新数据库为 200
A:延迟一会儿
A:第二次删除缓存
最终:
text
数据库:200
缓存:空
后续读请求会重新加载数据库新值。
3. 为什么"更新数据库 → 删除缓存"后也可以用延迟双删?
经典延迟双删确实主要针对:
text
删除缓存 → 更新数据库
但在工程实践中,也会使用这个变体:
text
更新数据库
↓
删除缓存
↓
延迟一会儿
↓
再次删除缓存
它解决的是另一类问题:
text
1. 慢读请求提前读到旧数据库,但晚于删除缓存后写回旧缓存
2. 主从延迟导致读请求从从库读到旧值并写回缓存
3. 第一次删除缓存失败,第二次删除作为补偿
场景:慢读请求写回旧缓存
假设缓存刚好为空:
text
数据库:100
缓存:空
读线程 R 先开始:
text
R:查缓存,发现为空
R:查数据库,拿到旧值 100
此时写线程 W 开始:
text
W:更新数据库为 200
W:删除缓存
但 R 前面已经拿到了旧值 100,只是还没写缓存。随后:
text
R:把 100 写回缓存
最终:
text
数据库:200
缓存:100
如果 W 在删除缓存后延迟再删一次,就可以把这个旧缓存再删掉。
所以:
经典延迟双删是为"先删缓存再改库"擦屁股。
更新数据库后的延迟再删,是给推荐方案加保险。
4. 延迟多久合适?
没有固定答案,原则是:
text
延迟时间 > 一次数据库查询 + 写缓存的最大耗时
常见配置:
text
500ms
1s
2s
5s
如果存在主从延迟,还要考虑主从同步时间。
5. 缺点
text
1. 延迟任务如果只存在内存中,服务宕机会丢
2. 延迟时间不好估
3. Redis 长时间故障时无效
4. 不能保证强一致,只是降低脏缓存概率
所以延迟双删最好配合:
text
MQ 重试
本地消息表
TTL
binlog 监听
八、方案四:更新数据库 → 更新缓存
这个方案的目标是提升缓存命中率。
流程:
text
更新数据库
↓
更新缓存
这样缓存不会被删除,下次读请求仍然能命中缓存。
适用场景
text
1. 对缓存命中率要求非常高
2. 数据是热点 key
3. 写操作并发不高
4. 更新后的缓存值可以完整构造
5. 可以接受额外并发控制成本
例如:
text
商品详情热点数据
业务字典
配置中心数据
首页模块配置
价格展示缓存
最大问题:并发写乱序
假设:
text
数据库:100
缓存:100
两个线程同时更新:
text
A:改成 200
B:改成 300
可能发生:
text
A:更新数据库为 200
B:更新数据库为 300
B:更新缓存为 300
A:更新缓存为 200
最终:
text
数据库:300
缓存:200
所以如果使用更新缓存方案,必须增加并发控制。
1. 分布式锁
对同一个 key 加锁:
text
lock product:1
↓
更新数据库
↓
更新缓存
unlock product:1
代码示意:
java
RLock lock = redissonClient.getLock("lock:product:" + id);
try {
lock.lock(5, TimeUnit.SECONDS);
updateDatabase();
updateCache();
} finally {
lock.unlock();
}
优点:
text
同一个 key 的更新串行化,避免并发乱序。
缺点:
text
1. 写性能下降
2. 锁过期、锁续期、误删锁都要考虑
3. 锁粒度过大会影响吞吐
4. 热点 key 写竞争严重时性能差
适合:
text
同一个 key 写并发不高,但一致性要求较高的场景。
2. 版本号控制
数据库和缓存都带版本号。
例如缓存值:
json
{
"value": 200,
"version": 2
}
如果 B 已经写入:
json
{
"value": 300,
"version": 3
}
A 后到时想写:
json
{
"value": 200,
"version": 2
}
发现当前缓存版本是 3,而自己版本是 2,于是拒绝覆盖。
核心思想:
只允许高版本覆盖低版本,低版本不能覆盖高版本。
优点:
text
1. 防止旧请求后写覆盖新缓存
2. 比分布式锁吞吐更好
3. 适合并发更新较多的场景
缺点:
text
1. 实现复杂
2. 缓存值需要带版本
3. 写缓存需要保证版本比较的原子性
可以使用 Redis Lua 脚本保证原子比较和写入。
3. CAS 更新缓存
CAS 的核心思想是:
text
只有当缓存还是我预期的版本时,我才更新它。
Redis 可以通过:
text
WATCH / MULTI / EXEC
Lua 脚本
版本号字段
实现类似 CAS 的效果。
适合:
text
并发更新同一个 key,且需要防止乱序覆盖。
九、方案五:更新数据库 → 删除缓存 → 异步预热缓存
这是热点数据场景下非常推荐的方案。
流程:
text
更新数据库
↓
删除缓存
↓
发送 MQ:请预热 product:1
↓
消费者查询数据库最新值
↓
重新写入缓存
它结合了两个优点:
text
删除缓存:一致性更稳
异步预热:减少后续读请求缓存未命中
代码示意:
java
public void updateProduct(Product product) {
productMapper.updateById(product);
redis.delete("product:" + product.getId());
mq.send(new CacheWarmupMessage(product.getId()));
}
消费者:
java
public void warmup(Long productId) {
Product latest = productMapper.selectById(productId);
redis.set("product:" + productId, latest, 30, TimeUnit.MINUTES);
}
适合:
text
1. 热点商品
2. 首页数据
3. 活动页配置
4. 排行榜
5. 高访问频率业务数据
优点:
text
1. 不直接更新缓存,避免写缓存乱序问题
2. 删除后快速预热,减少缓存未命中
3. 预热失败可以 MQ 重试
缺点:
text
1. 引入 MQ
2. 预热有延迟
3. 消费者需要防并发重复预热
十、方案六:互斥锁防缓存击穿
缓存一致性和缓存击穿经常一起出现。
当热点 key 被删除后,大量请求同时来读:
text
热点商品 product:1 缓存被删除
1 万个请求同时进来
全部发现缓存为空
全部查询数据库
数据库被打爆
解决方法:互斥锁。
流程:
text
读缓存
↓
缓存不存在
↓
尝试获取重建锁
↓
拿到锁:查数据库,写缓存
↓
没拿到锁:等待一小会儿,再查缓存
伪代码:
java
Product cache = redis.get(key);
if (cache != null) {
return cache;
}
boolean locked = redis.setIfAbsent("lock:" + key, "1", 5, TimeUnit.SECONDS);
if (locked) {
try {
Product db = productMapper.selectById(id);
redis.set(key, db, ttl);
return db;
} finally {
redis.delete("lock:" + key);
}
} else {
Thread.sleep(50);
return redis.get(key);
}
适合:
text
热点 key
缓存删除后容易瞬间打爆数据库
缺点:
text
1. 请求会等待
2. 锁超时要设置合理
3. 代码复杂度增加
十一、方案七:逻辑过期
逻辑过期是高并发热点数据常用方案。
普通 TTL 是 Redis 自动删除 key。
逻辑过期是:
text
Redis key 不自动删除
缓存值里带一个 expireTime 字段
例如:
json
{
"data": {
"id": 1,
"price": 100
},
"expireTime": "2026-06-04 12:00:00"
}
读流程:
text
读缓存
↓
缓存不存在:查数据库重建
↓
缓存存在,未逻辑过期:直接返回
↓
缓存存在,但逻辑过期:
先返回旧值
后台异步刷新缓存
优点:
text
1. 高可用
2. 不会出现热点 key 瞬间失效导致数据库被打爆
3. 用户请求延迟低
缺点:
text
1. 用户可能短时间读到旧数据
2. 实现更复杂
3. 不适合强一致业务
适合:
text
1. 首页数据
2. 商品详情热点数据
3. 排行榜
4. 推荐结果
5. 可以接受短暂旧数据的高并发场景
不适合:
text
余额
支付状态
库存真实扣减
权限判断
十二、方案八:Read-Through / Write-Through / Write-Behind
这些属于缓存架构模式。
1. Read-Through
业务只查缓存,缓存组件负责加载数据库。
text
业务查缓存
↓
缓存没有
↓
缓存组件查数据库
↓
缓存组件写缓存
↓
返回
优点:
text
业务代码更干净
缓存加载逻辑集中管理
缺点:
text
对缓存组件能力要求较高
普通业务中不如 Cache-Aside 常见
2. Write-Through
业务写缓存,由缓存组件同步写数据库。
text
业务写缓存
↓
缓存组件写数据库
↓
数据库和缓存同步更新
优点:
text
封装性好
业务不用自己维护数据库和缓存两套逻辑
缺点:
text
1. 写延迟变高
2. 缓存层复杂度高
3. 对基础设施要求高
适合:
text
统一缓存平台
中间件团队提供能力
3. Write-Behind
先写缓存,异步写数据库。
text
写缓存
↓
异步批量写数据库
优点:
text
写性能高
适合批量刷库
风险:
text
缓存写成功,数据库还没写,服务挂了,数据可能丢。
适合:
text
1. 日志
2. 计数器
3. 点赞数
4. 浏览量
5. 可以异步落库的数据
不适合:
text
订单
支付
余额
库存真实扣减
十三、方案九:主从库读写分离场景
如果系统是:
text
写主库
读从库
会出现主从延迟问题。
流程:
text
写请求更新主库为 200
删除缓存
读请求缓存未命中
读请求查询从库
从库还没同步,还是 100
读请求把 100 写回缓存
最终:
text
主库:200
缓存:100
解决方案
1. 更新后短时间读主库
用户刚写完数据,接下来几秒内读主库。
适合:
text
用户修改资料后立刻查看
订单状态刚变更后立刻查询
2. 延迟双删
text
更新数据库
删除缓存
延迟一段时间
再次删除缓存
等从库同步完成后,再删一次可能被旧从库值重建的缓存。
3. 版本号控制
缓存写入时检查版本。
如果从库旧数据版本低,就不能覆盖缓存中的新版本。
4. 关键读请求强制读主库
对于强一致读取,直接读主库,不读从库。
十四、方案十:多级缓存一致性
很多大型系统不是只有 Redis,还有本地缓存。
例如:
text
Caffeine 本地缓存
↓
Redis 分布式缓存
↓
MySQL
读流程:
text
先读本地缓存
本地没有读 Redis
Redis 没有读 MySQL
问题是:
text
数据库更新了
Redis 删除了
但是各个应用实例的本地缓存还在
推荐方案
text
更新数据库
↓
删除 Redis
↓
发送 MQ / Redis PubSub 缓存失效消息
↓
所有应用实例删除本地 Caffeine 缓存
本地缓存必须设置短 TTL。
text
即使广播失败,本地缓存也会很快过期。
适合:
text
1. 极高 QPS
2. 读多写少
3. 本地缓存命中率很重要
4. 多实例部署
注意:
多级缓存中,只删除 Redis 不够,本地缓存也必须失效。
十五、方案十一:强一致业务不要依赖缓存
有些业务不要幻想靠缓存一致性方案解决。
比如:
text
账户余额
支付状态
订单最终状态
库存真实扣减
优惠券核销
权限安全判断
这些场景应该:
text
核心判断走数据库 / 分布式事务 / 乐观锁 / 悲观锁 / Redis 原子操作
缓存只做展示或加速
例如:
text
展示库存:可以读缓存
扣减库存:必须走 Redis 原子扣减或数据库乐观锁
展示余额:可以缓存
扣款判断:必须查账务系统或数据库
订单列表:可以缓存
是否允许退款:必须查数据库最新状态
核心原则:
缓存可以加速读,但不要让缓存决定生死。
十六、不同业务场景的完整选型
1. 普通读多写少业务
例如:
text
商品详情
文章详情
用户主页
店铺信息
推荐:
text
Cache-Aside
更新数据库 → 删除缓存
缓存设置 TTL
删除失败简单重试
增强:
text
MQ 重试删除
互斥锁防击穿
2. 热点商品 / 首页数据
特点:
text
读非常多
缓存命中率很重要
删除缓存后可能打爆数据库
推荐:
text
更新数据库 → 删除缓存 → 异步预热缓存
互斥锁防击穿
逻辑过期
TTL 随机化
如果能接受短暂旧数据:
text
逻辑过期 + 后台刷新
3. 写并发高的同一个 key
例如:
text
库存
点赞数
计数器
排行榜分数
推荐:
text
Redis 原子操作
Lua 脚本
异步批量落库
版本号控制
MQ 顺序消费
不要简单使用:
text
更新数据库 → 更新缓存
非常容易乱序。
4. 对缓存命中率极高要求,又希望尽量新
例如:
text
业务配置
价格缓存
会员权益配置
业务字典
可以考虑:
text
更新数据库 → 更新缓存
但必须配合:
text
分布式锁
版本号
CAS
TTL
MQ 补偿
如果写少读多,也可以选择:
text
更新数据库 → 删除缓存 → 立即预热
5. 多服务共同修改数据
例如:
text
商品服务改商品
后台管理系统改商品
活动服务改商品价格
定时任务改商品状态
推荐:
text
业务层删除缓存 + binlog 监听删除缓存
或者:
text
CDC 统一负责缓存失效
否则某个服务忘记删缓存,就会出现脏数据。
6. 本地缓存 + Redis
推荐:
text
更新数据库
删除 Redis
发布缓存失效消息
所有实例删除本地缓存
本地缓存设置短 TTL
7. 主从库读写分离
推荐:
text
更新后短时间读主库
延迟双删
版本号防旧值写回
缓存 TTL 兜底
8. 强一致核心业务
推荐:
text
核心读写不走缓存
数据库事务 / 乐观锁 / 悲观锁 / Redis 原子操作
缓存只做展示
十七、企业级分层方案
如果做项目,可以按等级设计。
Level 1:基础可用
text
读:Cache-Aside
写:更新数据库 → 删除缓存
缓存:设置 TTL
适合普通项目。
Level 2:防删除失败
text
更新数据库
事务提交后删除缓存
删除失败立即重试
缓存 TTL 兜底
适合大多数中小业务。
Level 3:异步补偿
text
更新数据库
事务内写本地消息表
事务提交后删除缓存
失败则 MQ / 定时任务重试
缓存 TTL 兜底
适合订单、商品、用户等重要业务。
Level 4:热点保护
text
更新数据库
删除缓存
异步预热缓存
互斥锁防击穿
逻辑过期
TTL 随机化
适合高并发热点数据。
Level 5:多服务一致
text
业务删除缓存
CDC / binlog 监听数据库变更
统一删除或更新缓存
本地缓存广播失效
适合大型微服务系统。
Level 6:高并发写一致
text
分布式锁 / 版本号 / CAS / Lua
MQ 顺序消费
Redis 原子操作
最终异步落库
适合库存、计数、点赞、排行榜。
Level 7:强一致
text
核心读写不依赖缓存
数据库事务 / 账务系统 / 强一致存储
缓存只做展示
适合钱、支付、订单、权限、安全业务。
十八、最终记忆口诀
1. 普通数据
先改真相,再删副本。
text
更新数据库 → 删除缓存
2. 删除失败
撕公告失败,就登记工单继续撕。
text
重试 / MQ / 本地消息表 / binlog / TTL
3. 热点数据
不要让公告栏突然空掉,然后所有人冲去派出所查户口本。
text
异步预热 / 互斥锁 / 逻辑过期
4. 更新缓存
亲手贴新公告,就要防止别人后贴旧公告覆盖你。
text
分布式锁 / 版本号 / CAS
5. 强一致
生死判断不要看公告栏,要看户口本。
text
余额、支付、库存扣减、权限判断:不要依赖缓存最终一致。
十九、最终推荐组合
如果是普通 Java 后端项目,我建议默认选择这一套:
text
1. 读:Cache-Aside
2. 写:数据库事务提交后删除缓存
3. 缓存:设置合理 TTL + 随机抖动
4. 删除失败:立即重试 2~3 次
5. 仍失败:写本地消息表 / 发 MQ 重试
6. 热点 key:加互斥锁防击穿
7. 高热点:删除后异步预热
8. 多服务修改:加 binlog / CDC 兜底
9. 本地缓存:通过 MQ / PubSub 广播失效
10. 强一致业务:核心逻辑不依赖缓存
二十、总结
缓存一致性没有绝对完美方案,只有适合业务场景的取舍。
普通业务中,最推荐的基础方案是:
text
更新数据库 → 删除缓存 → TTL 兜底
如果删除缓存失败,就使用:
text
立即重试
MQ 重试
本地消息表
binlog 监听
TTL 兜底
如果是热点数据,就使用:
text
异步预热
互斥锁
逻辑过期
TTL 随机化
如果是更新缓存,就必须考虑:
text
分布式锁
版本号
CAS
乱序覆盖
如果是强一致业务,要记住:
text
缓存不能当真相。
数据库、事务、账务系统、强一致存储才是最终依据。
最后用一句话收尾:
缓存是副本,不是真相。
普通业务让副本最终一致;热点业务让副本别突然消失;强一致业务不要相信副本。