4. 缓存穿透:定义、产生原因、解决方案
4.1 定义与现象
缓存穿透是指客户端请求的数据在缓存和数据库中都不存在,导致每次请求都直接穿透缓存层,落到后端数据库上。由于缓存无法命中,这些请求会持续对数据库造成压力,严重时可能导致数据库连接耗尽或响应超时。
核心特征:缓存和数据库均无目标数据 → 请求绕过缓存 → 数据库负载飙升。
4.2 产生原因
| 原因分类 | 典型场景 | 危害等级 |
|---|---|---|
| 恶意攻击 | 攻击者构造大量不存在的 Key(如负 ID、超长字符串)发起请求 | 高 |
| 业务逻辑缺陷 | 前端未做参数校验,直接传入非法或空值 | 中 |
| 数据误删/未同步 | 数据库记录已被删除,但缓存未及时清理,请求仍按旧 Key 访问 | 中 |
| 缓存预热遗漏 | 新上线功能未将历史数据提前加载到缓存,导致首次请求全部穿透 | 低 |
4.3 解决方案对比
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 布隆过滤器 | 使用位数组 + 多个哈希函数判断 Key 是否存在 | 内存占用极低;查询速度快(O(k)) | 存在误判率;不支持删除元素 | 高并发、Key 空间大、允许少量误判 |
| 缓存空对象 | 将查询结果为 null 的 Key 也缓存起来,设置较短 TTL | 实现简单;无额外数据结构 | 占用额外缓存空间;存在短期数据不一致 | 中小规模、Key 总数可控 |
4.4 布隆过滤器(Bloom Filter)
布隆过滤器是一种空间效率极高的概率型数据结构,用于判断一个元素是否在集合中。它允许一定的误判率(False Positive),但不会漏判(False Negative)。
4.4.1 布隆过滤器实战代码(Java + Redisson)
// 1. 初始化布隆过滤器
RBloomFilter<String> bloomFilter = redissonClient
.getBloomFilter("user:id:bloom", 5000000L, 0.01);
bloomFilter.tryInit(5000000L, 0.01); // 预计元素500万,误判率1%
// 2. 预热:将所有合法用户ID加入过滤器
List<Long> allUserIds = userMapper.selectAllIds();
for (Long userId : allUserIds) {
bloomFilter.add(String.valueOf(userId));
}
// 3. 查询拦截
public User getUserById(Long userId) {
String key = String.valueOf(userId);
// 布隆过滤器前置拦截
if (!bloomFilter.contains(key)) {
log.warn("缓存穿透拦截 - 非法ID: {}", userId);
return null; // 直接返回,不查数据库
}
// 查缓存
User user = redisTemplate.opsForValue().get(key);
if (user != null) {
return user;
}
// 查数据库
user = userMapper.selectById(userId);
if (user != null) {
redisTemplate.opsForValue().set(key, user, 1, TimeUnit.HOURS);
}
return user;
}
最佳实践:布隆过滤器应随数据库全量数据一起初始化,并定期(如每天凌晨)重建,以同步新增/删除的数据。
4.5 缓存空对象
当查询数据库返回空结果时,仍然将该 Key 写入缓存,但 Value 设置为一个特殊标记(如空字符串或占位对象),并设置较短的过期时间(通常 30~60 秒)。后续相同请求在 TTL 内直接命中缓存空对象,避免重复穿透到数据库。
4.5.1 缓存空对象实战代码
public User getUserById(Long userId) {
String key = "user:" + userId;
// 1. 查缓存
String cachedValue = redisTemplate.opsForValue().get(key);
if (cachedValue != null) {
// 命中缓存空对象标记
if ("NULL_PLACEHOLDER".equals(cachedValue)) {
log.info("命中空对象缓存,直接返回 null");
return null;
}
return JSON.parseObject(cachedValue, User.class);
}
// 2. 查数据库
User user = userMapper.selectById(userId);
// 3. 无论是否为空,都写入缓存
if (user == null) {
// 缓存空对象,TTL 设为 30 秒
redisTemplate.opsForValue().set(key, "NULL_PLACEHOLDER", 30, TimeUnit.SECONDS);
log.warn("数据库无数据,缓存空对象: {}", key);
return null;
}
// 正常数据缓存 1 小时
redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 1, TimeUnit.HOURS);
return user;
}
4.6 两种方案对比总结
| 对比维度 | 布隆过滤器 | 缓存空对象 |
|---|---|---|
| 内存开销 | 极低(位数组) | 较高(存储大量空 Key) |
| 实现复杂度 | 中等(需引入额外组件) | 简单(仅需缓存逻辑) |
| 误判风险 | 有(可能误判存在) | 无(精确判断) |
| 数据一致性 | 需定期重建 | TTL 过期后自动恢复 |
| 适用规模 | 海量 Key(千万级以上) | 中小规模(百万级以内) |
| 防御恶意攻击 | 优秀(直接拦截非法 Key) | 一般(仍会占用缓存空间) |
4.7 组合策略推荐
在生产环境中,建议将两种方案组合使用,形成多层防护:
- 第一层:参数校验 — 在 API 网关或 Controller 层校验 ID 格式、范围,拒绝明显非法的请求。
- 第二层:布隆过滤器 — 拦截大部分不存在的 Key,降低数据库无效查询。
- 第三层:缓存空对象 — 对布隆过滤器误判的少量请求,以及合法但数据确实不存在的场景,提供短时间缓存保护。
- 第四层:数据库限流与熔断 — 作为兜底,防止突发流量击穿所有防护层。
注意:布隆过滤器无法删除元素。如果业务需要频繁删除 Key,建议使用布隆过滤器的变种(如 Counting Bloom Filter)或定期重建过滤器。缓存空对象的 TTL 不宜过长,否则可能导致数据长期不一致。