缓存击穿:一场针对热点数据的“精准打击”

好,咱们接着聊缓存击穿。说实话,我刚开始接触这个概念时,总觉得它跟穿透长得太像了,容易搞混。但实际在线上踩过坑之后,你会发现——这俩完全不是一回事。

什么是缓存击穿?

缓存击穿,说白了就是:某个热点 key 突然过期了,然后一瞬间大量请求直接怼到数据库上

你想想看,平时大家都从缓存里拿数据,数据库岁月静好。突然缓存里那个最热门的 key 没了,所有请求就像没头苍蝇一样,全跑去查数据库。数据库哪扛得住这种流量洪峰?

核心特征:

  • 针对的是单个热点 key,不是一堆不存在的 key
  • 发生在 key 过期瞬间,不是一直不存在
  • 后果是数据库瞬间被打爆,服务雪崩

击穿 vs 穿透:到底差在哪?

我记得有一次团队里新同学问我:“这两个不都是打到数据库了吗?” 嗯,这里要注意,区别其实很明显:

对比维度 缓存穿透 缓存击穿
目标数据 数据库中也不存在的数据 数据库中真实存在的热点数据
发生时机 持续发生,跟过期无关 key 过期瞬间
影响范围 可能涉及多个 key 通常单个热点 key
解决方案 布隆过滤器、空值缓存 互斥锁、逻辑过期

简单来说:穿透是“查无此人”,击穿是“人刚走,茶还没凉透,大家就冲进来了”。

互斥锁方案(SETNX):最直接的“挡箭牌”

我个人习惯用 SETNX 来解决击穿问题。原理很简单:当缓存失效后,让第一个请求去拿锁,然后查数据库重建缓存。其他请求拿不到锁,就等着或者直接返回旧数据。

来看一段我常用的代码:

public String getData(String key) {
    String value = redis.get(key);
    if (value != null) {
        return value;
    }
    
    // 尝试获取互斥锁
    String lockKey = "lock:" + key;
    boolean locked = redis.setnx(lockKey, "1", 30, TimeUnit.SECONDS);
    
    if (locked) {
        try {
            // 拿到锁,查数据库
            value = db.query(key);
            redis.set(key, value, 300, TimeUnit.SECONDS);
            return value;
        } finally {
            redis.del(lockKey); // 释放锁
        }
    } else {
        // 没拿到锁,等一会儿再试
        Thread.sleep(100);
        return getData(key); // 递归重试
    }
}

避坑指南:我曾经在释放锁时忘记加 finally,结果锁一直没释放,导致后续所有请求都卡死了。嗯,血的教训——锁的释放一定要放在 finally 里

逻辑过期方案:更优雅的“软过期”

互斥锁虽然好用,但有个问题:如果数据库查询很慢,所有请求都得等着。这时候我更喜欢用逻辑过期方案。

什么叫逻辑过期?就是缓存里存的数据不过期,但额外存一个过期时间字段。读取时判断是否过期,过期了就异步去更新。

// 缓存数据结构
{
    "data": "实际业务数据",
    "expireTime": 1700000000  // 逻辑过期时间戳
}

public String getData(String key) {
    String cacheData = redis.get(key);
    if (cacheData == null) {
        return null; // 缓存都没了,走降级
    }
    
    CacheObject obj = JSON.parse(cacheData);
    if (obj.expireTime > System.currentTimeMillis()) {
        // 没过期,直接返回
        return obj.data;
    }
    
    // 逻辑过期了,尝试获取锁去更新
    String lockKey = "lock:" + key;
    if (redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS)) {
        // 异步线程去更新缓存
        executor.submit(() -> {
            String newData = db.query(key);
            CacheObject newObj = new CacheObject(newData, System.currentTimeMillis() + 300000);
            redis.set(key, JSON.toJSON(newObj));
            redis.del(lockKey);
        });
    }
    
    // 返回旧数据,不阻塞
    return obj.data;
}

你看,这种方式的好处是:用户永远不用等。哪怕缓存过期了,也先返回旧数据,后台悄悄更新。用户体验好,数据库压力也小。

热点数据识别与预热:把问题扼杀在摇篮里

说实话,上面两种方案都是“亡羊补牢”。我更推荐的做法是:提前识别热点,主动预热

我在项目中用过这么几种方式:

  • 访问频率统计:在业务层记录每个 key 的访问次数,超过阈值就标记为热点
  • 人工配置:运营活动开始前,手动把相关数据预热到缓存
  • 定时任务扫描:每隔一段时间,扫描最近访问量高的 key,提前续期

预热的核心逻辑:

// 启动时加载热点数据
@PostConstruct
public void preheat() {
    List<String> hotKeys = getHotKeysFromConfig();
    for (String key : hotKeys) {
        String value = db.query(key);
        redis.set(key, value, 300, TimeUnit.SECONDS);
    }
}

击穿场景的降级策略:留好“后手”

不管你怎么预防,总有意外发生。我经历过一次线上事故:某个大促活动,热点 key 的过期时间设置得太短,结果互斥锁也没扛住,数据库直接挂了。

从那以后,我养成了一个习惯:给每个热点 key 都准备降级方案

常用的降级策略有:

  1. 返回默认值:缓存和数据库都挂了,直接返回一个预设的默认数据
  2. 限流熔断:对热点 key 的请求做限流,超过阈值直接拒绝
  3. 本地缓存兜底:在应用层维护一个小的本地缓存,作为最后一道防线

注意:降级不是万能的。我曾经见过一个团队,降级策略写得太随意,结果返回的默认数据导致前端页面大面积报错。降级数据一定要经过测试,确保业务能接受。

最后说一句:缓存击穿这个问题,预防永远比补救重要。热点数据预热 + 合理的过期时间 + 互斥锁兜底,这三板斧用好了,大部分击穿问题都能避免。当然,真遇到极端情况,降级策略就是你的“救命稻草”。