4. 缓存穿透防御实战
缓存穿透的本质,是请求查询的数据在缓存和数据库中都不存在,导致每次请求都绕过缓存直接落到数据库。与缓存击穿(热点 key 失效瞬间被大量并发打穿)和缓存雪崩(大批 key 同时失效)不同,穿透的杀伤力在于攻击者可以用大量随机、不存在的 key 持续制造无效查询,数据库连接池会被迅速占满。防御穿透不能只靠单一手段,本节围绕"参数校验 → 布隆过滤器 → 空值缓存 → 限流兜底"四层组合方案展开,并给出可落地的实现细节。
四层防御的职责分工
四层防御并非简单叠加,而是各司其职、逐层收窄无效流量:
- 参数校验:在最外层拦截明显非法的请求,如 ID 为负数、格式不符、超出业务范围,成本最低,直接返回错误,不触碰任何存储。
- 布隆过滤器:判断 key "可能存在"或"一定不存在"。对于一定不存在的 key,直接拒绝,避免查缓存和数据库。
- 空值缓存:对布隆过滤器判定"可能存在"但数据库实际查不到的 key,缓存一个短 TTL 的空值占位,防止同一 key 反复穿透。
- 限流:当上述手段仍被绕过(如布隆过滤器误判、空值缓存过期),用限流保护数据库,把异常流量控制在可承受范围内。
下图展示了四层防御的请求处理链路与各层的判断分支:
布隆过滤器的参数计算与实现
布隆过滤器用一个位数组和 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 则一定不存在 |
| 空值缓存 | TTL | 60 秒 | 命中空串则返回空,不查库 |
| 限流 | QPS 阈值 | 1000 QPS | 超限返回限流提示 |
组合方案的效果可以这样估算:假设攻击者每秒发送 10 万个随机 key,参数校验拦截约 30%(格式非法),剩余 7 万进入布隆过滤器;布隆过滤器拦截约 99%(误判率 1%),剩余约 700 个进入空值缓存;空值缓存命中后不再查库,最终落到数据库的请求约 700 个/秒,再经限流控制在 1000 QPS 以内,数据库压力从 10 万 QPS 降到不足 1000 QPS,防护效果显著。
落地建议:布隆过滤器在应用启动时预热,从数据库批量加载有效 ID;空值缓存的 TTL 根据业务容忍度调整;限流阈值通过压测确定。四层防御的日志要分别打点,便于观察各层拦截量,快速定位是哪一层失效。
最后强调一个常见误区:布隆过滤器只能判断"一定不存在",不能判断"一定存在"。因此它不能替代缓存,只能作为前置过滤。真正的数据读取仍要走"缓存 → 数据库"的正常路径,空值缓存负责兜住布隆过滤器误判放行的少量请求。四层组合的核心思想是逐层收窄无效流量,让每一层只处理自己最擅长的那部分请求,从而以最小成本保护数据库。