4. 双写一致性挑战:先更新数据库再删缓存 vs 先删缓存再更新数据库、延迟双删策略的原理与实现
在读写缓存并存的架构中,数据库与缓存之间的数据不一致是引发线上故障的头号杀手。本章聚焦于“双写一致性”这一核心难题,深入剖析两种主流更新顺序的差异,并给出延迟双删这一经典补偿方案的完整实现。
4.1 双写不一致的根源
当业务对同一份数据同时进行读(从缓存)和写(到数据库)操作时,由于数据库与缓存是两个独立的存储系统,无法通过单机事务保证原子性,必然存在时间窗口内的数据不一致。不一致的严重程度取决于:
- 更新顺序:先操作数据库还是先操作缓存。
- 并发读写:读请求是否在写请求的中间状态命中缓存。
- 缓存失效时机:删除操作与数据库提交之间的延迟。
核心原则:缓存一致性追求的是“最终一致”,而非强一致。我们的目标是将不一致的时间窗口压缩到业务可接受的范围(通常 < 100ms)。
4.2 先更新数据库再删缓存(Cache-Aside 模式)
这是业界最推荐的方案,也是 Redis + MySQL 组合的默认实践。流程如下:
- 写请求更新数据库。
- 写请求删除缓存(或标记失效)。
- 读请求发现缓存未命中,回查数据库,将新数据写入缓存。
优点:
- 缓存中始终是“最新”数据(因为旧数据被删除了)。
- 对读多写少的场景友好,缓存命中率高。
风险点:
- 如果步骤2(删缓存)失败,缓存中会一直保留旧数据,导致后续所有读请求都读到脏数据。
- 极端并发下:线程A更新数据库(旧值→新值),线程B在A删除缓存前读取缓存(命中旧值),然后A删除缓存,B将旧值写回缓存(因为B回查数据库时可能读到旧值)。
注意:上述极端并发发生的概率极低,因为需要满足“读请求在写请求删除缓存之前完成回查并写缓存”这一严格时序。但在高并发场景下仍可能发生,需要配合延迟双删或版本号机制兜底。
4.3 先删缓存再更新数据库
这种顺序试图通过“先让缓存失效”来避免读请求命中旧数据,但引入了新的问题:
- 写请求删除缓存。
- 写请求更新数据库。
- 在步骤1和2之间,读请求发现缓存未命中,回查数据库(此时数据库还是旧值),将旧值写入缓存。
- 写请求完成数据库更新,但缓存中已经是旧值。
结果:缓存中永久保留了旧数据,直到下一次缓存失效或手动清除。这种方案在实际生产中被认为风险更高,通常不推荐单独使用。
4.4 两种方案对比
| 对比维度 | 先更新 DB 再删缓存 | 先删缓存再更新 DB |
|---|---|---|
| 不一致窗口 | 极小(删缓存失败时窗口变大) | 较大(并发读可能写回旧值) |
| 脏数据概率 | 低(依赖删除操作成功) | 高(并发场景下极易出现) |
| 对删除失败的容忍度 | 需要重试或补偿机制 | 需要更复杂的版本控制 |
| 推荐度 | ⭐️⭐️⭐️⭐️⭐️(首选) | ⭐️⭐️(不推荐单独使用) |
4.5 延迟双删策略的原理与实现
延迟双删(Delayed Double Delete)是针对“先更新数据库再删缓存”方案的增强补偿机制,用于解决删除缓存失败或并发写回旧值的问题。
4.5.1 核心流程
- 第一次删除:写请求在更新数据库之前,先删除一次缓存(可选步骤,用于提前失效)。
- 更新数据库:执行数据库写操作。
- 延迟删除:在数据库更新完成后,等待一个固定的延迟时间(通常为几百毫秒),再次删除缓存。
延迟时间的设定原则:必须大于“读请求回查数据库并写回缓存”的最大耗时。一般建议设置为 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 延迟双删的架构流程
下图展示了延迟双删策略在并发读写场景下的完整执行时序:
4.7 生产环境最佳实践
- 优先选择“先更新数据库再删缓存”作为基础方案,配合延迟双删作为补偿。
- 删除缓存必须保证最终成功:使用消息队列或本地重试表,将删除操作异步化,确保即使 Redis 临时故障也能重试。
- 延迟时间动态化:根据业务接口的实时 P99 延迟动态调整延迟时间,避免固定值带来的覆盖不足。
- 结合版本号或时间戳:在缓存中存储数据的版本号或最后更新时间,写回缓存时校验版本,避免旧值覆盖新值。
总结:双写一致性没有银弹。延迟双删是工程上最实用的补偿方案,但必须配合可靠的删除重试机制和合理的延迟时间设定,才能将不一致窗口压缩到业务可接受的范围。