4. 先更新数据库再删除缓存:经典方案原理、并发问题与延迟双删

“先更新数据库,再删除缓存”是业界最经典的缓存一致性治理方案之一。该方案的核心思想是:以数据库为权威数据源(Source of Truth),缓存仅作为加速层。更新操作完成后,主动让缓存失效,下次读取时再从数据库加载最新数据。

4.1 方案原理与执行流程

该方案的标准执行步骤分为写入与读取两条路径:

操作路径 步骤 说明
写请求 ① 更新数据库 将最新数据写入 MySQL / PostgreSQL 等数据库
② 删除缓存 删除 Redis / Memcached 中对应的 key
读请求 ① 查询缓存 若缓存命中,直接返回
② 缓存未命中,查询数据库 从数据库读取最新数据
③ 回写缓存 将数据库结果写入缓存,设置过期时间
设计意图: 删除缓存而非更新缓存,是为了避免并发写场景下的“写写覆盖”问题。删除操作天然幂等,且能迫使后续读请求重新加载数据,保证最终一致性。

4.2 经典并发问题:A 删缓存,B 读旧数据写缓存

尽管方案简洁,但在高并发场景下存在一个经典的时序竞态问题,导致缓存中长时间驻留旧数据:

  1. 写线程 A 更新数据库(将值从 V1 改为 V2)。
  2. 写线程 A 删除缓存(此时缓存为空)。
  3. 读线程 B 发起读请求,缓存未命中。
  4. 读线程 B 查询数据库,但此时数据库尚未完成事务提交(或主从延迟),读到旧值 V1。
  5. 读线程 B 将 V1 写入缓存。
  6. 此后所有读请求都命中缓存中的 V1,直到缓存过期,造成数据不一致。
核心矛盾: 删除缓存与读请求回写缓存之间存在“时间窗口”。在这个窗口内,读请求可能读到旧数据并回填,导致缓存被“污染”。该问题在读写并发量级超过 1000 QPS 时尤为突出。
并发问题时序:A 删缓存,B 读旧数据写缓存 写线程 A 数据库 读线程 B ① 更新 DB V1 → V2 ② 删缓存 缓存已空 ③ 读缓存 未命中 ④ 读 DB 读到 V1 ⑤ 写缓存 V1 缓存 = V1 ⚠ 缓存被旧数据 V1 污染,后续读请求持续命中 V1,直到缓存过期

4.3 解决方案:延迟双删(Delayed Double Delete)

延迟双删是针对上述并发问题最成熟的工程化解决方案。其核心思路是:在第一次删除缓存后,等待一段时间,再次删除缓存,以覆盖并发读请求可能写入的旧数据。

4.3.1 执行流程

  1. 写线程 A 更新数据库。
  2. 写线程 A 第一次删除缓存。
  3. 写线程 A 休眠一段时间(延迟时间)。
  4. 写线程 A 第二次删除缓存。
延迟时间估算公式:
delay > 读请求平均耗时 + 数据库主从同步延迟 + 业务容忍的脏数据窗口
生产环境通常取 500ms ~ 1500ms,具体需根据实际监控数据调整。
延迟双删方案执行流程 ① 更新数据库 ② 第一次删缓存 ③ 休眠 delay ④ 第二次删缓存 并发读线程可能写旧数据 💡 第二次删除会清除并发读线程在休眠期间写入缓存的旧数据 延迟时间需覆盖:读请求耗时 + 主从同步延迟 + 业务容忍窗口 ✅ 生产优化:第二次删除可异步执行(消息队列 / 延迟队列),避免写线程阻塞

4.4 方案对比与选型建议

方案 一致性保障 并发风险 实现复杂度 推荐场景
先更新 DB,再删除缓存 最终一致(有窗口) 高:读线程可能写旧数据 低 低并发、可接受短时不一致
延迟双删 最终一致(窗口可控) 中:延迟时间需精确调优 中 高并发、读写比例高
先删除缓存,再更新 DB 弱一致 高:写线程可能覆盖旧数据 低 不推荐生产使用

4.5 工程落地要点

  • 延迟时间动态化: 不要硬编码固定延迟值。建议通过配置中心(如 Nacos / Apollo)动态调整,结合业务监控(P99 读耗时、主从延迟指标)持续优化。
  • 异步化第二次删除: 使用消息队列(RocketMQ / RabbitMQ)或本地延迟队列(如 Netty HashedWheelTimer)执行第二次删除,避免写线程阻塞。
  • 删除失败补偿: 第二次删除也可能失败。建议配合 缓存过期时间兜底(TTL 设置为业务可接受的最大不一致时间,如 5 分钟),并记录删除失败日志,通过离线任务补偿。
  • 主从延迟场景: 若数据库存在主从架构,延迟时间需额外加上主从同步延迟(通常 100ms ~ 500ms)。
最佳实践: 延迟双删 + 缓存 TTL 兜底 + 异步补偿任务,三者组合可达到 99.99% 的缓存一致性保障。对于金融、交易等强一致场景,建议升级为“分布式锁 + 缓存更新”或“CDC 监听 binlog 同步缓存”方案。