4. 先更新数据库再删除缓存:经典方案原理、并发问题与延迟双删
“先更新数据库,再删除缓存”是业界最经典的缓存一致性治理方案之一。该方案的核心思想是:以数据库为权威数据源(Source of Truth),缓存仅作为加速层。更新操作完成后,主动让缓存失效,下次读取时再从数据库加载最新数据。
4.1 方案原理与执行流程
该方案的标准执行步骤分为写入与读取两条路径:
| 操作路径 | 步骤 | 说明 |
|---|---|---|
| 写请求 | ① 更新数据库 | 将最新数据写入 MySQL / PostgreSQL 等数据库 |
| ② 删除缓存 | 删除 Redis / Memcached 中对应的 key | |
| 读请求 | ① 查询缓存 | 若缓存命中,直接返回 |
| ② 缓存未命中,查询数据库 | 从数据库读取最新数据 | |
| ③ 回写缓存 | 将数据库结果写入缓存,设置过期时间 |
设计意图: 删除缓存而非更新缓存,是为了避免并发写场景下的“写写覆盖”问题。删除操作天然幂等,且能迫使后续读请求重新加载数据,保证最终一致性。
4.2 经典并发问题:A 删缓存,B 读旧数据写缓存
尽管方案简洁,但在高并发场景下存在一个经典的时序竞态问题,导致缓存中长时间驻留旧数据:
- 写线程 A 更新数据库(将值从 V1 改为 V2)。
- 写线程 A 删除缓存(此时缓存为空)。
- 读线程 B 发起读请求,缓存未命中。
- 读线程 B 查询数据库,但此时数据库尚未完成事务提交(或主从延迟),读到旧值 V1。
- 读线程 B 将 V1 写入缓存。
- 此后所有读请求都命中缓存中的 V1,直到缓存过期,造成数据不一致。
核心矛盾: 删除缓存与读请求回写缓存之间存在“时间窗口”。在这个窗口内,读请求可能读到旧数据并回填,导致缓存被“污染”。该问题在读写并发量级超过 1000 QPS 时尤为突出。
4.3 解决方案:延迟双删(Delayed Double Delete)
延迟双删是针对上述并发问题最成熟的工程化解决方案。其核心思路是:在第一次删除缓存后,等待一段时间,再次删除缓存,以覆盖并发读请求可能写入的旧数据。
4.3.1 执行流程
- 写线程 A 更新数据库。
- 写线程 A 第一次删除缓存。
- 写线程 A 休眠一段时间(延迟时间)。
- 写线程 A 第二次删除缓存。
延迟时间估算公式:
生产环境通常取 500ms ~ 1500ms,具体需根据实际监控数据调整。
delay > 读请求平均耗时 + 数据库主从同步延迟 + 业务容忍的脏数据窗口生产环境通常取 500ms ~ 1500ms,具体需根据实际监控数据调整。
4.4 方案对比与选型建议
| 方案 | 一致性保障 | 并发风险 | 实现复杂度 | 推荐场景 |
|---|---|---|---|---|
| 先更新 DB,再删除缓存 | 最终一致(有窗口) | 高:读线程可能写旧数据 | 低 | 低并发、可接受短时不一致 |
| 延迟双删 | 最终一致(窗口可控) | 中:延迟时间需精确调优 | 中 | 高并发、读写比例高 |
| 先删除缓存,再更新 DB | 弱一致 | 高:写线程可能覆盖旧数据 | 低 | 不推荐生产使用 |
4.5 工程落地要点
- 延迟时间动态化: 不要硬编码固定延迟值。建议通过配置中心(如 Nacos / Apollo)动态调整,结合业务监控(P99 读耗时、主从延迟指标)持续优化。
- 异步化第二次删除: 使用消息队列(RocketMQ / RabbitMQ)或本地延迟队列(如 Netty HashedWheelTimer)执行第二次删除,避免写线程阻塞。
- 删除失败补偿: 第二次删除也可能失败。建议配合 缓存过期时间兜底(TTL 设置为业务可接受的最大不一致时间,如 5 分钟),并记录删除失败日志,通过离线任务补偿。
- 主从延迟场景: 若数据库存在主从架构,延迟时间需额外加上主从同步延迟(通常 100ms ~ 500ms)。
最佳实践: 延迟双删 + 缓存 TTL 兜底 + 异步补偿任务,三者组合可达到 99.99% 的缓存一致性保障。对于金融、交易等强一致场景,建议升级为“分布式锁 + 缓存更新”或“CDC 监听 binlog 同步缓存”方案。