3. 缓存穿透原理与防护:布隆过滤器、空值缓存、参数校验、限流降级

3.1 缓存穿透:定义与危害

缓存穿透是指查询一个根本不存在的数据。由于缓存中不存在该数据(缓存不命中),请求直接穿透到后端数据库;而数据库中同样不存在该记录,导致每次查询都落库,且返回空结果。当这种请求量巨大时(尤其是恶意攻击),数据库会承受巨大压力,甚至引发雪崩。

核心危害:缓存层完全失效,所有请求直击数据库,导致数据库连接耗尽、响应超时、服务不可用。

3.2 缓存穿透的典型场景

  • 恶意攻击:攻击者构造大量不存在的 Key(如连续递增 ID 但实际已删除的记录),绕过缓存。
  • 业务逻辑漏洞:用户查询非法参数(如负数 ID、超长字符串),后端未做校验直接查库。
  • 数据未预热:新上线功能或数据迁移后,大量合法但尚未缓存的查询穿透。

3.3 四层防护体系总览

解决缓存穿透需要构建多层防线,从入口到存储层层拦截。下表对比了四种主流防护手段的定位与效果:

防护层 拦截位置 核心原理 优点 缺点
参数校验 API 网关 / Controller 拒绝非法、无效参数请求 成本极低,拦截大部分恶意攻击 无法拦截合法但不存在的数据
布隆过滤器 缓存层之前 快速判断 Key 是否可能存在 内存占用极小,性能极高 有误判率,不支持删除
空值缓存 缓存层 将空结果也缓存,设置短 TTL 实现简单,直接保护数据库 占用额外缓存空间,存在短暂不一致
限流降级 全局 / 接口级 限制单位时间请求量,超出则降级 兜底保护,防止系统崩溃 影响正常用户,需精细配置

3.4 防护策略详解

3.4.1 参数校验:第一道防线

在请求进入业务逻辑前,对参数进行基础合法性校验,快速拒绝明显无效的请求。

// 示例:参数校验拦截非法请求
public Response<User> getUserById(Long userId) {
    // 1. 基础校验
    if (userId == null || userId <= 0) {
        return Response.fail("参数非法");
    }
    // 2. 格式校验(如 UUID 格式)
    if (!Pattern.matches("^[A-Za-z0-9-]+$", userId.toString())) {
        return Response.fail("参数格式错误");
    }
    // ... 后续查询逻辑
}
最佳实践:参数校验应前置到 API 网关或 Spring 的 @Validated 注解,避免无效请求进入应用层。

3.4.2 布隆过滤器:高效预判

布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于集合中。它由位数组和多个哈希函数组成。

  • 原理:将数据库中所有存在的 Key 预加载到布隆过滤器中。查询时,先检查布隆过滤器:如果返回“不存在”,则直接返回空,避免查库;如果返回“可能存在”,则继续查缓存或数据库。
  • 特点:有误判率(可能把不存在的 Key 误判为存在),但不会漏判(存在的 Key 一定被识别)。
// 示例:Guava 布隆过滤器使用
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;

// 初始化:预计插入 100 万条数据,误判率 1%
BloomFilter<Long> bloomFilter = BloomFilter.create(
    Funnels.longFunnel(), 1_000_000, 0.01);

// 预热:将所有存在的用户 ID 加入
for (Long id : userIdListFromDB) {
    bloomFilter.put(id);
}

// 查询时判断
public User getUser(Long userId) {
    // 1. 布隆过滤器拦截
    if (!bloomFilter.mightContain(userId)) {
        return null; // 一定不存在,直接返回
    }
    // 2. 查缓存
    User user = cache.get(userId);
    if (user != null) return user;
    // 3. 查数据库
    user = db.get(userId);
    if (user != null) {
        cache.set(userId, user);
    }
    return user;
}
注意:布隆过滤器不支持删除操作。如果数据被删除,需要重建过滤器或使用 Counting Bloom Filter 等变种。

3.4.3 空值缓存:简单有效

当查询数据库发现数据不存在时,仍然将该 Key 写入缓存,但 Value 设置为空(如 null 或特殊标记),并设置一个较短的过期时间(如 30-60 秒)。

// 示例:空值缓存实现
public User getUserWithNullCache(Long userId) {
    // 1. 查缓存
    Object cacheValue = cache.get(userId);
    if (cacheValue != null) {
        // 判断是否是空值标记
        if (NULL_MARK.equals(cacheValue)) {
            return null; // 缓存中标记为空,直接返回
        }
        return (User) cacheValue;
    }
    // 2. 查数据库
    User user = db.get(userId);
    if (user == null) {
        // 3. 缓存空值,TTL 设置为 30 秒
        cache.set(userId, NULL_MARK, 30);
        return null;
    }
    // 4. 缓存真实数据
    cache.set(userId, user, 3600);
    return user;
}
优化建议:空值缓存应与布隆过滤器配合使用。布隆过滤器拦截大部分不存在 Key,空值缓存作为兜底,处理布隆过滤器误判的那部分请求。

3.4.4 限流降级:终极兜底

当上述防护均未能完全阻挡穿透流量时,限流降级作为最后一道防线,保护数据库不被击垮。

  • 限流:对查询接口进行速率限制(如令牌桶、滑动窗口),超出阈值的请求直接返回“服务繁忙”或降级结果。
  • 降级:当检测到数据库压力过大时,主动返回默认值、缓存中的旧数据或错误提示,避免数据库崩溃。
// 示例:基于 Sentinel 的限流降级配置(伪代码)
@SentinelResource(value = "getUser", blockHandler = "blockHandler")
public User getUser(Long userId) {
    // ... 业务逻辑
}

// 限流后的降级方法
public User blockHandler(Long userId, BlockException ex) {
    // 返回降级结果:从本地缓存获取或返回默认值
    return localCache.getOrDefault(userId, User.getDefaultUser());
}

3.5 四层防护协作流程

下图展示了请求经过四层防护的完整链路:

缓存穿透四层防护协作流程图 客户端请求 第一层:参数校验 拒绝非法参数请求 直接返回错误 第二层:布隆过滤器 判断 Key 是否可能存在 直接返回空 第三层:空值缓存 缓存空结果 + 短 TTL 第四层:限流降级 保护数据库不被击垮 后端数据库 防护效果说明 • 参数校验:拦截 80% 恶意请求 • 布隆过滤器:拦截 99% 不存在 Key • 空值缓存:兜底误判请求 • 限流降级:防止系统雪崩

3.6 方案对比与选型建议

在实际项目中,应根据业务场景和数据特点选择合适的防护组合:

业务场景 推荐方案 原因
高并发、Key 数量大(亿级) 布隆过滤器 + 参数校验 内存占用低,性能损耗小
Key 数量小、变化频繁 空值缓存 + 限流降级 实现简单,无需维护布隆过滤器
安全合规要求高(防恶意攻击) 四层全上 层层拦截,最大限度保护数据库
读多写少、数据稳定 布隆过滤器 + 空值缓存 布隆过滤器预热后几乎无变化
核心结论:没有银弹。缓存穿透防护需要组合使用多种策略,形成“校验 → 预判 → 缓存 → 限流”的纵深防御体系。布隆过滤器适合大规模、相对静态的数据集;空值缓存适合小规模或动态变化的数据;参数校验和限流降级是所有系统都应具备的基础能力。