4. 缓存穿透:定义、产生原因、布隆过滤器原理与Redis实现、空值缓存策略
4.1 缓存穿透的定义与产生原因
缓存穿透是指查询一个根本不存在的数据,由于缓存层不命中,请求直接穿透到后端数据库(如 MySQL、Elasticsearch),导致数据库承受巨大压力。这类请求通常由恶意攻击或系统缺陷引发,其核心特征是:缓存中永远不存在该 key,每次请求都直达数据库。
| 维度 | 正常缓存流程 | 缓存穿透流程 |
|---|---|---|
| 请求 key | 存在(如 user:1001) | 不存在(如 user:-1 或随机 ID) |
| 缓存命中 | ✅ 直接返回 | ❌ 未命中 |
| 数据库查询 | 有数据,回写缓存 | 无数据,不写缓存 |
| 后续请求 | 命中缓存 | 继续穿透到 DB |
常见产生原因:
- 恶意攻击:攻击者构造大量不存在的 key(如负数 ID、超长字符串)发起高频请求。
- 业务逻辑缺陷:前端未做参数校验,后端直接使用非法参数查询数据库。
- 数据误删除:缓存中 key 被淘汰,但数据库中对应记录已被物理删除。
- 爬虫或遍历:爬虫遍历不存在的资源路径,导致大量无效查询。
⚠️ 穿透危害:当 QPS 达到万级甚至十万级时,穿透请求可直接打垮数据库连接池,引发级联雪崩。必须在上游缓存层拦截。
4.2 布隆过滤器原理
布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于集合中。它允许一定的误判率(假阳性),但绝不漏判(假阴性率为 0)。
核心原理:
- 位数组:初始化一个长度为 m 的 bit 数组,所有位初始为 0。
- 多个哈希函数:使用 k 个相互独立的哈希函数,每个函数将元素映射到位数组的某个位置。
- 添加元素:对元素执行 k 次哈希,将对应位全部置为 1。
- 查询元素:对元素执行 k 次哈希,检查所有对应位是否都为 1:
- 如果任意一位为 0 → 元素一定不存在。
- 如果所有位都为 1 → 元素可能存在(存在误判风险)。
📐 误判率公式:误判率 p ≈ (1 - e-kn/m)k,其中 n 为预期元素数量,m 为位数组长度,k 为哈希函数个数。工程上通常取 m = 10n,k = 7,误判率可控制在 1% 以下。
4.3 布隆过滤器的 Redis 实现
Redis 在 4.0 版本后通过 Module 系统 提供了官方布隆过滤器模块 RedisBloom。生产环境推荐使用该模块,而非自行用 bit 位图实现。
安装与加载:
# 下载并编译 RedisBloom
git clone https://github.com/RedisBloom/RedisBloom.git
cd RedisBloom
make
# 在 redis.conf 中加载模块
loadmodule /path/to/redisbloom.so
# 或运行时动态加载
MODULE LOAD /path/to/redisbloom.so
核心命令:
| 命令 | 功能 | 示例 |
|---|---|---|
BF.RESERVE |
创建布隆过滤器,指定容量和误判率 | BF.RESERVE user_filter 0.01 1000000 |
BF.ADD |
向过滤器中添加元素 | BF.ADD user_filter user:1001 |
BF.MADD |
批量添加元素 | BF.MADD user_filter user:1001 user:1002 |
BF.EXISTS |
检查元素是否存在 | BF.EXISTS user_filter user:1001 |
BF.MEXISTS |
批量检查元素是否存在 | BF.MEXISTS user_filter user:1001 user:9999 |
Java(Jedis)代码示例:
// 引入依赖:redis.clients:jedis:4.x
Jedis jedis = new Jedis("localhost", 6379);
// 创建布隆过滤器:预期 100 万元素,误判率 1%
jedis.sendCommand(Protocol.Command.valueOf("BF.RESERVE"),
"user_filter".getBytes(),
"0.01".getBytes(),
"1000000".getBytes());
// 添加已存在的用户 ID
jedis.sendCommand(Protocol.Command.valueOf("BF.ADD"),
"user_filter".getBytes(),
"user:1001".getBytes());
// 查询用户是否存在
boolean exists = jedis.sendCommand(Protocol.Command.valueOf("BF.EXISTS"),
"user_filter".getBytes(),
"user:9999".getBytes()) == 1L;
if (!exists) {
// 一定不存在,直接返回空,不查数据库
return null;
} else {
// 可能存在,继续查缓存或数据库
}
💡 最佳实践:布隆过滤器应在系统启动时预热,将数据库中所有有效主键批量加载到过滤器中。对于新增数据,务必同步
BF.ADD,保持过滤器与数据库一致。
4.4 空值缓存策略
空值缓存是另一种解决缓存穿透的经典方案。当数据库查询结果为空时,仍然将该 key 写入缓存,但 value 设置为一个特殊标记(如 null 或 EMPTY),并设置一个较短的过期时间(如 30~60 秒)。
实现流程:
- 请求到达,先查缓存。
- 缓存命中且值为
null标记 → 直接返回空,不查数据库。 - 缓存未命中 → 查询数据库。
- 数据库返回空 → 将 key 写入缓存,value 设为
null,TTL 设为 30 秒。 - 数据库返回数据 → 正常回写缓存,TTL 设为正常值(如 1 小时)。
Redis 命令示例:
// 查询缓存
GET user:9999
// 返回 nil(不存在)或 "NULL_MARKER"(空值标记)
// 数据库查询为空时,写入空值标记
SET user:9999 "NULL_MARKER" EX 30 NX
// 正常数据回写
SET user:1001 "{...}" EX 3600
空值缓存 vs 布隆过滤器对比:
| 维度 | 布隆过滤器 | 空值缓存 |
|---|---|---|
| 内存占用 | 极低(位数组,约 1.2MB/百万元素) | 较高(每个空 key 存储完整 key+value) |
| 误判风险 | 有假阳性(可能放行不存在 key) | 无误判(精确拦截) |
| 实现复杂度 | 需加载模块或自行实现 | 简单,仅需业务逻辑判断 |
| 适用场景 | 海量 key、高 QPS、key 空间固定 | key 数量有限、可接受短时空值缓存 |
| 过期管理 | 无需过期,持久有效 | 需设置短 TTL,防止数据不一致 |
4.5 综合策略与选型建议
在实际生产系统中,通常将布隆过滤器与空值缓存结合使用,形成多层防护:
- 第一层:布隆过滤器 — 拦截绝大多数不存在的 key,内存开销极低,适合海量 key 场景。
- 第二层:空值缓存 — 兜底处理布隆过滤器的假阳性误判,以及少量合法但数据已删除的 key。
- 第三层:数据库限流与熔断 — 当穿透流量超出阈值时,触发降级保护。
🔧 生产推荐配置:
• 布隆过滤器:误判率 0.1%~1%,容量 = 数据库总行数 × 1.2(预留增长空间)。
• 空值缓存 TTL:30~60 秒,避免长时间缓存空值导致数据不一致。
• 监控报警:实时监控缓存命中率与数据库 QPS,当命中率突降时自动告警。
• 布隆过滤器:误判率 0.1%~1%,容量 = 数据库总行数 × 1.2(预留增长空间)。
• 空值缓存 TTL:30~60 秒,避免长时间缓存空值导致数据不一致。
• 监控报警:实时监控缓存命中率与数据库 QPS,当命中率突降时自动告警。