2. 缓存穿透保护算法总览:布隆过滤器、空值缓存、参数校验、限流降级等方案对比
缓存穿透是指查询一个根本不存在的数据,缓存层无法命中,请求直接穿透到后端存储(如数据库、磁盘),导致存储层压力骤增。若攻击者利用大量不存在的 key 发起请求,可能直接打垮数据库。本章对四种主流穿透保护方案进行系统性对比,帮助你在不同场景下做出正确选型。
核心原则:穿透保护的目标不是“完全避免穿透”,而是“将穿透流量控制在后端可承受的范围内”。没有银弹,只有组合策略。
2.1 四种方案核心原理
| 方案 | 核心原理 | 拦截层级 | 内存开销 | 误判率 |
|---|---|---|---|---|
| 布隆过滤器 | 位数组 + 多个哈希函数,判断 key 是否可能存在 | 缓存层之前 | 低(固定位图) | 有(可控制) |
| 空值缓存 | 将不存在的 key 缓存一个短 TTL 的空值标记 | 缓存层内部 | 中(随空 key 数量增长) | 无 |
| 参数校验 | 在入口层校验请求参数合法性(如 ID 格式、范围) | 网关 / API 层 | 无 | 无 |
| 限流降级 | 对同一 IP / 用户 / key 的请求频率进行限制 | 网关 / 应用层 | 低(计数器) | 无 |
选型提示:布隆过滤器适合“key 全集可预知”的场景(如用户 ID 范围固定);空值缓存适合“key 空间无限但穿透量可控”的场景;参数校验是成本最低的第一道防线;限流降级是兜底手段,防止恶意流量打穿所有保护。
2.2 布隆过滤器(Bloom Filter)
布隆过滤器使用一个长度为 m 的位数组和 k 个独立的哈希函数。插入 key 时,将 k 个哈希位置置为 1;查询时,若所有 k 个位置均为 1,则 key 可能存在;若任一位置为 0,则 key 一定不存在。
- 优点:空间效率极高(百万级 key 仅需几 MB),查询时间复杂度 O(k),支持高并发。
- 缺点:存在误判率(假阳性),无法删除元素(标准版),需要提前知道 key 全集。
- 关键参数:位数组大小 m、哈希函数个数 k、预期元素数量 n。误判率 p ≈ (1 - e-kn/m)k。
// 布隆过滤器核心参数估算(Java Guava 实现)
int expectedInsertions = 1_000_000; // 预期插入量 n
double fpp = 0.01; // 期望误判率 p
BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
expectedInsertions,
fpp);
// 内部自动计算最优 m 和 k
避坑:布隆过滤器无法处理“冷 key 预热”问题——如果业务 key 是动态生成的(如用户自定义短链接),无法提前加载到过滤器中,此时布隆过滤器会误杀所有合法新 key。解决方案:配合空值缓存或使用计数型布隆过滤器(Counting Bloom Filter)。
2.3 空值缓存(Null Cache)
当查询一个不存在的 key 时,将 key 写入缓存,value 设为空值标记(如 null 或 "__EMPTY__"),并设置一个较短的 TTL(如 30~60 秒)。后续相同 key 的请求在 TTL 内直接返回空值,不再穿透到数据库。
- 优点:实现简单,无误判,支持动态 key 空间。
- 缺点:占用缓存空间(大量空 key 可能撑爆内存),TTL 设置不当会导致数据不一致(真实数据写入后仍返回空值)。
- 关键参数:空值 TTL、空值最大数量限制、空值淘汰策略。
// 空值缓存伪代码(Redis + Lua 脚本)
String cacheKey = "user:" + userId;
String value = redis.get(cacheKey);
if (value != null) {
if ("__EMPTY__".equals(value)) {
return null; // 空值命中,直接返回
}
return deserialize(value);
}
// 缓存未命中,查询数据库
User user = userDao.findById(userId);
if (user == null) {
// 设置空值缓存,TTL=30秒
redis.setex(cacheKey, 30, "__EMPTY__");
return null;
}
redis.setex(cacheKey, 3600, serialize(user));
return user;
避坑:空值缓存容易被恶意攻击者利用——构造大量随机不存在的 key,导致缓存中塞满空值标记,挤占正常缓存空间。必须配合空值数量上限和LRU 淘汰策略,或对同一 IP 的空 key 生成速率进行限流。
2.4 参数校验与限流降级
参数校验是第一道物理防线。例如用户 ID 必须是正整数且不超过系统最大用户数,订单号必须符合特定格式。校验不通过直接返回 400 错误,不进入缓存层。
限流降级是最后一道防线。当检测到某个 key、用户或 IP 的请求频率超过阈值时,直接拒绝或返回降级结果(如默认值、静态页)。常用算法:令牌桶、漏桶、滑动窗口。
| 维度 | 参数校验 | 限流降级 |
|---|---|---|
| 拦截对象 | 非法参数 | 高频请求 |
| 资源消耗 | 几乎为零 | 低(计数器) |
| 误杀风险 | 低(规则明确) | 中(阈值设置不当) |
| 典型实现 | 正则、范围检查、白名单 | Guava RateLimiter、Redis 滑动窗口 |
| 适用场景 | 所有入口请求 | 恶意攻击、突发流量 |
2.5 方案组合策略
单一方案无法应对所有穿透场景。推荐分层组合架构:
- L1 参数校验:在网关层过滤明显非法的请求(如 ID ≤ 0、格式错误)。
- L2 布隆过滤器:拦截大部分不存在的 key(需提前加载合法 key 全集)。
- L3 空值缓存:兜住布隆过滤器误判和动态 key 的穿透。
- L4 限流降级:在应用层对同一 key 或 IP 的穿透频率进行限制。
2.6 方案对比总结
| 对比维度 | 布隆过滤器 | 空值缓存 | 参数校验 | 限流降级 |
|---|---|---|---|---|
| 实现复杂度 | 中 | 低 | 低 | 中 |
| 内存开销 | 低(固定) | 中(随空 key 增长) | 无 | 低 |
| 误判/误杀 | 有(假阳性) | 无 | 无 | 有(阈值误杀) |
| 动态 key 支持 | 差(需预加载) | 好 | 好 | 好 |
| 抗恶意攻击 | 强 | 弱(需配合限流) | 中 | 强 |
| 推荐场景 | key 全集固定 | 动态 key + 低频穿透 | 所有入口 | 高并发 + 恶意流量 |
实战建议:在大多数业务系统中,推荐“参数校验 + 布隆过滤器 + 空值缓存”三层组合。布隆过滤器拦截 99% 的穿透,空值缓存兜住剩余 1% 的误判和动态 key,参数校验作为前置过滤。如果系统面临 DDoS 级别的攻击,再叠加限流降级作为第四层。