3. 缓存穿透治理方案:布隆过滤器原理与实现、空值缓存策略、参数校验与限流

缓存穿透是指查询一个根本不存在的数据,缓存层和数据库层都无法命中,导致每次请求都直接穿透到数据库。在高并发场景下,这会使数据库承受巨大压力,甚至引发雪崩。本章将系统讲解三种核心治理手段:布隆过滤器、空值缓存策略以及参数校验与限流。

核心原则:缓存穿透治理的目标是在请求到达数据库之前,尽可能早地拦截无效查询。布隆过滤器用于“预判不存在”,空值缓存用于“标记不存在”,参数校验与限流用于“拒绝非法与超量请求”。

3.1 布隆过滤器原理与实现

布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于集合中。它允许一定的误判率(假阳性),但绝对不会漏判(假阴性率为0)。

3.1.1 工作原理

  1. 初始化:创建一个长度为 m 的位数组(bit array),所有位初始化为 0。
  2. 添加元素:使用 k 个独立的哈希函数,将元素映射到位数组的 k 个位置,并将这些位置的值设为 1。
  3. 查询元素:同样使用 k 个哈希函数计算位置,如果所有位置的值都是 1,则元素可能存在;如果任何一个位置是 0,则元素一定不存在。
关键参数:
  • m:位数组长度(bit 数)
  • n:预期插入元素数量
  • k:哈希函数个数
  • p:期望误判率

最优哈希函数个数:k = (m/n) * ln(2)

布隆过滤器工作原理 输入元素 k 个哈希函数 Hash₁, Hash₂, ..., Hashₖ 位数组 (m bits) [0, 1, 0, 1, 1, 0, 0, 1, ...] 查询元素 计算 k 个哈希位置 全为1? 是 可能存在 否 一定不存在 说明: • 假阳性(误判存在)可能发生,但假阴性(漏判存在)概率为 0。 • 通过调整 m 和 k 可以控制误判率 p。误判率越低,所需空间越大。

3.1.2 在缓存穿透治理中的应用

在缓存层之前部署布隆过滤器,将所有可能存在的 key 预先加载到位数组中。当请求到达时:

  • 布隆过滤器判断不存在:直接返回空响应,不查询缓存和数据库。
  • 布隆过滤器判断可能存在:放行请求,继续查询缓存或数据库。
注意事项:
  • 布隆过滤器无法删除元素(标准实现)。如需删除,可使用 Counting Bloom Filter。
  • 数据更新时,需要同步更新布隆过滤器(重建或使用增量更新机制)。
  • 误判率 p 建议设置在 1% ~ 5% 之间,以平衡空间和准确性。

3.1.3 代码实现示例(Guava)

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import java.nio.charset.Charset;

public class BloomFilterDemo {
    // 预期插入数量
    private static final int EXPECTED_INSERTIONS = 1000000;
    // 期望误判率
    private static final double FPP = 0.01;

    private static final BloomFilter<String> bloomFilter =
            BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()),
                               EXPECTED_INSERTIONS, FPP);

    public static void init(List<String> allPossibleKeys) {
        for (String key : allPossibleKeys) {
            bloomFilter.put(key);
        }
    }

    public static boolean mightContain(String key) {
        return bloomFilter.mightContain(key);
    }
}

3.2 空值缓存策略

当查询数据库发现数据不存在时,将空值(或特殊标记)也写入缓存,并设置一个较短的过期时间。这样,后续相同的请求在缓存中就能直接命中空值,避免再次穿透到数据库。

3.2.1 实现步骤

  1. 查询缓存:如果缓存命中,直接返回结果(包括空值标记)。
  2. 查询数据库:如果缓存未命中,查询数据库。
  3. 数据存在:将数据写入缓存,设置正常过期时间。
  4. 数据不存在:将空值(如 null 或 "EMPTY")写入缓存,设置较短过期时间(如 30 ~ 60 秒)。

3.2.2 参数对比

策略 缓存内容 过期时间 适用场景
正常缓存 真实数据 较长(如 1 小时) 数据存在且稳定
空值缓存 空值标记 较短(如 30 秒) 数据不存在,但可能被频繁查询
最佳实践:空值缓存应与布隆过滤器配合使用。布隆过滤器拦截大部分不存在 key,空值缓存作为兜底,处理布隆过滤器误判或数据删除后重新查询的情况。

3.3 参数校验与限流

参数校验和限流是缓存穿透治理的第一道防线,在请求进入系统的最前端进行拦截。

3.3.1 参数校验

对请求参数进行严格的合法性校验,拒绝明显无效的 key。例如:

  • 格式校验:key 必须符合 UUID 格式、数字范围、字符串长度等。
  • 业务规则校验:用户 ID 必须大于 0、订单号必须存在等。
  • 黑名单校验:拦截已知的恶意 key 或攻击模式。
// 参数校验示例
public Response getProduct(String productId) {
    // 1. 基础校验
    if (productId == null || productId.isEmpty()) {
        return Response.error("productId 不能为空");
    }
    // 2. 格式校验
    if (!productId.matches("^[A-Z]{2}\\d{6}$")) {
        return Response.error("productId 格式不正确");
    }
    // 3. 业务校验
    if (isBlacklisted(productId)) {
        return Response.error("非法请求");
    }
    // ... 后续查询逻辑
}

3.3.2 限流

对同一 key 或同一 IP 的请求进行限流,防止恶意攻击或异常流量导致缓存穿透。常用限流算法:

算法 原理 优点 缺点
令牌桶 以固定速率生成令牌,请求需获取令牌才能执行 允许突发流量,平滑限流 实现相对复杂
漏桶 请求以固定速率流出,超出桶容量则丢弃 流量整形效果好 无法应对突发流量
滑动窗口 将时间划分为小窗口,统计每个窗口内的请求数 实现简单,精度可控 窗口边界可能出现流量尖峰
限流粒度建议:
  • 全局限流:限制整个系统的 QPS,防止数据库被打满。
  • Key 级别限流:对同一个 key 的访问频率进行限制,防止热点 key 穿透。
  • 用户/IP 级别限流:限制单个用户或 IP 的访问频率,防止恶意攻击。
缓存穿透治理整体架构 客户端请求 第一道防线 参数校验 + 限流 第二道防线 布隆过滤器 缓存层 Redis / Memcached 数据库层 MySQL / PostgreSQL 第三道防线 空值缓存策略 短 TTL 空值标记 返回响应 三层防线协同工作:参数校验与限流拦截非法/超量请求 → 布隆过滤器拦截不存在 key → 空值缓存兜底处理误判或数据删除场景。

3.4 三种策略对比与选型建议

策略 拦截能力 空间成本 时间成本 误判风险 推荐场景
参数校验与限流 低(仅拦截明显非法请求) 低 低 无 所有系统必备
布隆过滤器 高(拦截大部分不存在 key) 中(位数组) 低(O(k) 哈希计算) 有(假阳性) key 空间大、数据相对静态
空值缓存 中(拦截已查询过的不存在 key) 低(少量空值缓存) 中(需查询数据库一次) 无 key 空间小、数据动态变化
推荐组合:参数校验与限流(必选)+ 布隆过滤器(核心)+ 空值缓存(兜底)。三层防线层层递进,能够有效抵御 99% 以上的缓存穿透攻击。