2. 缓存穿透解决方案:布隆过滤器原理、空值缓存策略、参数校验与限流
缓存穿透是指查询一个根本不存在的数据,缓存层和数据库层都无法命中,导致每次请求都直接打到数据库。在高并发场景下,这会将数据库压垮。本节深入剖析三种主流解决方案的原理、适用场景与协同策略。
2.1 缓存穿透的成因与危害
穿透的本质是缓存层失效与数据不存在的叠加效应。攻击者或异常流量利用这一点,构造大量不存在的 Key 发起请求,绕过缓存直接穿透到数据库。
| 维度 | 说明 |
|---|---|
| 触发条件 | 查询 Key 在缓存中不存在,且数据库中也不存在该记录 |
| 直接后果 | 每次请求都穿透缓存,数据库连接数飙升,响应延迟剧增 |
| 典型场景 | 恶意攻击(遍历不存在的 ID)、业务漏洞(前端未做参数校验) |
| 危害等级 | 高——可导致数据库连接池耗尽、服务雪崩 |
关键认知:缓存穿透与缓存击穿(热点 Key 过期)不同。穿透是数据本身不存在,击穿是数据存在但缓存刚好过期。治理策略完全不同,不可混淆。
2.2 布隆过滤器原理与实现
布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于集合中。它允许一定的误判率(假阳性),但绝不漏判(假阴性概率为 0)。
2.2.1 核心原理
- 位数组:初始化一个长度为 m 的二进制位数组,所有位初始为 0。
- 多个哈希函数:使用 k 个相互独立的哈希函数,每个函数将输入元素映射到位数组的某个位置。
- 插入操作:对每个元素,用 k 个哈希函数计算得到 k 个位置,将这些位置全部置为 1。
- 查询操作:对查询元素,同样计算 k 个哈希位置。如果所有位置都是 1,则元素可能存在;如果任意一个位置是 0,则元素一定不存在。
数学特性:误判率 p 与位数组长度 m、哈希函数个数 k、插入元素数量 n 相关。最优哈希函数个数 k = (m/n) * ln2。实际工程中,通常将误判率控制在 1% 以内。
2.2.2 工程实现要点
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 位数组长度 m | 预估数据量 n × 10 ~ 20 | 越大误判率越低,但内存占用增加 |
| 哈希函数个数 k | 7 ~ 13 | 根据 m/n 计算最优值 |
| 误判率 p | ≤ 1% | 业务可接受范围,过高会失去过滤意义 |
| 数据结构 | Redis Bitmap / Guava BloomFilter | 分布式场景用 Redis,单机用 Guava |
// Java 示例:使用 Guava 布隆过滤器
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
// 预估插入 100 万条数据,误判率 1%
BloomFilter<Integer> bloomFilter = BloomFilter.create(
Funnels.integerFunnel(), 1_000_000, 0.01);
// 插入所有合法 ID
for (Integer id : validIdList) {
bloomFilter.put(id);
}
// 查询时先过布隆过滤器
public Object queryWithBloom(String key) {
if (!bloomFilter.mightContain(key)) {
// 一定不存在,直接返回空
return null;
}
// 可能存在,继续查缓存 + 数据库
return queryFromCacheOrDB(key);
}
实践建议:布隆过滤器无法删除元素。如果业务需要频繁删除,考虑使用计数布隆过滤器(Counting Bloom Filter)或布谷鸟过滤器(Cuckoo Filter),后者支持删除且空间效率更高。
2.3 空值缓存策略
空值缓存(Cache-Through for Null)是最简单直接的穿透治理手段:当数据库查询结果为空时,仍然将该 Key 写入缓存,但 Value 标记为空值占位符,并设置较短的过期时间。
2.3.1 实现流程
- 请求到达,先查缓存。
- 缓存命中且值为空占位符,直接返回空(避免查库)。
- 缓存未命中,查数据库。
- 数据库结果为空,将 Key 写入缓存,Value 设为
NULL_PLACEHOLDER,TTL 设为 30~60 秒。 - 数据库结果非空,正常写入缓存并返回。
// 空值缓存伪代码
public Object queryWithNullCache(String key) {
// 1. 查缓存
Object cacheValue = cache.get(key);
if (cacheValue != null) {
if (NULL_PLACEHOLDER.equals(cacheValue)) {
return null; // 空值命中,直接返回
}
return cacheValue;
}
// 2. 缓存未命中,查数据库
Object dbValue = db.query(key);
if (dbValue == null) {
// 3. 空值缓存,TTL 设为 30 秒
cache.set(key, NULL_PLACEHOLDER, 30);
return null;
}
// 4. 正常缓存
cache.set(key, dbValue, 3600);
return dbValue;
}
2.3.2 优缺点对比
| 维度 | 布隆过滤器 | 空值缓存 |
|---|---|---|
| 空间开销 | 极低(位数组) | 较高(每个空 Key 占用完整缓存条目) |
| 误判风险 | 有(假阳性) | 无(精确判断) |
| 实现复杂度 | 中等(需维护哈希函数) | 低(简单 if-else) |
| 适用场景 | 海量 Key、内存敏感 | Key 数量可控、对精确性要求高 |
| 删除支持 | 困难(标准布隆不支持) | 容易(直接删除缓存条目) |
避坑指南:空值缓存的 TTL 不宜过长(建议 ≤ 60 秒),否则当真实数据写入后,缓存中仍然是空占位符,导致数据不一致。如果业务对一致性要求高,可在数据写入时主动删除空值缓存。
2.4 参数校验与限流
参数校验和限流是第一道防线,在请求进入缓存层之前就拦截非法流量。
2.4.1 参数校验策略
- 格式校验:ID 必须是正整数、UUID 格式、长度范围等。
- 业务规则校验:如订单号必须包含日期前缀、用户 ID 必须在合法范围内。
- 黑名单校验:对已知的恶意 Key 模式(如
../../etc/passwd)直接拒绝。
// 参数校验示例
public boolean validateKey(String key) {
// 1. 非空校验
if (key == null || key.trim().isEmpty()) return false;
// 2. 格式校验:必须是数字
if (!key.matches("\\d+")) return false;
// 3. 范围校验:ID 必须在 1 ~ 1亿 之间
long id = Long.parseLong(key);
if (id < 1 || id > 100_000_000) return false;
// 4. 黑名单校验
if (BLACKLIST.contains(key)) return false;
return true;
}
2.4.2 限流策略
限流用于控制单位时间内的请求速率,防止突发穿透流量打垮数据库。常用算法包括:
| 算法 | 特点 | 适用场景 |
|---|---|---|
| 令牌桶 | 允许突发流量,平滑速率 | 通用场景,推荐首选 |
| 漏桶 | 强制固定速率,不允许突发 | 对稳定性要求极高的场景 |
| 滑动窗口 | 精确控制时间窗口内的请求数 | 需要精确计数的场景 |
2.5 三种方案协同策略
在实际生产系统中,三种方案通常组合使用,形成纵深防御体系:
- 第一道防线(入口层):参数校验 + 限流,拦截明显非法的请求。
- 第二道防线(缓存层前置):布隆过滤器,快速过滤掉绝大多数不存在的 Key。
- 第三道防线(缓存层内部):空值缓存,兜底处理布隆过滤器的假阳性以及少量合法但数据不存在的请求。
推荐组合:布隆过滤器 + 空值缓存 + 限流。布隆过滤器拦截 99% 的不存在 Key,空值缓存处理剩余 1% 的假阳性,限流防止突发流量。三者配合可将数据库穿透请求降低 99.99% 以上。
2.6 本章小结
- 布隆过滤器:空间高效,适合海量 Key 场景,但存在假阳性且不支持删除。
- 空值缓存:实现简单,精确判断,但占用缓存空间,需注意 TTL 设置。
- 参数校验与限流:第一道防线,成本最低,必须前置。
- 协同策略:三层防护层层递进,缺一不可。