4. 缓存穿透与解决方案:布隆过滤器原理、空值缓存、接口层校验
缓存穿透是指查询一个根本不存在的数据,由于缓存层不命中,请求直接穿透到后端数据库(如 MySQL、MongoDB),导致数据库压力骤增,甚至引发雪崩。本章将深入剖析穿透的成因,并系统讲解三种主流解决方案:布隆过滤器、空值缓存与接口层校验。
核心认知:缓存穿透的本质是“缓存层失效”与“数据不存在”的叠加效应。解决方案的核心思路是:在请求到达数据库之前,尽可能早地拦截掉无效查询。
4.1 缓存穿透的成因与危害
穿透通常由以下场景触发:
- 恶意攻击:攻击者构造大量不存在的 Key(如连续递增的 ID 但实际已删除),绕过缓存直接攻击数据库。
- 业务误操作:前端或上游服务传入非法参数(如负数 ID、超长字符串),导致查询必然为空。
- 缓存与数据库数据不一致:数据已被删除,但缓存未及时失效,导致后续查询穿透。
| 对比维度 | 正常查询(命中缓存) | 缓存穿透 |
|---|---|---|
| 请求路径 | Client → Redis → 返回 | Client → Redis(未命中) → DB → 空结果 |
| 数据库压力 | 无 | 极高(大量无效查询) |
| 响应延迟 | 毫秒级 | 秒级或超时 |
| 系统稳定性 | 稳定 | 极易引发雪崩 |
4.2 解决方案一:接口层校验
接口层校验是第一道防线,在请求进入缓存层之前,对参数进行合法性检查,快速拒绝明显无效的请求。
4.2.1 校验策略
- 参数格式校验:ID 必须为正整数、字符串长度限制、枚举值范围检查。
- 业务规则校验:如订单状态只能查询已存在的状态码、用户 ID 必须存在于用户表中。
- 频率限制:对同一 IP 或 Token 的请求速率进行限流(如 100 QPS)。
// 接口层校验示例(Java)
public Response<User> getUserById(Long userId) {
// 1. 参数基础校验
if (userId == null || userId <= 0) {
return Response.fail("参数错误:userId 必须为正整数");
}
// 2. 业务规则校验(如用户 ID 范围)
if (userId > MAX_VALID_USER_ID) {
return Response.fail("参数错误:userId 超出合法范围");
}
// 3. 限流校验(伪代码)
if (!rateLimiter.tryAcquire(userId.toString())) {
return Response.fail("请求过于频繁,请稍后重试");
}
// 4. 进入缓存查询
return cacheService.getUser(userId);
}
最佳实践:接口层校验应放在 API 网关或 Controller 层,使用 AOP 或过滤器统一处理,避免在每个 Service 中重复编写。
4.3 解决方案二:空值缓存
当数据库查询结果为空时,仍然将该 Key 写入缓存,并设置一个较短的过期时间(如 30~60 秒)。这样后续相同的请求可以直接命中缓存,避免穿透到数据库。
4.3.1 实现要点
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 空值缓存 TTL | 30~60 秒 | 太短无法有效拦截,太长可能导致数据不一致 |
| 缓存 Value | 特殊标记(如 "NULL" 或空 JSON) | 与正常数据区分,避免业务误判 |
| 缓存 Key 生成 | 与正常 Key 相同 | 保证查询路径一致 |
// 空值缓存实现伪代码
public User getUser(Long userId) {
// 1. 查缓存
Object cacheValue = redis.get("user:" + userId);
if (cacheValue != null) {
// 2. 判断是否是空值标记
if ("NULL".equals(cacheValue)) {
return null; // 直接返回空,不查 DB
}
return (User) cacheValue;
}
// 3. 缓存未命中,查数据库
User user = userDao.findById(userId);
if (user == null) {
// 4. 写入空值缓存,TTL 30 秒
redis.setex("user:" + userId, 30, "NULL");
return null;
}
// 5. 写入正常缓存,TTL 1 小时
redis.setex("user:" + userId, 3600, user);
return user;
}
注意:空值缓存会占用 Redis 内存。如果攻击者构造大量不同的无效 Key,可能导致内存暴涨。建议配合布隆过滤器使用,或对空值 Key 设置更短的 TTL(如 10 秒)。
4.4 解决方案三:布隆过滤器
布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素一定不存在或可能存在于一个集合中。它由一个位数组和多个哈希函数组成。
4.4.1 工作原理
- 初始化:创建一个长度为 m 的位数组,所有位初始化为 0。
- 添加元素:使用 k 个哈希函数对元素计算哈希值,将位数组中对应位置置为 1。
- 查询元素:同样使用 k 个哈希函数计算,如果所有对应位都是 1,则元素可能存在;如果任意一位为 0,则元素一定不存在。
4.4.2 在 Redis 中使用布隆过滤器
Redis 4.0 之后提供了 Bloom Filter 模块(redisbloom),支持以下命令:
| 命令 | 功能 | 示例 |
|---|---|---|
BF.ADD |
向布隆过滤器添加元素 | BF.ADD user_filter 1001 |
BF.EXISTS |
检查元素是否存在 | BF.EXISTS user_filter 1001 → (integer) 1 |
BF.RESERVE |
创建布隆过滤器并指定参数 | BF.RESERVE user_filter 0.01 1000000 |
BF.MADD |
批量添加元素 | BF.MADD user_filter 1001 1002 1003 |
// 布隆过滤器 + 缓存穿透防护示例
public User getUserWithBloom(Long userId) {
// 1. 先检查布隆过滤器
if (!bloomFilter.exists("user_filter", userId)) {
// 一定不存在,直接返回 null,避免查缓存和 DB
return null;
}
// 2. 查缓存
Object cacheValue = redis.get("user:" + userId);
if (cacheValue != null) {
return (User) cacheValue;
}
// 3. 查数据库
User user = userDao.findById(userId);
if (user != null) {
redis.setex("user:" + userId, 3600, user);
}
return user;
}
4.4.3 误判率与参数选择
布隆过滤器存在误判率(False Positive),即可能将不存在的元素误判为存在。误判率与以下参数相关:
- m:位数组长度(越大误判率越低)
- k:哈希函数个数(越多误判率越低,但计算开销增大)
- n:已插入元素数量
经验公式:m = -n * ln(p) / (ln(2)^2),其中 p 为期望误判率。例如,期望误判率 1%,插入 100 万元素,需要约 9.6 MB 位数组。
架构建议:布隆过滤器适合“冷启动”场景,即数据量相对固定且可预加载。对于动态变化的数据,需要定期重建过滤器或使用 Redis 的
BF.RESERVE 动态扩容。
4.5 三种方案对比与选型
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 接口层校验 | 实现简单、零存储开销、实时生效 | 只能拦截明显非法请求,无法拦截“合法但不存在”的 Key | 所有系统的基础防线,必须启用 |
| 空值缓存 | 实现简单、对任意 Key 有效 | 占用缓存内存、存在短暂数据不一致窗口 | Key 空间较小或攻击 Key 重复率高的场景 |
| 布隆过滤器 | 内存效率极高、可拦截大量无效 Key | 存在误判率、无法删除元素、需要预加载 | Key 空间大、数据相对固定的场景 |
推荐组合:接口层校验(必选)+ 布隆过滤器(核心防护)+ 空值缓存(兜底)。三层防护可以覆盖 99.9% 以上的穿透场景。