4. 缓存穿透:定义、产生原因、解决方案

4.1 定义与现象

缓存穿透是指客户端请求的数据在缓存和数据库中都不存在,导致每次请求都直接穿透缓存层,落到后端数据库上。由于缓存无法命中,这些请求会持续对数据库造成压力,严重时可能导致数据库连接耗尽或响应超时。

核心特征:缓存和数据库均无目标数据 → 请求绕过缓存 → 数据库负载飙升。

4.2 产生原因

原因分类 典型场景 危害等级
恶意攻击 攻击者构造大量不存在的 Key(如负 ID、超长字符串)发起请求 高
业务逻辑缺陷 前端未做参数校验,直接传入非法或空值 中
数据误删/未同步 数据库记录已被删除,但缓存未及时清理,请求仍按旧 Key 访问 中
缓存预热遗漏 新上线功能未将历史数据提前加载到缓存,导致首次请求全部穿透 低

4.3 解决方案对比

方案 原理 优点 缺点 适用场景
布隆过滤器 使用位数组 + 多个哈希函数判断 Key 是否存在 内存占用极低;查询速度快(O(k)) 存在误判率;不支持删除元素 高并发、Key 空间大、允许少量误判
缓存空对象 将查询结果为 null 的 Key 也缓存起来,设置较短 TTL 实现简单;无额外数据结构 占用额外缓存空间;存在短期数据不一致 中小规模、Key 总数可控

4.4 布隆过滤器(Bloom Filter)

布隆过滤器是一种空间效率极高的概率型数据结构,用于判断一个元素是否在集合中。它允许一定的误判率(False Positive),但不会漏判(False Negative)。

布隆过滤器判断流程 请求 Key Hash 1 Hash 2 Hash k 位数组(Bit Array) 0 1 0 1 1 0 0 1 0 1 1 0 0 0 1 0 ... 索引: 0 1 2 3 4 5 6 7 8 9 ... 所有哈希位置均为 1 → 可能存在 任一哈希位置为 0 → 一定不存在 可能存在 → 查数据库 一定不存在 → 直接拒绝 误判率 ≈ (1 - e-kn/m)k,其中 k=哈希函数数,m=位数组长度,n=元素数量 推荐误判率设置:1% ~ 5%

4.4.1 布隆过滤器实战代码(Java + Redisson)

// 1. 初始化布隆过滤器
RBloomFilter<String> bloomFilter = redissonClient
    .getBloomFilter("user:id:bloom", 5000000L, 0.01);
bloomFilter.tryInit(5000000L, 0.01);  // 预计元素500万,误判率1%

// 2. 预热:将所有合法用户ID加入过滤器
List<Long> allUserIds = userMapper.selectAllIds();
for (Long userId : allUserIds) {
    bloomFilter.add(String.valueOf(userId));
}

// 3. 查询拦截
public User getUserById(Long userId) {
    String key = String.valueOf(userId);

    // 布隆过滤器前置拦截
    if (!bloomFilter.contains(key)) {
        log.warn("缓存穿透拦截 - 非法ID: {}", userId);
        return null;  // 直接返回,不查数据库
    }

    // 查缓存
    User user = redisTemplate.opsForValue().get(key);
    if (user != null) {
        return user;
    }

    // 查数据库
    user = userMapper.selectById(userId);
    if (user != null) {
        redisTemplate.opsForValue().set(key, user, 1, TimeUnit.HOURS);
    }
    return user;
}
最佳实践:布隆过滤器应随数据库全量数据一起初始化,并定期(如每天凌晨)重建,以同步新增/删除的数据。

4.5 缓存空对象

当查询数据库返回空结果时,仍然将该 Key 写入缓存,但 Value 设置为一个特殊标记(如空字符串或占位对象),并设置较短的过期时间(通常 30~60 秒)。后续相同请求在 TTL 内直接命中缓存空对象,避免重复穿透到数据库。

4.5.1 缓存空对象实战代码

public User getUserById(Long userId) {
    String key = "user:" + userId;

    // 1. 查缓存
    String cachedValue = redisTemplate.opsForValue().get(key);
    if (cachedValue != null) {
        // 命中缓存空对象标记
        if ("NULL_PLACEHOLDER".equals(cachedValue)) {
            log.info("命中空对象缓存,直接返回 null");
            return null;
        }
        return JSON.parseObject(cachedValue, User.class);
    }

    // 2. 查数据库
    User user = userMapper.selectById(userId);

    // 3. 无论是否为空,都写入缓存
    if (user == null) {
        // 缓存空对象,TTL 设为 30 秒
        redisTemplate.opsForValue().set(key, "NULL_PLACEHOLDER", 30, TimeUnit.SECONDS);
        log.warn("数据库无数据,缓存空对象: {}", key);
        return null;
    }

    // 正常数据缓存 1 小时
    redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 1, TimeUnit.HOURS);
    return user;
}

4.6 两种方案对比总结

对比维度 布隆过滤器 缓存空对象
内存开销 极低(位数组) 较高(存储大量空 Key)
实现复杂度 中等(需引入额外组件) 简单(仅需缓存逻辑)
误判风险 有(可能误判存在) 无(精确判断)
数据一致性 需定期重建 TTL 过期后自动恢复
适用规模 海量 Key(千万级以上) 中小规模(百万级以内)
防御恶意攻击 优秀(直接拦截非法 Key) 一般(仍会占用缓存空间)

4.7 组合策略推荐

在生产环境中,建议将两种方案组合使用,形成多层防护:

  1. 第一层:参数校验 — 在 API 网关或 Controller 层校验 ID 格式、范围,拒绝明显非法的请求。
  2. 第二层:布隆过滤器 — 拦截大部分不存在的 Key,降低数据库无效查询。
  3. 第三层:缓存空对象 — 对布隆过滤器误判的少量请求,以及合法但数据确实不存在的场景,提供短时间缓存保护。
  4. 第四层:数据库限流与熔断 — 作为兜底,防止突发流量击穿所有防护层。
注意:布隆过滤器无法删除元素。如果业务需要频繁删除 Key,建议使用布隆过滤器的变种(如 Counting Bloom Filter)或定期重建过滤器。缓存空对象的 TTL 不宜过长,否则可能导致数据长期不一致。