线上复盘:DB 已更新,缓存也删了,为何用户读到的还是旧值?

8 阅读7分钟

案发时间:2025-12-22 凌晨 01:15 涉及组件:Redis, MySQL, Redisson 核心现象:Cache Aside 模式下的“脏数据回种” (Stale Data Re-injection) 复盘背景:在一次普通的商品改价发布中,我们偶发性地观察到了数据不一致现象。


一、 问题背景:一次“符合预期”的操作?

在处理高并发读写的业务场景中(如热点商品改价),我们团队内部默认遵循标准的 Cache Aside Pattern(旁路缓存模式)。其逻辑非常简单且经典:

  1. 写流程:先更新数据库,成功后删除缓存。
  2. 读流程:先查缓存;若未命中(Miss),则查数据库,并将结果回写到缓存中。

在绝大多数场景下,这套逻辑运行得非常稳定。但在最近一次压测(模拟大促流量)中,我们发现了一个反直觉的现象:

运营人员将商品价格从 ¥100 修改为 ¥10。监控日志显示,数据库更新成功,Redis 删除指令也执行成功。但在随后的几秒内,部分用户依然读取到了 ¥100 的旧价格,并且这个旧价格被重新写入了 Redis,导致更长时间的错误展示。

起初我们怀疑是代码逻辑漏洞或数据库主从延迟,但经过细致的日志比对,我们发现问题的根源隐藏在应用层的并发时序中。


二、 深度还原:毫秒级的“竞态条件”

为了解释这个现象,我们需要将时间轴放大到毫秒级别。这并非代码错误,而是在极高并发下, “读操作”的耗时窗口与**“写操作”的执行点**发生了不幸的重叠。

我们将这种现象称为**“脏数据回种”**。

让我们以此案例的时间轴为例:

  • 线程 A(读请求 - 用户访问)

    1. T1:查询 Redis,发现无数据(可能是过期了,也可能是刚好被删)。
    2. T2:查询 MySQL。关键点: 此时写请求尚未提交,线程 A 读到了 旧值 ¥100
    3. T3(此时发生了一次微小的 Full GC,或者网络出现短暂抖动,导致线程 A 暂停了 50ms)
  • 线程 B(写请求 - 运营改价)

    1. T4:更新 MySQL,将价格改为 ¥10
    2. T5:删除 Redis Key。
    3. (线程 B 任务完成,它认为数据已经是最新的了)
  • 线程 A(读请求 - 恢复运行)

    1. T6:线程 A 从暂停中恢复,手里依然握着 T2 时刻读到的 旧值 ¥100
    2. T7回写 Redis。它并不知道中间发生了什么,只是尽职地将 ¥100 写入缓存。

复盘结论: 问题的本质在于,线程 A 的**“查询 DB”“回写 Cache”**这两个动作不是原子的。在两个动作之间,插入了线程 B 的修改操作。导致线程 A 用一个“过期的真理”覆盖了“最新的事实”。


三、 方案探讨:关于“延时双删”的思考

在技术社区中,针对此类问题,大家常提到的方案是 “延时双删” (Delayed Double Delete) 。 即:更新DB -> 删缓存 -> Sleep(N) -> 再删缓存

这种方案在很多非核心业务中是可行的,但在高并发的核心链路(如交易、库存)中,我们可能需要更审慎地评估它的副作用:

  1. 时延的不确定性

    • 代码中的 Sleep(N) 很难设定一个精准值。它需要大于“读请求查库+网络传输+写缓存”的所有耗时,甚至还要考虑数据库主从同步的延迟(Replication Lag)。在网络波动的现实环境下,这往往是一个概率游戏。
  2. 吞吐量的损耗

    • 强制让写线程阻塞一段时间,会显著降低系统的写吞吐量。虽然可以使用 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;
    }
}

五、 局限性与反思

虽然引入读写锁解决了应用层的并发乱序问题,但作为工程师,我们必须诚实地面对架构中的物理局限性

问:这套方案能解决“数据库主从延迟”带来的问题吗?

答:不能完全解决。

如果架构是“主库写、从库读”,即使加了应用层的锁,流程依然可能是:

  1. 写线程:更新主库 -> 释放写锁。
  2. 读线程:获取读锁 -> 读取从库

如果此时主从同步尚未完成(Binlog 传输耗时),读线程依然会读到从库的旧值并写入缓存。

针对这种极端一致性要求的场景,我们可能需要做出更艰难的取舍:

  1. 强制读主(Force Master) :在 Cache Miss 时,强制去主库查询。但这会增加主库压力。
  2. 订阅 Binlog 兜底:使用 Canal 等工具监听 Binlog,一旦检测到变更,再次执行删除缓存的操作,作为最终一致性的保障。

结语

在分布式系统中,没有“银弹”。 Cache Aside 模式在 99% 的场景下足够优秀,而在那 1% 的极端并发下,我们需要权衡复杂度与一致性。读写锁提供了一种比“延时双删”更确定的控制手段,但也引入了锁的开销。

选择哪种方案,取决于业务对“脏数据”的容忍度,以及我们对系统复杂度的驾驭能力。