2、缓存穿透问题:定义与危害、解决方案

好,咱们接着聊缓存穿透。这问题我估计不少人都遇到过,说白了就是——请求绕过了缓存,直接打到了数据库上

什么意思呢?正常流程是:请求先查缓存,缓存有就直接返回,缓存没有再去查数据库。但缓存穿透是,你查的 key 在缓存里不存在,在数据库里也不存在。每次请求都直接穿透缓存,怼到数据库上。如果这种请求量一大,数据库就扛不住了。

2.1 缓存穿透的定义

我习惯这么定义它:缓存穿透是指查询一个根本不存在的数据。缓存层和存储层都没有这个数据,但请求还是源源不断地打过来。

举个例子。你有个用户信息接口,前端传了个 user_id=999999。缓存里没有,数据库里也没有。但攻击者可以伪造大量不存在的 ID,比如 1000000、1000001……每个请求都穿透缓存,直接查数据库。数据库压力瞬间飙升。

核心问题:缓存层失效,存储层直接暴露在恶意流量下。

2.2 缓存穿透的危害

危害其实很直接,我总结了几点:

  • 数据库压力暴增:大量无效查询打到数据库,连接池被占满,正常业务也受影响。
  • 系统响应变慢:数据库扛不住,查询变慢,接口响应时间从毫秒级变成秒级。
  • 可能引发雪崩:数据库挂了,依赖它的服务全崩,连锁反应。

我记得有一次线上事故,就是被人用脚本扫了一堆不存在的商品 ID。缓存全部穿透,数据库 CPU 直接飙到 100%,接口大面积超时。嗯,那次之后我们才认真搞了防穿透方案。

2.3 解决方案一:布隆过滤器

布隆过滤器,说白了就是一个很省内存的“存在性判断”工具。它能告诉你:这个 key 大概率存在,或者一定不存在。

原理不复杂。它用多个哈希函数,把一个 key 映射到位数组的几个位置上。查询时,如果这几个位置都是 1,就说 key 可能存在;只要有一个是 0,那 key 肯定不存在。

我的经验:布隆过滤器有误判率,但可以接受。一般控制在 1% 以内就行。误判的意思是,它可能说“存在”,但实际不存在。不过它不会说“不存在”但实际存在——这个特性很关键。

实战中怎么用?我一般这么搞:

  1. 系统启动时,把所有合法 key 加载到布隆过滤器里。
  2. 每次请求进来,先查布隆过滤器。
  3. 如果过滤器说“不存在”,直接返回空,不查缓存也不查数据库。
  4. 如果过滤器说“存在”,再走正常的缓存→数据库流程。

来看代码:

// 初始化布隆过滤器
BloomFilter<String> bloomFilter = BloomFilter.create(
    Funnels.stringFunnel(Charset.forName("UTF-8")),
    1000000,  // 预计元素数量
    0.01      // 误判率 1%
);

// 系统启动时,加载所有合法 key
for (String key : allValidKeys) {
    bloomFilter.put(key);
}

// 请求处理
public Object getData(String key) {
    // 第一步:布隆过滤器判断
    if (!bloomFilter.mightContain(key)) {
        return null;  // 一定不存在,直接返回
    }

    // 第二步:查缓存
    Object cacheData = redis.get(key);
    if (cacheData != null) {
        return cacheData;
    }

    // 第三步:查数据库
    Object dbData = db.query(key);
    if (dbData != null) {
        redis.set(key, dbData, 3600);  // 缓存1小时
    }
    return dbData;
}

注意:布隆过滤器不支持删除操作。如果业务中有删除 key 的场景,需要重建过滤器,或者用计数布隆过滤器(Counting Bloom Filter)。

2.4 解决方案二:缓存空对象

这个方法更简单粗暴。既然查不到数据,那就把“空”也缓存起来。下次同样的 key 过来,缓存直接返回空,不用再查数据库了。

具体做法:

  • 查询数据库发现没有数据时,在缓存里存一个空值(比如 null 或特殊标记)。
  • 设置一个较短的过期时间,比如 5 分钟。
  • 下次请求直接命中缓存,返回空。

代码示例:

public Object getDataWithNullCache(String key) {
    // 查缓存
    Object cacheData = redis.get(key);
    if (cacheData != null) {
        // 注意:这里要区分“缓存了空值”和“缓存没命中”
        if (cacheData instanceof NullObject) {
            return null;  // 缓存了空值,直接返回空
        }
        return cacheData;
    }

    // 查数据库
    Object dbData = db.query(key);
    if (dbData == null) {
        // 缓存空对象,过期时间设短一点
        redis.set(key, NullObject.INSTANCE, 300);  // 5分钟
        return null;
    }

    // 正常数据,缓存
    redis.set(key, dbData, 3600);
    return dbData;
}

避坑指南:我曾经犯过一个错——空对象缓存时间设得太长。结果某个商品上架后,用户等了半小时才看到。后来我统一把空对象过期时间控制在 5 分钟以内,既防穿透,又保证数据及时更新。

2.5 两种方案对比

对比项 布隆过滤器 缓存空对象
内存占用 极低(位数组) 较高(存储空 key)
实现复杂度 中等(需要初始化) 简单(几行代码)
误判率 有(1% 左右)
适用场景 key 数量大,内存敏感 key 数量少,实现简单
数据更新 需要重建过滤器 过期后自动更新

2.6 我的实战建议

说实话,两种方案我都在线上用过。我的习惯是:

  • 如果 key 总量可控(比如几百万以内),用缓存空对象就够了,简单直接。
  • 如果 key 总量很大(比如上亿),或者内存很紧张,用布隆过滤器更划算。
  • 也可以组合使用:布隆过滤器做第一道防线,缓存空对象做第二道。我有个项目就是这么干的,效果很好。

最后提醒一句:缓存穿透只是缓存问题的冰山一角。后面咱们还会聊缓存击穿和缓存雪崩,那才是真正要命的。嗯,先把穿透搞定,后面就好办了。