4. 缓存穿透防御实战

缓存穿透的本质,是请求查询的数据在缓存和数据库中都不存在,导致每次请求都绕过缓存直接落到数据库。与缓存击穿(热点 key 失效瞬间被大量并发打穿)和缓存雪崩(大批 key 同时失效)不同,穿透的杀伤力在于攻击者可以用大量随机、不存在的 key 持续制造无效查询,数据库连接池会被迅速占满。防御穿透不能只靠单一手段,本节围绕"参数校验 → 布隆过滤器 → 空值缓存 → 限流兜底"四层组合方案展开,并给出可落地的实现细节。

四层防御的职责分工

四层防御并非简单叠加,而是各司其职、逐层收窄无效流量:

  • 参数校验:在最外层拦截明显非法的请求,如 ID 为负数、格式不符、超出业务范围,成本最低,直接返回错误,不触碰任何存储。
  • 布隆过滤器:判断 key "可能存在"或"一定不存在"。对于一定不存在的 key,直接拒绝,避免查缓存和数据库。
  • 空值缓存:对布隆过滤器判定"可能存在"但数据库实际查不到的 key,缓存一个短 TTL 的空值占位,防止同一 key 反复穿透。
  • 限流:当上述手段仍被绕过(如布隆过滤器误判、空值缓存过期),用限流保护数据库,把异常流量控制在可承受范围内。

下图展示了四层防御的请求处理链路与各层的判断分支:

客户端请求 第1层:参数校验 ID>0、格式合法、范围合规 非法参数 直接返回错误 第2层:布隆过滤器 m=100万位, k=7, 误判率≈0.8% 一定不存在 直接拒绝 第3层:空值缓存 SET key "" EX 60 NX 命中空值 返回空,不查库 第4层:限流兜底 令牌桶 1000 QPS,超限拒绝 超限 返回限流提示 数据库查询 查不到则回写空值 拦截层 过滤层 缓存层 保护层

布隆过滤器的参数计算与实现

布隆过滤器用一个位数组和 k 个哈希函数实现。插入元素时,将 k 个哈希位置置 1;查询时,若任一位置为 0,则该元素一定不存在;若全为 1,则可能存在(存在误判)。误判率由位数组长度 m、哈希函数个数 k 和已插入元素数 n 共同决定,近似公式为:

p ≈ (1 - e^(-kn/m))^k

最优哈希个数:k = (m/n) * ln2 ≈ 0.693 * (m/n)
给定 n 和期望误判率 p 时:m = -n * ln(p) / (ln2)^2

假设业务中商品 ID 总量约 100 万,期望误判率控制在 1% 以内,代入公式:m ≈ -1000000 × ln(0.01) / (0.693)^2 ≈ 958 万位,约 1.14 MB;k ≈ 0.693 × 9.58 ≈ 6.64,取 7。也就是说,用约 1.2 MB 内存就能把 100 万商品的误判率压到 1% 以下,代价极低。

Redis 4.0 之后可用 BF.ADD、BF.EXISTS 命令,底层由 RedisBloom 模块提供。初始化时用 BF.RESERVE 指定容量和误判率:

BF.RESERVE product:bloom 0.01 1000000
BF.ADD product:bloom 10086
BF.EXISTS product:bloom 10086   # 返回 1
BF.EXISTS product:bloom 99999   # 返回 0,一定不存在

需要注意:布隆过滤器不支持删除。商品下架后,其 ID 仍留在过滤器中,查询会继续放行到空值缓存层,由空值缓存兜住。若业务删除频繁,可改用计数布隆过滤器(Counting Bloom Filter)或定期重建。

空值缓存的写入与过期策略

空值缓存的关键是TTL 要短,通常 30~120 秒。TTL 太长会导致数据恢复后仍返回空值,TTL 太短则防护效果打折。写入时用 SET key "" EX 60 NX,NX 保证不会覆盖真实数据。读取时需区分"空字符串"和"未命中":

String val = redis.get(key);
if (val != null) {
    return val.isEmpty() ? null : val;  // 空串代表已确认不存在
}
// 缓存未命中,查库
Product p = db.queryById(id);
if (p == null) {
    redis.set(key, "", 60, TimeUnit.SECONDS);  // 回写空值
    return null;
}
redis.set(key, JSON.toJSONString(p), 3600, TimeUnit.SECONDS);
return p;

注意:空值缓存会占用额外内存。若攻击者用海量随机 key 制造穿透,空值缓存本身可能被撑爆。因此空值缓存必须配合布隆过滤器使用——布隆过滤器先挡掉绝大部分随机 key,空值缓存只处理少量漏网的误判 key。

限流兜底与组合方案参数

限流是最后一道防线。推荐用 Redis + Lua 实现令牌桶,按接口维度限流。例如商品详情接口限制 1000 QPS,超出直接返回"系统繁忙"。限流阈值应略高于正常峰值,避免误伤正常流量。下表给出四层防御的推荐参数与判断依据:

防御层关键参数推荐值(示例)判断依据
参数校验ID 范围、格式ID ∈ [1, 10^9]非法直接拒绝,不查存储
布隆过滤器m、k、误判率m=958万位, k=7, p≈1%返回 0 则一定不存在
空值缓存TTL60 秒命中空串则返回空,不查库
限流QPS 阈值1000 QPS超限返回限流提示

组合方案的效果可以这样估算:假设攻击者每秒发送 10 万个随机 key,参数校验拦截约 30%(格式非法),剩余 7 万进入布隆过滤器;布隆过滤器拦截约 99%(误判率 1%),剩余约 700 个进入空值缓存;空值缓存命中后不再查库,最终落到数据库的请求约 700 个/秒,再经限流控制在 1000 QPS 以内,数据库压力从 10 万 QPS 降到不足 1000 QPS,防护效果显著。

落地建议:布隆过滤器在应用启动时预热,从数据库批量加载有效 ID;空值缓存的 TTL 根据业务容忍度调整;限流阈值通过压测确定。四层防御的日志要分别打点,便于观察各层拦截量,快速定位是哪一层失效。

最后强调一个常见误区:布隆过滤器只能判断"一定不存在",不能判断"一定存在"。因此它不能替代缓存,只能作为前置过滤。真正的数据读取仍要走"缓存 → 数据库"的正常路径,空值缓存负责兜住布隆过滤器误判放行的少量请求。四层组合的核心思想是逐层收窄无效流量,让每一层只处理自己最擅长的那部分请求,从而以最小成本保护数据库。