延迟双删的使用与注意事项
目录一、背景与目标为什么需要延迟双删二、概念与原理2.1 缓存更新的常见模式Cache Aside Pattern最常用2.2 先删缓存后更新数据库的脏数据问题2.3 先更新数据库后删缓存的脏数据问题三、核心详解3.1 延迟双删的执行步骤3.2 为什么休眠时间要大于读请求耗时3.3 代码实现Java伪代码异步实现推荐不阻塞写线程Go实现四、逻辑与对比4.1 三种缓存更新策略对比4.2 延迟双删 vs Canal异步删除五、案例实战实战1模拟并发脏数据实战2加入延迟双删实战3基于Canal的异步删除生产推荐六、避坑 最佳实践 常见误区✅ 最佳实践七、总结 路线图核心要点面试回答面试高频问题学习路线图一、背景与目标为什么需要延迟双删在缓存数据库的读写模式下常见的做法读请求先读缓存命中则返回未命中则读数据库并写入缓存写请求先更新数据库然后删除缓存或更新缓存但这种方式存在一个经典的并发脏数据问题。学完本文后你将能够✅ 理解缓存数据库模式下并发脏数据的产生原因✅ 掌握延迟双删的完整执行步骤和代码实现✅ 知道延迟双删的局限性和改进方案✅ 面试时从容回答如何保证缓存与数据库一致性二、概念与原理2.1 缓存更新的常见模式Cache Aside Pattern最常用读流程 读缓存 → 命中→ 返回 ↘ 未命中 → 读数据库 → 写入缓存 → 返回 写流程 1. 更新数据库 2. 删除缓存为什么是删除缓存而不是更新缓存更新缓存是写操作当并发写多时缓存会被频繁更新但读请求可能很少读到这些中间值。删除缓存是惰性删除等下次读请求再加载更节省资源。2.2 先删缓存后更新数据库的脏数据问题-- 原始值缓存X数据库X -- 线程A要把X改为Y时间线程A写操作线程B读操作t1删除缓存旧值X被删除-t2-读缓存未命中t3-读数据库读到旧值Xt4更新数据库改为Y-t5-将旧值X写入缓存 ←缓存脏数据结果缓存中是X旧值数据库中是Y新值数据不一致2.3 先更新数据库后删缓存的脏数据问题时间线程A写操作线程B读操作t1更新数据库X→Y-t2-读缓存命中读到Xt3删除缓存-t4-读缓存未命中t5-读数据库读到Yt6-写缓存Y这个场景最终一致。但还有一种极端情况时间线程A写操作线程B读操作t1更新数据库X→Y-t2-缓存过期读数据库读到Yt3删除缓存-t4-写缓存Y但如果步骤t2读到的是X旧值然后缓存删除在写缓存之前就没事。但如果读数据库慢读到的是Y然后缓存删除和写缓存并发可能出现先写Y再被删的情况。延迟双删就是为了解决先删缓存后更新数据库场景下的脏数据问题。三、核心详解3.1 延迟双删的执行步骤1. 删除缓存第一次删除 2. 更新数据库 3. 休眠一段时间例如500ms 4. 再次删除缓存第二次删除每一步的目的步骤目的第一次删除快速将旧缓存清掉更新数据库数据变更生效休眠等待可能存在的并发读请求把旧数据写回缓存第二次删除把并发读请求写回的脏缓存删除保证最终一致3.2 为什么休眠时间要大于读请求耗时休眠时间 读请求平均耗时 几百ms缓冲如果休眠时间太短第二次删除执行时并发读请求还没写完缓存脏数据会继续存在直到下一次更新或缓存过期如果休眠时间太长写操作被阻塞太久吞吐量下降3.3 代码实现Java伪代码public void updateData(String key, Object newValue) { // 1. 第一次删除缓存 redis.delete(key); // 2. 更新数据库 database.update(newValue); // 3. 休眠一段时间例如500ms Thread.sleep(500); // 4. 第二次删除缓存 redis.delete(key); }异步实现推荐不阻塞写线程public void updateDataAsync(String key, Object newValue) { // 1. 第一次删除缓存 redis.delete(key); // 2. 更新数据库 database.update(newValue); // 3. 异步延迟删除 executor.schedule(() - { redis.delete(key); }, 500, TimeUnit.MILLISECONDS); }Go实现func UpdateData(key string, newValue interface{}) { // 1. 第一次删除缓存 redisClient.Del(ctx, key) // 2. 更新数据库 db.Model(Model{}).Update(value, newValue) // 3. 延迟第二次删除 time.AfterFunc(500*time.Millisecond, func() { redisClient.Del(ctx, key) }) }四、逻辑与对比4.1 三种缓存更新策略对比策略执行步骤脏数据窗口复杂度先更新DB再删缓存1.更新DB 2.删缓存极小极短时间窗口低先删缓存再更新DB1.删缓存 2.更新DB较大并发读可能写回旧数据低延迟双删1.删缓存 2.更新DB 3.休眠 4.删缓存极小第二次删除后中4.2 延迟双删 vs Canal异步删除方案实现方式延迟复杂度可靠性延迟双删代码中Thread.sleep几百ms简单中固定休眠不准Canal订阅binlog监听MySQL binlog异步删缓存亚秒级复杂高binlog必达消息队列发送延迟消息删除缓存可配置中高五、案例实战实战1模拟并发脏数据// 模拟没有延迟双删的场景 // 假设初始值Redis有缓存的XMySQL有X // 线程A写操作 new Thread(() - { redis.del(key); // 1. 删缓存 Thread.sleep(100); // 模拟慢网络 db.update(key, Y); // 2. 更新数据库 }).start(); // 线程B读操作在线程A删缓存后立即执行 new Thread(() - { String val redis.get(key); // 1. 读缓存未命中 if (val null) { String dbVal db.get(key); // 2. 读数据库可能读到X redis.set(key, dbVal); // 3. 写缓存写回旧值X } }).start(); // 结果缓存中是X数据库中是Y不一致实战2加入延迟双删// 线程A写操作使用延迟双删 new Thread(() - { redis.del(key); // 1. 第一次删除 db.update(key, Y); // 2. 更新数据库 Thread.sleep(500); // 3. 休眠 redis.del(key); // 4. 第二次删除清除脏数据 }).start(); // 线程B读操作 new Thread(() - { String val redis.get(key); // 1. 读缓存未命中 if (val null) { String dbVal db.get(key); // 2. 读数据库读到旧值X redis.set(key, dbVal); // 3. 写缓存写回旧值X → 脏数据 } }).start(); // 结果虽然线程B写回了脏数据X但线程A在500ms后再次删除 // 下一次读请求 → 缓存未命中 → 读数据库得到Y→ 写缓存Y // 最终一致实战3基于Canal的异步删除生产推荐// 不再需要延迟双删而是用Canal监听binlog // Canal客户端监听 CanalTable(user) public void handleUserUpdate(User user) { // binlog变更时异步删除缓存 redis.del(user: user.getId()); } // 业务代码只需更新数据库 public void updateUser(User user) { userMapper.updateById(user); // 不需要再操作缓存Canal自动处理 // 而且基于binlog不会有脏数据问题 }六、避坑 最佳实践 常见误区误区1延迟双删保证强一致性❌ 不对。延迟双删只保证最终一致性。在第二次删除之前缓存中可能仍为旧数据。误区2休眠时间越长越好❌ 不对。休眠时间太长会阻塞写操作降低吞吐量。应该根据业务读请求的平均耗时来设置。误区3所有场景都适合延迟双删❌ 不对。高并发写入场景下重复的缓存删除可能会影响性能。更推荐使用Canal或消息队列。✅ 最佳实践优先使用Cache Aside Pattern先更新数据库再删缓存脏数据窗口最小如果业务必须先删缓存再更新DB使用延迟双删Canal兜底休眠时间设置为读请求P99耗时几百ms缓冲使用异步延迟删除消息队列/线程池避免阻塞写线程高并发场景推荐Canal订阅binlog实现缓存与数据库的最终一致性七、总结 路线图核心要点面试回答延迟双删是保证Redis与MySQL最终一致性的一种简单策略。核心步骤先删除缓存再更新数据库然后休眠一段时间通常大于读请求耗时最后再删除一次缓存。作用消除在先删缓存后更新数据库过程中由于并发读请求把旧数据写回缓存导致的脏数据问题。局限性固定休眠时间难以精确控制且会阻塞写操作。生产环境中更推荐使用binlog异步删除Canal或消息队列方案。面试高频问题问题一句话答案延迟双删能保证强一致性吗不能只保证最终一致性休眠时间怎么设置业务读请求P99耗时几百ms缓冲延迟双删的缺点是什么固定休眠难控制、阻塞写线程生产环境推荐什么方案Canal订阅binlog或消息队列异步删除学习路线图缓存一致性学习路径 ├── 基础篇 │ ├── 缓存数据库读写模式 │ ├── Cache Aside Pattern │ └── 先删缓存 vs 先更新DB ├── 进阶篇 │ ├── 延迟双删原理与实现 │ ├── 休眠时间计算 │ └── 异步删除优化 └── 高级篇 ├── Canal原理与部署 ├── binlog订阅机制 ├── 消息队列延迟删除 └── 最终一致性架构设计下一步推荐学习Redis缓存穿透/击穿/雪崩解决方案Canal原理与实战分布式事务与最终一致性