在使用Redis作为MySQL前置缓存的架构中,当数据发生变更时,究竟是先删缓存再更新数据库,还是先更新数据库再删缓存,这个看似简单的问题背后隐藏着并发时序的复杂逻辑。选错了策略,轻则出现短暂脏数据,重则缓存长期与数据库不一致,直接影响业务正确性。本文将围绕先删后更新这一经典策略展开分析,讲清楚它的适用场景、致命缺陷以及补救方案。

先删后更新策略的基本原理
先删后更新,指的是在数据发生修改时,先执行DEL删除Redis中对应的缓存key,然后再去更新数据库。这种策略的思想是:删除缓存后,后续的读请求发现缓存未命中,就会回源到数据库读取最新值并重新写入缓存,从而保证缓存数据最终与数据库一致。
典型的代码实现如下:
public void updateProduct(Product product) {
String key = "product:" + product.getId();
// 第一步:先删除缓存
redisTemplate.delete(key);
// 第二步:再更新数据库
productMapper.updateById(product);
}这种方案最大的优点是实现简单,不需要考虑缓存写入失败的问题。因为删除操作是幂等的,即使删除两次也不会有副作用。而且它避免了先更新数据库场景下,缓存更新失败导致的脏数据长期存在的问题。正因如此,很多中小型项目在初期都会采用这种方式。
并发场景下的脏读陷阱
然而先删后更新存在一个明显的并发缺陷。考虑这样一个时序:线程A执行更新操作,先删除了缓存,接着开始更新数据库。就在数据库还没更新完成的时候,线程B发起了一次读请求,发现缓存未命中,于是去数据库读数据。此时读到的是更新前的旧值,接着线程B把这个旧值写回了缓存。等线程A把数据库更新完成后,缓存里留着的仍然是旧值,而数据库已经是新值,两者出现了不一致。只要这个key没有设置过期时间,这个脏数据就会一直存在。
用一个时间线来梳理这个过程:
时刻1:线程A 删除缓存 缓存 miss 时刻2:线程B 查询缓存未命中 时刻3:线程B 查询数据库,得到旧值 时刻4:线程B 将旧值写入缓存 时刻5:线程A 更新数据库为新值 结果:缓存 = 旧值,数据库 = 新值,不一致!
这个问题在读写并发量大的场景下出现概率显著上升,特别是读请求回源数据库本身就有耗时,写回缓存的窗口期并不短。要缓解这个问题,一方面可以给所有缓存key都设置合理的过期时间作为兜底,即使出现脏数据也能在过期后自愈;另一方面可以采用延迟双删来主动修复。
延迟双删方案详解
延迟双删是在先删后更新基础上演进而来的方案:在更新前删除一次缓存,更新数据库后,再等待一小段时间进行第二次删除。第二次删除的目的,就是把并发读请求可能写入的旧值清掉,从而保证后续读请求能拿到最新数据。
实现代码大致如下:
public void updateWithDoubleDelete(Product product) {
String key = "product:" + product.getId();
// 第一次删除缓存
redisTemplate.delete(key);
// 更新数据库
productMapper.updateById(product);
// 延迟一段时间后二次删除(异步执行,避免阻塞主流程)
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(500); // 延迟时间需大于一次读请求的耗时
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
redisTemplate.delete(key);
}, executor);
}延迟时间的选择是关键:必须大于一次读请求数据库查询加写回缓存的耗时,通常设置几百毫秒到一秒。如果延迟过短,第二次删除发生在读请求写回之前,脏数据仍然会残留;如果延迟过长,又会拉长不一致的时间窗口。此外,如果服务是集群部署,删除操作最好通过消息队列广播到所有实例,或者直接在代码中异步执行,避免因为请求路由到不同节点导致第二次删除丢失。
先更新数据库后删缓存对比分析
与先删后更新相对的,是经典的Cache Aside模式中的先更新数据库再删缓存。理论上它也存在不一致的可能:读请求未命中缓存后查到旧值,写回缓存前,写请求完成了数据库更新和缓存删除,随后旧值被写回。但这个时序要求读请求在缓存未命中后、写回缓存前发生一次完整的写操作,概率远低于先删后更新的场景。
两种方案的对比可以总结如下:
| 对比维度 | 先删后更新 | 先更新后删(Cache Aside) |
|---|---|---|
| 脏数据概率 | 较高,读回写窗口大 | 较低,时序条件苛刻 |
| 实现复杂度 | 简单 | 简单,但需处理删除失败 |
| 缓存击穿风险 | 删除后读集中回源 | 相对较小 |
| 推荐场景 | 低并发、可配延迟双删 | 大多数常规读写场景 |
需要注意的是,先更新后删也存在删除失败的问题。如果数据库更新成功但缓存删除失败,同样会出现不一致。对此可以引入重试机制,例如把删除失败的key写入消息队列异步重试,或者更彻底的做法是通过Canal订阅MySQL的Binlog,在数据真正落库后由独立服务负责删除缓存,彻底解耦业务代码与缓存维护逻辑。结合过期时间兜底,即使极端情况下重试也失败,脏数据也会在过期后被清理。
实践中的选型建议
综合来看,没有绝对完美的缓存一致性方案,只有适合业务场景的方案。对于读多写少的常规业务,优先采用先更新数据库再删缓存的Cache Aside模式,并给所有key设置过期时间作为最终兜底。对于已经采用先删后更新的系统,务必升级为延迟双删,并根据线上读接口的耗时统计数据调整延迟阈值。对一致性要求极高的场景,如库存、账户余额等,则应该考虑引入分布式锁串行化读写,或者基于Binlog的异步删除方案,甚至接受短暂不一致但通过版本号校验来拒绝过期数据。
最后提醒一点,无论选择哪种策略,监控都不可缺席。建议在系统中埋点统计缓存与数据库不一致的探测指标,例如定期抽样比对热点key的值,一旦发现不一致率上升,能够及时定位是延迟参数不合理还是删除操作失败,让缓存一致性处于可观测、可调优的状态。