4. 缓存穿透:定义、产生原因、布隆过滤器原理与Redis实现、空值缓存策略

4.1 缓存穿透的定义与产生原因

缓存穿透是指查询一个根本不存在的数据,由于缓存层不命中,请求直接穿透到后端数据库(如 MySQL、Elasticsearch),导致数据库承受巨大压力。这类请求通常由恶意攻击或系统缺陷引发,其核心特征是:缓存中永远不存在该 key,每次请求都直达数据库。

维度 正常缓存流程 缓存穿透流程
请求 key 存在(如 user:1001) 不存在(如 user:-1 或随机 ID)
缓存命中 ✅ 直接返回 ❌ 未命中
数据库查询 有数据,回写缓存 无数据,不写缓存
后续请求 命中缓存 继续穿透到 DB

常见产生原因:

  • 恶意攻击:攻击者构造大量不存在的 key(如负数 ID、超长字符串)发起高频请求。
  • 业务逻辑缺陷:前端未做参数校验,后端直接使用非法参数查询数据库。
  • 数据误删除:缓存中 key 被淘汰,但数据库中对应记录已被物理删除。
  • 爬虫或遍历:爬虫遍历不存在的资源路径,导致大量无效查询。
⚠️ 穿透危害:当 QPS 达到万级甚至十万级时,穿透请求可直接打垮数据库连接池,引发级联雪崩。必须在上游缓存层拦截。

4.2 布隆过滤器原理

布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于集合中。它允许一定的误判率(假阳性),但绝不漏判(假阴性率为 0)。

核心原理:

  1. 位数组:初始化一个长度为 m 的 bit 数组,所有位初始为 0。
  2. 多个哈希函数:使用 k 个相互独立的哈希函数,每个函数将元素映射到位数组的某个位置。
  3. 添加元素:对元素执行 k 次哈希,将对应位全部置为 1。
  4. 查询元素:对元素执行 k 次哈希,检查所有对应位是否都为 1:
    • 如果任意一位为 0 → 元素一定不存在。
    • 如果所有位都为 1 → 元素可能存在(存在误判风险)。
📐 误判率公式:误判率 p ≈ (1 - e-kn/m)k,其中 n 为预期元素数量,m 为位数组长度,k 为哈希函数个数。工程上通常取 m = 10n,k = 7,误判率可控制在 1% 以下。
布隆过滤器原理流程 布隆过滤器原理流程 元素 X k 个哈希函数 H₁, H₂, ..., Hₖ 位数组(长度 m) 0 1 0 1 1 0 0 1 ... 1 0 检查 k 个位 是否全部为 1? ❌ 一定不存在 拦截,不查 DB ✅ 可能存在 放行,查 DB 验证 假阳性率 < 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 秒)。

实现流程:

  1. 请求到达,先查缓存。
  2. 缓存命中且值为 null 标记 → 直接返回空,不查数据库。
  3. 缓存未命中 → 查询数据库。
  4. 数据库返回空 → 将 key 写入缓存,value 设为 null,TTL 设为 30 秒。
  5. 数据库返回数据 → 正常回写缓存,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,防止数据不一致
缓存穿透解决方案架构对比 缓存穿透解决方案架构对比 方案一:布隆过滤器 客户端请求 布隆过滤器 Redis 缓存 数据库 不存在则拦截 存在则放行 缓存未命中时回源 方案二:空值缓存 客户端请求 Redis 缓存 空值标记 数据库 命中空值直接返回 未命中则查 DB DB 空时写入空值

4.5 综合策略与选型建议

在实际生产系统中,通常将布隆过滤器与空值缓存结合使用,形成多层防护:

  • 第一层:布隆过滤器 — 拦截绝大多数不存在的 key,内存开销极低,适合海量 key 场景。
  • 第二层:空值缓存 — 兜底处理布隆过滤器的假阳性误判,以及少量合法但数据已删除的 key。
  • 第三层:数据库限流与熔断 — 当穿透流量超出阈值时,触发降级保护。
🔧 生产推荐配置:
• 布隆过滤器:误判率 0.1%~1%,容量 = 数据库总行数 × 1.2(预留增长空间)。
• 空值缓存 TTL:30~60 秒,避免长时间缓存空值导致数据不一致。
• 监控报警:实时监控缓存命中率与数据库 QPS,当命中率突降时自动告警。