缓存一致性常被简化成一句“先更新数据库,再删除缓存”。这句话通常是合理默认值,但它不是数学上的强一致,也无法独立解决删除失败、旧值回填、主从延迟和并发写覆盖。

一、先建立模型:你需要的到底是哪种一致性

大多数业务缓存追求的是最终一致:数据库是事实源,缓存允许在短窗口内陈旧,但必须能自动收敛。真正要求读后写强一致的场景,应考虑绕过缓存、版本校验或在同一串行化边界内处理,而不是不断叠加“双删”。

read:  cache hit → return
       cache miss → query DB → fill cache → return

write: update DB → transaction commit → delete cache

二、三个必须会画时间线的竞态

竞态 A:先删缓存,再更新数据库

写线程删除缓存后尚未提交数据库,读线程 miss,读取旧数据库值并回填缓存;随后写线程提交,新数据库值与旧缓存长期不一致。因此“先删后改”不适合作为默认顺序。

竞态 B:先更新数据库,再删缓存,但删除失败

顺序正确也可能因为网络抖动、Redis 超时或进程崩溃导致删除失败。此时旧缓存会一直存在,除非 TTL 到期或有可靠重试。删除动作必须被当成可失败的外部副作用。

竞态 C:缓存 miss 的慢查询覆盖新值

读线程先查到旧值,但回填很慢;写线程已更新数据库并删除缓存;读线程最后把旧值写回。这是“旧值回填”问题,发生概率低,但在慢查询、热点键或大对象序列化时会放大。

三、常见策略分别解决什么

策略解决的问题局限
更新 DB 后删缓存降低先删后改的旧值回填概率删除仍可能失败
TTL保证错误不会永久存在只能限制不一致上界
删除重试 / 事务消息处理删除失败需要幂等和死信治理
延迟双删降低并发读回填旧值概率延迟时间难精确,不能替代可靠重试
Binlog / CDC把缓存失效从业务事务中解耦存在消费延迟和运维成本
版本号 / CAS阻止旧版本覆盖新版本读写协议更复杂

常规业务可采用“事务提交后删除 + TTL + 删除失败重试”。热点且不能容忍旧值回填时,再增加逻辑版本:只有待写入版本不小于当前缓存版本才允许覆盖。

// 伪代码:数据库提交成功后投递失效事件
@Transactional
public void updateOrder(Order order) {
    orderRepository.update(order);
    outboxRepository.append("ORDER_CACHE_EVICT", order.getId());
}

// 消费者重复执行 delete 是安全的
public void consume(CacheEvictEvent event) {
    redis.delete("order:" + event.id());
}

四、工程兜底:从正确顺序升级为可运营系统

  1. 事务后执行:缓存删除必须在数据库提交后触发,避免事务回滚却提前失效。
  2. 可重试:使用本地消息表、事务消息或 CDC,记录失败次数与最后错误。
  3. 可观测:监控缓存删除失败率、重试积压、热点键命中率与 DB 回源量。
  4. 防击穿:热点键失效时使用单飞、互斥锁或逻辑过期,避免所有请求同时回源。
  5. 限制缓存价值:不要把强一致核心数据只放在缓存中;缓存应该是派生数据。

五、面试或方案评审时怎么表达

我们的事实源是数据库,目标是最终一致。写路径在事务提交后删除缓存,避免先删后改导致旧值回填;删除失败通过可靠事件重试,TTL 限制最坏不一致窗口。热点键再配合单飞和版本校验。若业务要求读后写强一致,关键读直接走主库或带版本读取,不把缓存方案包装成强一致。

这套表达的关键是:先说一致性目标,再说主流程,然后主动暴露竞态和兜底,而不是背出“延迟双删”四个字。