凌晨三点,刺耳的电话铃声划破夜空。屏幕上是监控系统的一片血红:核心商品接口响应时间飙升至10秒,数据库CPU持续100%。就在刚才,一场精心策划的秒杀活动上线,仅仅三十秒后,整个系统濒临崩溃。而根因,竟是我们为“保证缓存一致性”而实施的“延迟双删”策略。
如果你也曾在实施延迟双删时,对“第一次删除后,缓存失效那短暂而危险的窗口期”感到隐隐不安,那么这篇文章,正是为你准备的“避坑指南”。
一、延迟双删:理想丰满,现实骨感
让我们先快速回顾一下,延迟双删(Delayed Double-Delete)这一经典缓存一致性策略的标准流程:
- 第一次删除:更新数据库之前,先删除Redis中的缓存数据。
- 更新数据库:执行实际的数据库更新操作。
- 第二次删除(延迟):更新数据库之后,休眠一个短暂时间(如几百毫秒到1秒),再次删除Redis缓存。
它的设计初衷非常巧妙:第一次删除是为了避免在“先更新数据库,再删除缓存”策略中,在更新数据库后、删除缓存前,有其他请求读到旧缓存;第二次延迟删除,则是为了清理在第一次删除后、数据库更新完成前,可能被其他请求写入Redis的旧数据。
听起来很完美,不是吗?然而,在高并发的显微镜下,一个致命的弱点暴露无遗。
请看下面这段典型的伪代码实现:
public void updateProduct(Product product) {
// 1. 第一次删除缓存
redisTemplate.delete("product:" + product.getId()); // Highlight: 危险起点!缓存即刻失效
// 2. 更新数据库
productMapper.updateById(product); // 此操作耗时,产生时间窗口
// 3. 延迟后第二次删除
asyncExecute(() -> {
Thread.sleep(500); // 延迟500毫秒
redisTemplate.delete("product:" + product.getId());
});
}
//Highlight: 标注的第一行代码,就是潘多拉魔盒开启的瞬间。从这一行执行成功开始,直到数据库更新完成、新的缓存被重建之前,关于这个商品的所有缓存都不存在了。
二、风暴之眼:第一次删除后的“缓存空洞”
为了直观理解危机如何发生,我们剖析高并发请求在此时如何“挤垮”数据库。
这个流程揭示了一场“完美风暴”:
- 缓存瞬间失效:第一次删除让缓存键即刻消失。
- 数据库更新耗时:
updateById 操作可能涉及行锁、索引更新、触发器执行等,即使只需50ms,在高并发下也极其漫长。
- 并发请求涌入:在这几十到几百毫秒的窗口期内,成千上万的查询请求同时到达。
- 缓存击穿与雪崩:所有请求在Redis中查不到数据(缓存击穿),全部转向MySQL。瞬间的、针对同一数据的大量数据库查询,可能压垮连接池,并可能因锁竞争进一步拖慢更新操作本身,形成恶性循环(缓存雪崩的前兆)。
- 旧数据回填风险:在数据库更新完成前,先到达的查询请求,会将查询到的旧数据回填到Redis中,尽管后续有延迟删除,但这造成了不必要的数据库压力和数据短暂不一致。
生活化类比:想象一家火爆的餐厅。
- Redis = 传菜员(缓存),记住每桌的菜品。
- MySQL = 后厨(数据库),负责现做。
- 更新菜品(延迟双删):1)先让所有传菜员忘记“红烧肉”这道菜(第一次删)。2)后厨去修改“红烧肉”的食谱(更新数据库)。3)等一会儿,再次通知传菜员忘记“红烧肉”(延迟删)。
- 灾难场景:在传菜员们忘记“红烧肉”,而后厨还在研究新食谱的几分钟里,所有点了“红烧肉”的顾客(并发请求)都会直接涌向后厨窗口询问。后厨师傅被团团围住,根本无法专心改食谱,整个出菜流程陷入瘫痪。
三、破局之道:多层级防御策略
理解了问题本质,解决方案便需要构建一个从“治标”到“治本”的立体防御体系。
策略一:基础加固——互斥锁(Mutex Lock)
最直接的思路是:在缓存失效期间,只允许一个请求去查询数据库并回填缓存,其他请求等待。
public Product getProductById(Long id) {
String key = "product:" + id;
// 1. 尝试从缓存获取
Product product = redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
// 2. 缓存未命中,尝试获取分布式锁
String lockKey = "lock:product:" + id;
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
try {
// 3. 获取锁成功,再次检查缓存(Double Check)
product = redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
// 4. 查询数据库 // Highlight: 只有第一个线程执行此耗时操作
product = productMapper.selectById(id);
if (product != null) {
// 5. 回写缓存
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
}
return product;
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
} else {
// 6. 未获取到锁,短暂等待后重试或返回默认值
Thread.sleep(100);
return redisTemplate.opsForValue().get(key); // 重试获取缓存
}
}
“避坑指南”小贴士:
- 锁粒度要细:锁的Key必须精确到具体的数据ID(如
lock:product:123),而不是整个表或类型,否则性能会急剧下降。
- 设置超时时间:锁必须有超时时间,防止持有锁的线程崩溃导致死锁。
- Double Check:在获得锁后、查询数据库前,必须再次检查缓存,因为可能已被先拿到锁的线程重建。
策略二:优化进阶——异步更新与“逻辑”过期
对于更新频繁的热点数据,我们可以采用“缓存永不过期” + “异步更新”的策略。
- 物理永不过期:Redis中存储的数据不设置TTL。
- 存储逻辑时间:在缓存Value中封装一个逻辑过期时间(如
expireAt)。
- 异步刷新:当发现数据逻辑过期时,由当前线程返回旧数据,同时提交一个异步任务去更新缓存。
// 缓存值对象
@Data
public class CacheWrapper<V> {
private V data;
private long expireAt; // 逻辑过期时间戳
public boolean isExpired() {
return System.currentTimeMillis() > expireAt;
}
}
// 获取数据逻辑
public Product getProductById(Long id) {
String key = "product:" + id;
CacheWrapper<Product> wrapper = redisTemplate.opsForValue().get(key);
if (wrapper == null) {
// 缓存不存在,按互斥锁方式回源加载
return loadFromDbAndCache(id);
}
if (!wrapper.isExpired()) {
// 逻辑上未过期,直接返回
return wrapper.getData();
}
// 逻辑已过期,触发异步更新
asyncRefreshCache(key, id);
// 仍返回旧数据,保证可用性
return wrapper.getData();
}
策略三:架构升级——读写分离与数据库护航
当单点防御不足以抵御洪流时,需要架构层面的支持。
- 数据库读写分离:将所有因缓存失效导致的读请求,引流到数据库的只读从库,保护核心主库。这要求你的数据同步延迟必须在业务可接受范围内。
- 启用数据库连接池与查询缓存:合理配置连接池(如HikariCP)参数,并利用MySQL的Query Cache(尽管在8.0中已移除,但在早期版本或Percona/MariaDB中仍可考虑)缓解重复查询。
- 服务降级与熔断:在监测到数据库压力过大时,通过熔断器(如Hystrix、Sentinel)暂时屏蔽部分非核心的、会导致缓存击穿的查询,直接返回降级内容(如默认值、静态页面),为数据库争取恢复时间。
个人踩坑案例:
在一次大促中,我们负责的推荐服务就曾因延迟双删导致DB抖动。当时我们采用了“互斥锁+热点数据预加载”的组合拳。在活动开始前5分钟,通过定时任务,用互斥锁机制将所有核心商品的缓存提前重建一遍。同时,在代码中为这些热点Key的查询都加上了本地缓存(Caffeine),设置极短的过期时间(如2秒),作为Redis崩溃前的最后一道屏障。这个“组合防御”成功扛住了零点瞬间百倍于平时的流量。
四、面试官追问:如何设计一个更通用的解决方案?
如果你在面试中回答了上述方案,面试官可能会进一步追问:“如果让你设计一个SDK或者框架来一劳永逸地解决这类问题,你会怎么考虑?”
这是一个考察系统设计能力的问题。一个通用的“防缓存击穿”组件可以包含以下模块:
- 热点探测模块:实时监控缓存未命中率,自动识别出热点Key。
- 策略决策引擎:根据Key的热度、数据一致性要求、Value大小等,自动选择最佳策略(互斥锁、异步重建、永久缓存等)。
- 分布式锁服务:集成一个高可用的分布式锁实现(基于Redis或ZooKeeper)。
- 监控与告警:对缓存击穿事件、数据库慢查询进行监控和告警。
总结
- 认知第一:延迟双删不是银弹,第一次删除后产生的“缓存空洞期”是高并发下的主要风险点。
- 首选方案:对于明确的热点Key,互斥锁(Mutex Lock) 是解决并发穿透最经典、最可靠的方式,务必注意锁粒度、超时和Double Check。
- 进阶优化:对于更新不极度频繁的热点数据,逻辑过期+异步刷新能在保证可用性的同时,大幅提升性能体验。
- 架构兜底:数据库读写分离是从根本上分担读压力的有效手段,结合连接池优化和熔断降级,构建系统韧性。
- 组合使用:在实际复杂场景中,往往需要根据业务特征,组合运用多种策略(如本地缓存+分布式锁+异步队列)。
在云栈社区的技术讨论中,我们经常深入探讨这类系统架构的细节问题,欢迎你来分享你的实战经验。