案发时间:2025-12-22 凌晨 01:15 涉及组件:Redis, MySQL, Redisson 核心现象:Cache Aside 模式下的“脏数据回种” (Stale Data Re-injection) 复盘背景:在一次普通的商品改价发布中,我们偶发性地观察到了数据不一致现象。
一、 问题背景:一次“符合预期”的操作?
在处理高并发读写的业务场景中(如热点商品改价),我们团队内部默认遵循标准的 Cache Aside Pattern(旁路缓存模式)。其逻辑非常简单且经典:
- 写流程:先更新数据库,成功后删除缓存。
- 读流程:先查缓存;若未命中(Miss),则查数据库,并将结果回写到缓存中。
在绝大多数场景下,这套逻辑运行得非常稳定。但在最近一次压测(模拟大促流量)中,我们发现了一个反直觉的现象:
运营人员将商品价格从 ¥100 修改为 ¥10。监控日志显示,数据库更新成功,Redis 删除指令也执行成功。但在随后的几秒内,部分用户依然读取到了 ¥100 的旧价格,并且这个旧价格被重新写入了 Redis,导致更长时间的错误展示。
起初我们怀疑是代码逻辑漏洞或数据库主从延迟,但经过细致的日志比对,我们发现问题的根源隐藏在应用层的并发时序中。
二、 深度还原:毫秒级的“竞态条件”
为了解释这个现象,我们需要将时间轴放大到毫秒级别。这并非代码错误,而是在极高并发下, “读操作”的耗时窗口与**“写操作”的执行点**发生了不幸的重叠。
我们将这种现象称为**“脏数据回种”**。
让我们以此案例的时间轴为例:
-
线程 A(读请求 - 用户访问) :
T1:查询 Redis,发现无数据(可能是过期了,也可能是刚好被删)。T2:查询 MySQL。关键点: 此时写请求尚未提交,线程 A 读到了 旧值 ¥100。T3: (此时发生了一次微小的 Full GC,或者网络出现短暂抖动,导致线程 A 暂停了 50ms) 。
-
线程 B(写请求 - 运营改价) :
T4:更新 MySQL,将价格改为 ¥10。T5:删除 Redis Key。- (线程 B 任务完成,它认为数据已经是最新的了) 。
-
线程 A(读请求 - 恢复运行) :
T6:线程 A 从暂停中恢复,手里依然握着T2时刻读到的 旧值 ¥100。T7:回写 Redis。它并不知道中间发生了什么,只是尽职地将 ¥100 写入缓存。
复盘结论: 问题的本质在于,线程 A 的**“查询 DB”和“回写 Cache”**这两个动作不是原子的。在两个动作之间,插入了线程 B 的修改操作。导致线程 A 用一个“过期的真理”覆盖了“最新的事实”。
三、 方案探讨:关于“延时双删”的思考
在技术社区中,针对此类问题,大家常提到的方案是 “延时双删” (Delayed Double Delete) 。 即:更新DB -> 删缓存 -> Sleep(N) -> 再删缓存。
这种方案在很多非核心业务中是可行的,但在高并发的核心链路(如交易、库存)中,我们可能需要更审慎地评估它的副作用:
-
时延的不确定性:
- 代码中的
Sleep(N)很难设定一个精准值。它需要大于“读请求查库+网络传输+写缓存”的所有耗时,甚至还要考虑数据库主从同步的延迟(Replication Lag)。在网络波动的现实环境下,这往往是一个概率游戏。
- 代码中的
-
吞吐量的损耗:
- 强制让写线程阻塞一段时间,会显著降低系统的写吞吐量。虽然可以使用 MQ 异步删除来缓解,但这又引入了新的组件依赖和复杂性(例如 MQ 延迟或丢失)。
因此,我们倾向于认为:延时双删更多是一种低成本的“缓解”手段,而非严谨的“解决”方案。
四、 改进尝试:引入读写锁 (ReadWriteLock)
如果我们希望彻底避免“脏数据回种”,核心思路是:在“缓存重建”的过程中,应该感知并互斥掉“写操作”。
但我们不能简单地使用互斥锁(Mutex),因为缓存场景是典型的“读多写少”。如果所有读请求都互斥,系统的性能将退化为数据库的性能。
利用 Redisson 提供的分布式读写锁 RReadWriteLock,我们可以尝试建立一种更精细的协作机制:
- 普通读(缓存命中) :不加锁,保持 Redis 的高性能(99% 的流量走这里)。
- 缓存重建(Cache Miss) :加读锁。允许多个线程并行读取(防止击穿),但阻塞写线程。
- 数据修改:加写锁。确保在修改期间,没有线程能构建脏缓存。
🛠️ 生产级代码参考
这段代码展示了我们目前的实践思路。请注意其中的 Double Check Locking (DCL) 设计,以及为了防止雪崩而加入的随机 TTL。
Java
@Component
public class ProductPriceService {
@Resource
private RedissonClient redisson;
@Resource
private ProductMapper productMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
private static final String LOCK_PREFIX = "lock:product:";
/**
* 写操作:更新价格
* 目标:在更新期间,暂时阻断缓存的构建,保证数据落地
*/
public void updateProductPrice(Long id, BigDecimal newPrice) {
RReadWriteLock rwLock = redisson.getReadWriteLock(LOCK_PREFIX + id);
RLock writeLock = rwLock.writeLock();
// 加写锁
// 此时,任何试图重建缓存的读线程都会被阻塞,直到我们完成更新
writeLock.lock();
try {
// 1. 更新数据库
// 注意:此处尽量避免使用 @Transactional 包裹整个方法,
// 否则可能出现“锁释放了但事务还没提交”的边缘Case(详见案卷 No.01)
productMapper.updatePrice(id, newPrice);
// 2. 删除缓存
redisTemplate.delete("product:" + id);
} finally {
writeLock.unlock();
}
}
/**
* 读操作:查询价格
* 目标:仅在缓存未命中时加锁,兼顾性能与一致性
*/
public Product getProduct(Long id) {
String cacheKey = "product:" + id;
// 1. 快速路径:命中缓存直接返回,无锁消耗
Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) return product;
// 2. 慢速路径:缓存未命中,准备重建
RReadWriteLock rwLock = redisson.getReadWriteLock(LOCK_PREFIX + id);
RLock readLock = rwLock.readLock();
// 加读锁
// 多个读线程可以同时持有读锁(并行查库),但会阻塞写锁
readLock.lock();
try {
// 3. 双重检查 (DCL)
// 必不可少:防止在等待锁的过程中,已有其他线程完成了缓存重建
product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) return product;
// 4. 查询数据库
// 此时持有读锁,意味着没有写锁在执行,DB 数据相对稳定
// 建议:对于核心业务,此处推荐强制读取主库 (Force Master) 以避免主从延迟
product = productMapper.selectById(id);
// 5. 回写缓存
if (product != null) {
// 细节:设置随机过期时间(例如 1小时 + 随机N秒),预防缓存雪崩
redisTemplate.opsForValue().set(cacheKey, product,
3600 + new Random().nextInt(600), TimeUnit.SECONDS);
}
} finally {
readLock.unlock();
}
return product;
}
}
五、 局限性与反思
虽然引入读写锁解决了应用层的并发乱序问题,但作为工程师,我们必须诚实地面对架构中的物理局限性:
问:这套方案能解决“数据库主从延迟”带来的问题吗?
答:不能完全解决。
如果架构是“主库写、从库读”,即使加了应用层的锁,流程依然可能是:
- 写线程:更新主库 -> 释放写锁。
- 读线程:获取读锁 -> 读取从库。
如果此时主从同步尚未完成(Binlog 传输耗时),读线程依然会读到从库的旧值并写入缓存。
针对这种极端一致性要求的场景,我们可能需要做出更艰难的取舍:
- 强制读主(Force Master) :在 Cache Miss 时,强制去主库查询。但这会增加主库压力。
- 订阅 Binlog 兜底:使用 Canal 等工具监听 Binlog,一旦检测到变更,再次执行删除缓存的操作,作为最终一致性的保障。
结语
在分布式系统中,没有“银弹”。 Cache Aside 模式在 99% 的场景下足够优秀,而在那 1% 的极端并发下,我们需要权衡复杂度与一致性。读写锁提供了一种比“延时双删”更确定的控制手段,但也引入了锁的开销。
选择哪种方案,取决于业务对“脏数据”的容忍度,以及我们对系统复杂度的驾驭能力。