4. 缓存穿透:定义、产生原因与解决方案
4.1 定义与问题本质
缓存穿透是指查询一个根本不存在的数据时,缓存层无法命中(因为不存在),请求直接穿透到后端数据库。由于数据库中也不存在该记录,每次查询都会落到数据库上,导致数据库压力骤增,甚至引发雪崩。
核心特征:缓存层与数据库层均无目标数据,每次请求都穿透缓存直达数据库。
4.2 产生原因
| 原因类别 | 具体场景 | 典型示例 |
|---|---|---|
| 恶意攻击 | 攻击者构造大量不存在的 Key 发起请求 | 遍历不存在的用户 ID(如 -1、99999999) |
| 业务逻辑缺陷 | 前端未做参数校验,直接传入非法参数 | 空字符串、特殊字符、超长 ID |
| 数据误删/未同步 | 数据库记录已被删除,但缓存未及时清理 | 商品下架后,客户端仍请求旧 ID |
| 缓存预热遗漏 | 新上线功能未将热点数据提前加载到缓存 | 新活动页面未预加载商品详情 |
4.3 解决方案一:布隆过滤器(Bloom Filter)
布隆过滤器是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于集合中。它由一个位数组和多个哈希函数组成。
4.3.1 工作原理
- 初始化:创建一个长度为 m 的位数组,所有位初始化为 0。
- 添加元素:使用 k 个哈希函数对元素计算哈希值,将位数组中对应位置置为 1。
- 查询元素:同样使用 k 个哈希函数计算位置,如果所有位置均为 1,则元素可能存在;如果任意一位为 0,则元素一定不存在。
注意:布隆过滤器存在误判率(假阳性),即可能将不存在的元素误判为存在。但不会漏判(假阴性率为 0)。误判率可通过调整位数组长度和哈希函数数量来控制。
4.3.2 布隆过滤器 vs 传统缓存
| 对比维度 | 布隆过滤器 | 传统缓存(如 Redis String) |
|---|---|---|
| 空间占用 | 极低(位数组,每个元素仅占 1 bit) | 较高(存储完整 Key-Value) |
| 查询速度 | O(k),k 为哈希函数个数 | O(1) 哈希表查找 |
| 误判率 | 有(假阳性) | 无 |
| 删除支持 | 不支持(标准版) | 支持 |
| 适用场景 | 防止缓存穿透、爬虫去重 | 常规缓存读写 |
4.3.3 Redis 中的布隆过滤器
Redis 4.0+ 通过 RedisBloom 模块支持布隆过滤器。常用命令:
# 创建一个布隆过滤器,预期容量 10000,误判率 0.01
BF.RESERVE user_filter 0.01 10000
# 添加元素
BF.ADD user_filter user:1001
# 批量添加
BF.MADD user_filter user:1002 user:1003 user:1004
# 检查元素是否存在
BF.EXISTS user_filter user:1001 # (integer) 1
BF.EXISTS user_filter user:9999 # (integer) 0
# 批量检查
BF.MEXISTS user_filter user:1001 user:9999
4.4 解决方案二:空值缓存(Cache-Through for Null)
当查询数据库发现数据不存在时,仍然将空值(如 null、空字符串、特殊标记)写入缓存,并设置一个较短的过期时间(通常 30~60 秒)。后续相同请求直接返回空值,避免重复查询数据库。
4.4.1 实现流程
- 查询缓存:根据 Key 查询 Redis。
- 缓存命中:如果缓存中存在(即使是空值标记),直接返回结果。
- 缓存未命中:查询数据库。
- 数据库有数据:写入缓存(正常过期时间),返回结果。
- 数据库无数据:写入空值标记到缓存(短过期时间,如 30s),返回空。
4.4.2 代码示例
public String getProduct(String productId) {
// 1. 查询缓存
String cacheValue = redis.get("product:" + productId);
if (cacheValue != null) {
// 2. 命中缓存(包括空值标记)
if ("NULL_MARK".equals(cacheValue)) {
return null; // 直接返回空
}
return cacheValue;
}
// 3. 缓存未命中,查询数据库
Product product = productDao.selectById(productId);
if (product != null) {
// 4. 有数据,写入缓存(过期时间 1 小时)
redis.setex("product:" + productId, 3600, product.toJson());
return product.toJson();
} else {
// 5. 无数据,写入空值标记(过期时间 30 秒)
redis.setex("product:" + productId, 30, "NULL_MARK");
return null;
}
}
4.5 两种方案对比与选型
| 对比维度 | 布隆过滤器 | 空值缓存 |
|---|---|---|
| 空间开销 | 极低(位数组) | 中等(存储完整 Key) |
| 实现复杂度 | 较高(需引入模块/算法) | 低(简单 if-else) |
| 误判风险 | 有(假阳性) | 无 |
| 恶意攻击防护 | 优秀(直接拦截非法 Key) | 良好(但会占用缓存空间) |
| 数据一致性 | 需考虑布隆过滤器更新 | 空值过期后自动恢复 |
| 推荐场景 | 高并发、Key 空间大、恶意攻击频繁 | 中小规模、快速实现、Key 数量可控 |
最佳实践:大型系统通常组合使用两种方案——布隆过滤器作为第一道防线拦截大部分非法 Key,空值缓存作为兜底策略处理少量漏网之鱼。