一、实际问题
在实际开发中,我们通常会用 Redis 作为缓存来加速数据读取。但当数据需要更新时,就面临一个经典问题:应该先删除缓存,还是先更新数据库?
遗憾的是,无论选择哪种顺序,在并发场景下都可能产生脏数据。

以"先删缓存,再更新数据库"为例,考虑以下时序:
- 线程 A 删除缓存
- 线程 B 读取缓存未命中,从数据库读到旧值,写入缓存
- 线程 A 更新数据库为新值
此时数据库中是新值,而缓存中却是线程 B 写入的旧值,数据不一致就此产生。反过来"先更新数据库,再删缓存"同样存在类似的竞态窗口。
由于线程调度的不确定性,这类问题在高并发下并非小概率事件。要解决它,常见的思路有两种:延迟双删 和读写锁,各有适用场景。
二、延迟双删
1. 删除 Redis 缓存 ← 第一次删
2. 更新数据库
3. 发送一条延时消息到 MQ或者Canal监听binlog日志
4. MQ 消费者收到消息后再次删除缓存 ← 第二次删
延迟双删的核心思想很简单:既然一次删除缓存无法保证一致性,那就删两次,中间给数据库更新留出时间窗口。
基本流程:
- 删除 Redis 缓存 ← 第一次删除
- 更新数据库
- 发送一条延迟消息到 MQ(或通过 Canal 监听 binlog)
- MQ 消费者收到消息后再次删除缓存 ← 第二次删除
第一次删除是为了让后续读请求能走到数据库;更新数据库后,再补一次删除,把可能在第一步和第二步之间被脏数据回填的缓存清理掉。
两种实现方式:
- MQ 延迟消息:业务代码中,更新数据库后向消息队列发送一条延迟消息,消费者到期后执行缓存删除。常用 RocketMQ 的延迟消息或 RabbitMQ 的 TTL + 死信队列来实现。
- Canal 监听 binlog :Canal 伪装为 MySQL 的从节点,通过读取 binlog 日志感知数据变更,自动触发缓存删除。这种方式的优势在于不需要修改业务代码,对应用层完全透明。
两个需要注意的点:
第一,第二步发送的应该是延迟消息而非立即删除。这是为了给数据库主从复制留出同步时间------如果主库刚写完、从库还没同步,此时删缓存后读请求打到从库,读到的可能还是旧值,缓存又会被旧数据回填。
第二,延迟双删并不能做到强一致,两次删除之间仍然存在一个短暂的不一致窗口。如果业务对一致性要求不高(比如缓存热点商品数据、文章浏览量等),这种方案完全够用,且实现成本低、性能影响小。
三、读写锁

如果对数据一致性有严格要求,延迟双删的短暂不一致窗口是不可接受的,这时可以考虑读写锁方案。
Redis 提供了两种锁的语义:
- 读锁(共享锁):多个线程可以同时持有读锁进行读操作,但读锁存在时,写操作必须等待。
- 写锁(排他锁):一个线程持有写锁后,其他线程的读操作和写操作全部阻塞。
使用方式:
- 读数据时加读锁,从缓存读取;缓存未命中则加写锁,从数据库加载并回填缓存。
- 写数据时加写锁,先更新数据库,再删除(或更新)缓存。
读写锁能够完全保证数据一致性,因为同一时刻要么只有读操作在并发执行,要么只有一个写操作在执行,从根本上消除了竞态条件。
代价是性能------写操作会阻塞所有读写请求,在读多写少的场景下影响尤为明显。因此,读写锁适合对一致性要求极高的业务,比如优惠券计算、订单交易、库存扣减等场景。
四、方案对比
| 延迟双删 | 读写锁 | |
|---|---|---|
| 一致性 | 最终一致,存在短暂窗口 | 强一致 |
| 性能 | 影响小,异步删除 | 写操作阻塞读写,性能下降明显 |
| 实现复杂度 | 中等(需引入 MQ 或 Canal) | 较低(Redis 原生支持) |
| 适用场景 | 热点数据、一致性要求不高的读多场景 | 交易、库存等强一致性场景 |
实际选型时,不需要非此即彼。同一个系统中,对不同业务模块可以根据一致性要求分别采用不同策略------大部分读场景用延迟双删,关键写场景用读写锁,这样能在一致性和性能之间取得较好的平衡。