【无标题】

深入理解缓存一致性:为什么推荐「更新数据库 → 删除缓存」而不是延迟双删?

在使用 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
↓
再删除缓存

让可能写入旧缓存的线程先完成,然后再清除。

所以第二次删除必须延迟。

解决的问题: 「先更后删」策略下,极低概率出现的并发脏读:

  1. 线程 B 缓存失效,查数据库(此时读到旧值)
  2. 线程 A 更新数据库(新值)
  3. 线程 A 删除缓存
  4. 线程 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
↓
删除缓存