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

浙公网安备 33010602011771号