延迟双删策略:为什么需要“延迟”这一下?
延迟双删,这个名字听起来有点绕。说白了就是:先删缓存,再更新数据库,等一会儿,再删一次缓存。
我第一次看到这个方案时,心里也犯嘀咕:删两次?还带延迟?这不是脱裤子放屁吗?
后来在秒杀场景里栽了跟头,才明白——这一下延迟,救了多少数据一致性。
什么是延迟双删?
先看流程,我画了一张图,帮你快速理解:
流程很简单,但核心问题来了:为什么需要延迟?
为什么需要延迟?
我直接说结论:延迟是为了干掉“脏读”。
你想想看,第一次删除缓存后,数据库还没更新完。这时候如果来了一个读请求,发现缓存里没数据,就会去数据库读。读到的还是旧数据,然后写回缓存。
等数据库更新完了,缓存里存的却是旧数据。这不就脏了吗?
延迟的作用就是:等所有可能读到旧数据的读请求都完成,再删一次。这样缓存里就只剩新数据了。
核心公式:
延迟时间 > 读请求从缓存穿透到数据库再写回缓存的总耗时
秒杀场景下的实战配置
秒杀场景有个特点:读多写少,瞬间并发极高。库存数据一旦不一致,后果很严重。
我在项目中遇到过一个问题:某次秒杀活动,库存显示还有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 来同步缓存,或者直接用分布式锁保证强一致性。
嗯,以上就是延迟双删的核心内容。记住一句话:延迟是为了等待,等待是为了干净。