4. 双写一致性挑战:先更新数据库再删缓存 vs 先删缓存再更新数据库、延迟双删策略的原理与实现

在读写缓存并存的架构中,数据库与缓存之间的数据不一致是引发线上故障的头号杀手。本章聚焦于“双写一致性”这一核心难题,深入剖析两种主流更新顺序的差异,并给出延迟双删这一经典补偿方案的完整实现。

4.1 双写不一致的根源

当业务对同一份数据同时进行读(从缓存)和写(到数据库)操作时,由于数据库与缓存是两个独立的存储系统,无法通过单机事务保证原子性,必然存在时间窗口内的数据不一致。不一致的严重程度取决于:

  • 更新顺序:先操作数据库还是先操作缓存。
  • 并发读写:读请求是否在写请求的中间状态命中缓存。
  • 缓存失效时机:删除操作与数据库提交之间的延迟。
核心原则:缓存一致性追求的是“最终一致”,而非强一致。我们的目标是将不一致的时间窗口压缩到业务可接受的范围(通常 < 100ms)。

4.2 先更新数据库再删缓存(Cache-Aside 模式)

这是业界最推荐的方案,也是 Redis + MySQL 组合的默认实践。流程如下:

  1. 写请求更新数据库。
  2. 写请求删除缓存(或标记失效)。
  3. 读请求发现缓存未命中,回查数据库,将新数据写入缓存。

优点:

  • 缓存中始终是“最新”数据(因为旧数据被删除了)。
  • 对读多写少的场景友好,缓存命中率高。

风险点:

  • 如果步骤2(删缓存)失败,缓存中会一直保留旧数据,导致后续所有读请求都读到脏数据。
  • 极端并发下:线程A更新数据库(旧值→新值),线程B在A删除缓存前读取缓存(命中旧值),然后A删除缓存,B将旧值写回缓存(因为B回查数据库时可能读到旧值)。
注意:上述极端并发发生的概率极低,因为需要满足“读请求在写请求删除缓存之前完成回查并写缓存”这一严格时序。但在高并发场景下仍可能发生,需要配合延迟双删或版本号机制兜底。

4.3 先删缓存再更新数据库

这种顺序试图通过“先让缓存失效”来避免读请求命中旧数据,但引入了新的问题:

  1. 写请求删除缓存。
  2. 写请求更新数据库。
  3. 在步骤1和2之间,读请求发现缓存未命中,回查数据库(此时数据库还是旧值),将旧值写入缓存。
  4. 写请求完成数据库更新,但缓存中已经是旧值。

结果:缓存中永久保留了旧数据,直到下一次缓存失效或手动清除。这种方案在实际生产中被认为风险更高,通常不推荐单独使用。

4.4 两种方案对比

对比维度 先更新 DB 再删缓存 先删缓存再更新 DB
不一致窗口 极小(删缓存失败时窗口变大) 较大(并发读可能写回旧值)
脏数据概率 低(依赖删除操作成功) 高(并发场景下极易出现)
对删除失败的容忍度 需要重试或补偿机制 需要更复杂的版本控制
推荐度 ⭐️⭐️⭐️⭐️⭐️(首选) ⭐️⭐️(不推荐单独使用)

4.5 延迟双删策略的原理与实现

延迟双删(Delayed Double Delete)是针对“先更新数据库再删缓存”方案的增强补偿机制,用于解决删除缓存失败或并发写回旧值的问题。

4.5.1 核心流程

  1. 第一次删除:写请求在更新数据库之前,先删除一次缓存(可选步骤,用于提前失效)。
  2. 更新数据库:执行数据库写操作。
  3. 延迟删除:在数据库更新完成后,等待一个固定的延迟时间(通常为几百毫秒),再次删除缓存。

延迟时间的设定原则:必须大于“读请求回查数据库并写回缓存”的最大耗时。一般建议设置为 500ms ~ 1s,具体需根据业务接口的 P99 延迟调整。

为什么需要延迟? 假设线程A更新数据库后立即删除缓存,但此时线程B正在回查数据库(可能读到旧值)并准备写回缓存。如果A的删除操作在线程B写回之前完成,则B仍然会将旧值写入缓存。延迟删除确保在线程B完成写回之后,再次删除缓存,从而清除这个“残留的旧值”。

4.5.2 实现示例(Java + Redis + MySQL)

public void updateWithDelayedDoubleDelete(String key, String newValue) {
    // 1. 第一次删除缓存(可选,用于提前失效)
    redisTemplate.delete(key);

    // 2. 更新数据库
    database.update(key, newValue);

    // 3. 提交事务后,异步执行延迟删除
    executorService.schedule(() -> {
        redisTemplate.delete(key);
        log.info("延迟双删完成,key: {}", key);
    }, 500, TimeUnit.MILLISECONDS);
}

4.5.3 延迟双删的局限性

  • 延迟时间难以精确:如果业务接口的 P99 延迟波动较大,固定延迟可能无法覆盖所有场景。
  • 删除操作本身可能失败:延迟删除仍然可能因网络或 Redis 故障而失败,需要配合重试机制。
  • 增加写请求的响应时间:虽然延迟删除是异步的,但调度任务仍会占用线程资源。

4.6 延迟双删的架构流程

下图展示了延迟双删策略在并发读写场景下的完整执行时序:

延迟双删策略执行时序图 延迟双删策略执行时序 写请求线程 读请求线程 缓存服务 第1次删缓存 更新数据库 延迟等待 读缓存(未命中) 回查数据库 写回旧值到缓存 第2次删缓存 关键点 延迟时间必须大于“读请求回查数据库 + 写回缓存”的总耗时 第二次删除确保清除读请求可能写回的旧值

4.7 生产环境最佳实践

  • 优先选择“先更新数据库再删缓存”作为基础方案,配合延迟双删作为补偿。
  • 删除缓存必须保证最终成功:使用消息队列或本地重试表,将删除操作异步化,确保即使 Redis 临时故障也能重试。
  • 延迟时间动态化:根据业务接口的实时 P99 延迟动态调整延迟时间,避免固定值带来的覆盖不足。
  • 结合版本号或时间戳:在缓存中存储数据的版本号或最后更新时间,写回缓存时校验版本,避免旧值覆盖新值。
总结:双写一致性没有银弹。延迟双删是工程上最实用的补偿方案,但必须配合可靠的删除重试机制和合理的延迟时间设定,才能将不一致窗口压缩到业务可接受的范围。