找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2746

积分

0

好友

363

主题
发表于 6 小时前 | 查看: 0| 回复: 0

凌晨三点,刺耳的电话铃声划破夜空。屏幕上是监控系统的一片血红:核心商品接口响应时间飙升至10秒,数据库CPU持续100%。就在刚才,一场精心策划的秒杀活动上线,仅仅三十秒后,整个系统濒临崩溃。而根因,竟是我们为“保证缓存一致性”而实施的“延迟双删”策略。

如果你也曾在实施延迟双删时,对“第一次删除后,缓存失效那短暂而危险的窗口期”感到隐隐不安,那么这篇文章,正是为你准备的“避坑指南”。

一、延迟双删:理想丰满,现实骨感

让我们先快速回顾一下,延迟双删(Delayed Double-Delete)这一经典缓存一致性策略的标准流程:

  1. 第一次删除:更新数据库之前,先删除Redis中的缓存数据。
  2. 更新数据库:执行实际的数据库更新操作。
  3. 第二次删除(延迟):更新数据库之后,休眠一个短暂时间(如几百毫秒到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: 标注的第一行代码,就是潘多拉魔盒开启的瞬间。从这一行执行成功开始,直到数据库更新完成、新的缓存被重建之前,关于这个商品的所有缓存都不存在了

二、风暴之眼:第一次删除后的“缓存空洞”

为了直观理解危机如何发生,我们剖析高并发请求在此时如何“挤垮”数据库。

这个流程揭示了一场“完美风暴”:

  1. 缓存瞬间失效:第一次删除让缓存键即刻消失。
  2. 数据库更新耗时updateById 操作可能涉及行锁、索引更新、触发器执行等,即使只需50ms,在高并发下也极其漫长。
  3. 并发请求涌入:在这几十到几百毫秒的窗口期内,成千上万的查询请求同时到达。
  4. 缓存击穿与雪崩:所有请求在Redis中查不到数据(缓存击穿),全部转向MySQL。瞬间的、针对同一数据的大量数据库查询,可能压垮连接池,并可能因锁竞争进一步拖慢更新操作本身,形成恶性循环(缓存雪崩的前兆)。
  5. 旧数据回填风险:在数据库更新完成前,先到达的查询请求,会将查询到的旧数据回填到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:在获得锁后、查询数据库前,必须再次检查缓存,因为可能已被先拿到锁的线程重建。

策略二:优化进阶——异步更新与“逻辑”过期

对于更新频繁的热点数据,我们可以采用“缓存永不过期” + “异步更新”的策略。

  1. 物理永不过期:Redis中存储的数据不设置TTL。
  2. 存储逻辑时间:在缓存Value中封装一个逻辑过期时间(如 expireAt)。
  3. 异步刷新:当发现数据逻辑过期时,由当前线程返回旧数据,同时提交一个异步任务去更新缓存。
// 缓存值对象
@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或者框架来一劳永逸地解决这类问题,你会怎么考虑?”

这是一个考察系统设计能力的问题。一个通用的“防缓存击穿”组件可以包含以下模块:

  1. 热点探测模块:实时监控缓存未命中率,自动识别出热点Key。
  2. 策略决策引擎:根据Key的热度、数据一致性要求、Value大小等,自动选择最佳策略(互斥锁、异步重建、永久缓存等)。
  3. 分布式锁服务:集成一个高可用的分布式锁实现(基于Redis或ZooKeeper)。
  4. 监控与告警:对缓存击穿事件、数据库慢查询进行监控和告警。

总结

  1. 认知第一:延迟双删不是银弹,第一次删除后产生的“缓存空洞期”是高并发下的主要风险点。
  2. 首选方案:对于明确的热点Key,互斥锁(Mutex Lock) 是解决并发穿透最经典、最可靠的方式,务必注意锁粒度、超时和Double Check。
  3. 进阶优化:对于更新不极度频繁的热点数据,逻辑过期+异步刷新能在保证可用性的同时,大幅提升性能体验。
  4. 架构兜底:数据库读写分离是从根本上分担读压力的有效手段,结合连接池优化熔断降级,构建系统韧性。
  5. 组合使用:在实际复杂场景中,往往需要根据业务特征,组合运用多种策略(如本地缓存+分布式锁+异步队列)。

云栈社区的技术讨论中,我们经常深入探讨这类系统架构的细节问题,欢迎你来分享你的实战经验。




上一篇:Canvas与Audio指纹:前端设备唯一识别(原理与JavaScript实现)
下一篇:物联网设备OTA技术全解析:架构方案与核心流程详解
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-31 22:57 , Processed in 0.290170 second(s), 43 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表