延迟双删策略:为什么需要“延迟”这一下?

延迟双删,这个名字听起来有点绕。说白了就是:先删缓存,再更新数据库,等一会儿,再删一次缓存

我第一次看到这个方案时,心里也犯嘀咕:删两次?还带延迟?这不是脱裤子放屁吗?

后来在秒杀场景里栽了跟头,才明白——这一下延迟,救了多少数据一致性

什么是延迟双删?

先看流程,我画了一张图,帮你快速理解:

延迟双删策略流程图 1. 写请求到达 2. 第一次删除缓存 3. 更新数据库 4. 延迟 N 毫秒 5. 第二次删除缓存 关键:延迟时间 > 读请求耗时 确保旧数据已被读请求写入缓存

流程很简单,但核心问题来了:为什么需要延迟?

为什么需要延迟?

我直接说结论:延迟是为了干掉“脏读”

你想想看,第一次删除缓存后,数据库还没更新完。这时候如果来了一个读请求,发现缓存里没数据,就会去数据库读。读到的还是旧数据,然后写回缓存。

等数据库更新完了,缓存里存的却是旧数据。这不就脏了吗?

延迟的作用就是:等所有可能读到旧数据的读请求都完成,再删一次。这样缓存里就只剩新数据了。

核心公式:

延迟时间 > 读请求从缓存穿透到数据库再写回缓存的总耗时

秒杀场景下的实战配置

秒杀场景有个特点:读多写少,瞬间并发极高。库存数据一旦不一致,后果很严重。

我在项目中遇到过一个问题:某次秒杀活动,库存显示还有10件,实际已经卖光了。用户下单后提示“库存不足”,体验极差。查了半天,就是缓存一致性没处理好。

后来我用了延迟双删,配置如下:

参数 推荐值 说明
延迟时间 500ms - 1000ms 根据业务读耗时调整,秒杀场景建议 800ms
重试次数 3 次 第二次删除失败时重试,防止漏删
缓存过期时间 30s - 60s 兜底策略,即使双删失败,过期后也能自动恢复
删除方式 异步删除 避免阻塞主线程,用消息队列或线程池

我的经验:延迟时间不要设死。我习惯用动态计算的方式:记录最近100次读请求的平均耗时,乘以1.5倍作为延迟时间。这样能自适应业务变化。

代码实现

直接上代码。这是我在秒杀项目中用过的实现,做了简化,但核心逻辑不变:

@Service
public class StockCacheService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private StockMapper stockMapper;

    @Autowired
    private ThreadPoolExecutor asyncExecutor;

    // 延迟时间,单位毫秒
    private static final long DELAY_MS = 800;

    // 最大重试次数
    private static final int MAX_RETRY = 3;

    /**
     * 更新库存 - 延迟双删
     */
    public void updateStock(Long skuId, Integer newStock) {
        String cacheKey = "stock:" + skuId;

        // 第一步:删除缓存
        redisTemplate.delete(cacheKey);

        // 第二步:更新数据库
        stockMapper.updateStock(skuId, newStock);

        // 第三步:延迟后再次删除缓存
        asyncExecutor.submit(() -> {
            try {
                Thread.sleep(DELAY_MS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            retryDeleteCache(cacheKey, 0);
        });
    }

    /**
     * 带重试的缓存删除
     */
    private void retryDeleteCache(String key, int retryCount) {
        try {
            redisTemplate.delete(key);
            // 删除成功,记录日志
            log.info("缓存删除成功, key: {}", key);
        } catch (Exception e) {
            log.error("缓存删除失败, key: {}, retry: {}", key, retryCount, e);
            if (retryCount < MAX_RETRY) {
                // 重试,每次间隔 200ms
                try {
                    Thread.sleep(200);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                }
                retryDeleteCache(key, retryCount + 1);
            } else {
                // 重试耗尽,发送告警
                log.warn("缓存删除重试耗尽, key: {}, 请人工处理", key);
                // 这里可以接入告警系统
            }
        }
    }
}

注意:第二次删除一定要异步执行。我曾经见过有人直接在业务线程里 sleep,结果接口响应时间飙升到 1 秒以上,秒杀直接崩了。

避坑指南

我踩过的坑,列出来给你参考:

  • 延迟时间设太短:我曾经设了 200ms,结果读请求还没写完缓存,第二次删除就执行了。等于白删。
  • 忘记兜底策略:延迟双删不是万能的。如果第二次删除失败,缓存就永远脏了。一定要配合缓存过期时间。
  • 删除操作不加锁:高并发下,两次删除之间可能插入新的写请求。建议用分布式锁控制同一 key 的写操作串行化。
  • 忽略主从延迟:如果数据库是主从架构,更新主库后,从库可能还没同步。读请求如果落到从库,读到的还是旧数据。这种情况建议强制读主库。

我的习惯:在秒杀场景中,我会把延迟双删和缓存过期时间配合使用。过期时间设 30 秒,即使双删失败,最多 30 秒后数据也能自动恢复。这算是一个保底方案。

延迟双删的局限性

说实话,延迟双删不是银弹。它有几个硬伤:

  • 延迟时间不好确定:设短了没用,设长了影响性能。需要根据业务压测来调优。
  • 第二次删除可能失败:虽然加了重试,但极端情况下还是会漏删。
  • 不适合写频繁的场景:如果每秒有上千次写操作,缓存会被反复删除,读请求几乎每次都穿透到数据库。

所以我的建议是:延迟双删适合秒杀这种“读多写少、写操作相对低频”的场景。如果是写频繁的业务,可以考虑用 Canal 监听 binlog 来同步缓存,或者直接用分布式锁保证强一致性。

嗯,以上就是延迟双删的核心内容。记住一句话:延迟是为了等待,等待是为了干净


专注资料整理