深入理解缓存一致性:为什么推荐「更新数据库 → 删除缓存」而不是延迟双删?
在使用 Redis + MySQL 的系统中,如何保证缓存与数据库的一致性,是面试和实际开发中的经典问题。
很多人听说过 延迟双删,但往往会产生疑问:
为什么是「先删缓存 → 更新数据库 → 延迟再删缓存」?
为什么不能「更新数据库 → 删除缓存 → 再删除一次缓存」?
本文将系统地分析几种常见方案及其优缺点。
一、缓存更新的四种方案
1. 先更新数据库,再更新缓存
text
更新数据库
↓
更新缓存
问题
假设两个线程同时操作:
线程 A:
text
更新数据库(值=10)
线程 B:
text
更新数据库(值=20)
由于网络延迟:
text
A 更新数据库成功
B 更新数据库成功
B 更新缓存(20)
A 更新缓存(10)
最终:
text
数据库:20
缓存:10
缓存与数据库不一致。
结论
❌ 不推荐。
2. 删除缓存 → 更新数据库
text
删除缓存
↓
更新数据库
并发问题
线程 A:
text
删除缓存
线程 B:
text
查询缓存,未命中
↓
查询数据库(旧值)
↓
写入缓存(旧值)
线程 A:
text
更新数据库(新值)
最终:
text
数据库:新值
缓存:旧值
产生脏数据。
结论
❌ 不推荐。
二、延迟双删方案
为了解决上面的问题,引入了延迟双删。
流程:
text
删除缓存
↓
更新数据库
↓
等待一段时间
↓
再次删除缓存
即:
text
第一次删缓存
↓
更新DB
↓
sleep(500ms)
↓
第二次删缓存
为什么需要第二次删除?
线程 A:
text
删除缓存
线程 B:
text
查询缓存失败
↓
查询数据库(旧值)
↓
准备写缓存
线程 A:
text
更新数据库(新值)
线程 B:
text
写入缓存(旧值)
最终:
text
数据库:新值
缓存:旧值
因此:
text
线程A等待500ms
↓
再次删除缓存
将旧缓存清除。
Java 示例
java
// 第一次删除缓存
redis.delete(key);
// 更新数据库
userMapper.update(user);
// 延迟删除
Thread.sleep(500);
// 第二次删除缓存
redis.delete(key);
生产环境通常使用 MQ:
text
删除缓存
↓
更新数据库
↓
发送延迟消息
↓
500ms后消费消息
↓
再次删除缓存
例如:
- RocketMQ 延迟消息
- RabbitMQ 延迟队列
- Kafka + 定时任务
三、主流方案:更新数据库 → 删除缓存
实际上,大多数互联网项目采用的是:
text
更新数据库
↓
删除缓存
而不是延迟双删。
例如:
java
userMapper.update(user);
redis.delete("user:" + id);
为什么这样更合理?
假设:
线程 A:
text
更新数据库
线程 B:
text
读取缓存(旧值)
线程 A:
text
删除缓存
最终:
text
数据库:新值
缓存:被删除
下一次查询:
text
缓存不存在
↓
查询数据库
↓
得到新值
↓
重新写入缓存
不会产生脏数据。
四、更新数据库 → 删除缓存仍然存在的问题
极端情况下:
线程 B:
text
缓存失效
↓
查询数据库(旧值)
此时:
线程 A:
text
更新数据库(新值)
↓
删除缓存
随后线程 B:
text
把旧值重新写入缓存
最终:
text
数据库:新值
缓存:旧值
虽然这个时间窗口非常小,但理论上仍然可能发生。
五、为什么不是「更新DB → 删缓存 → 再删缓存」?
很多人会想到:
text
更新数据库
↓
删除缓存
↓
立即再删除一次
实际上:
text
第一次删除
第二次删除
几乎同时完成。
而此时读线程可能还没有执行完:
text
查询数据库
↓
写缓存
第二次删除根本起不到作用。
因此必须:
text
更新数据库
↓
删除缓存
↓
等待500ms
↓
再删除缓存
让可能写入旧缓存的线程先完成,然后再清除。
所以第二次删除必须延迟。
解决的问题: 「先更后删」策略下,极低概率出现的并发脏读:
- 线程 B 缓存失效,查数据库(此时读到旧值)
- 线程 A 更新数据库(新值)
- 线程 A 删除缓存
- 线程 B 把刚才读到的旧值写回缓存
此时缓存里有旧值,数据库是新值,不一致。
第二次延迟删除的作用,就是等线程 B 这种"读旧写回"的操作完成后,再清一次缓存,把旧值扫掉。
六、生产环境如何实现延迟双删?
不推荐:
java
Thread.sleep(500);
因为:
- 阻塞线程;
- 影响吞吐量;
- 延迟时间难以控制。
通常采用 MQ:
text
更新数据库
↓
删除缓存
↓
发送延迟消息
↓
500ms后消费
↓
再次删除缓存
实现方式:
- RocketMQ 延迟消息
- RabbitMQ 延迟队列
- Kafka + 定时任务
七、为什么主流方案仍然是「更新数据库 → 删除缓存」?
原因有三个:
① 实现简单
java
updateDB();
deleteCache();
即可完成。
② 脏数据窗口极小
只有:
text
查询数据库
+
写缓存
这几十毫秒内才可能出现问题。
概率极低。
③ 最终一定一致
即使缓存出现旧值:
text
缓存过期
↓
重新加载数据库
↓
恢复一致
系统最终不会长期不一致。
八、高一致性方案
对于金融、电商库存等场景,仅靠删除缓存可能不够。
可以采用:
方案1:更新 DB → 删除缓存 → 延迟双删
text
更新DB
↓
删除缓存
↓
MQ延迟500ms
↓
再次删除缓存
一致性:★★★★☆
方案2:Canal + MQ
监听 MySQL Binlog:
text
更新数据库
↓
Binlog
↓
Canal
↓
MQ
↓
删除缓存
一致性:★★★★★
方案3:分布式锁
保证读写串行。
一致性:★★★★★
性能:较低。
九、面试标准回答
在 Redis + MySQL 场景下,推荐采用「更新数据库 → 删除缓存」策略,而不是更新缓存。因为更新缓存容易产生并发覆盖问题。虽然更新数据库后删除缓存仍存在极端情况下的数据不一致,但概率非常低,因此成为业界主流方案。对于一致性要求较高的场景,可以结合延迟双删、MQ、Canal 等方案保证最终一致性。
总结
| 方案 | 推荐指数 | 一致性 |
|---|---|---|
| 更新 DB → 更新缓存 | ⭐ | 容易覆盖 |
| 删除缓存 → 更新 DB | ⭐⭐ | 存在脏数据 |
| 删除缓存 → 更新 DB → 延迟双删 | ⭐⭐⭐⭐ | 最终一致 |
| 更新 DB → 删除缓存 | ⭐⭐⭐⭐⭐ | 主流方案 |
| 更新 DB → 删除缓存 → 延迟双删 | ⭐⭐⭐⭐⭐ | 高一致性 |
| Canal + MQ 同步缓存 | ⭐⭐⭐⭐⭐ | 最佳实践 |
最终建议
text
普通业务:
更新 DB
↓
删除缓存
高一致性业务:
更新 DB
↓
删除缓存
↓
MQ 延迟删除
大型分布式系统:
MySQL Binlog
↓
Canal
↓
MQ
↓
删除缓存