3. 缓存穿透原理与防护:布隆过滤器、空值缓存、参数校验、限流降级
3.1 缓存穿透:定义与危害
缓存穿透是指查询一个根本不存在的数据。由于缓存中不存在该数据(缓存不命中),请求直接穿透到后端数据库;而数据库中同样不存在该记录,导致每次查询都落库,且返回空结果。当这种请求量巨大时(尤其是恶意攻击),数据库会承受巨大压力,甚至引发雪崩。
核心危害:缓存层完全失效,所有请求直击数据库,导致数据库连接耗尽、响应超时、服务不可用。
3.2 缓存穿透的典型场景
- 恶意攻击:攻击者构造大量不存在的 Key(如连续递增 ID 但实际已删除的记录),绕过缓存。
- 业务逻辑漏洞:用户查询非法参数(如负数 ID、超长字符串),后端未做校验直接查库。
- 数据未预热:新上线功能或数据迁移后,大量合法但尚未缓存的查询穿透。
3.3 四层防护体系总览
解决缓存穿透需要构建多层防线,从入口到存储层层拦截。下表对比了四种主流防护手段的定位与效果:
| 防护层 | 拦截位置 | 核心原理 | 优点 | 缺点 |
|---|---|---|---|---|
| 参数校验 | API 网关 / Controller | 拒绝非法、无效参数请求 | 成本极低,拦截大部分恶意攻击 | 无法拦截合法但不存在的数据 |
| 布隆过滤器 | 缓存层之前 | 快速判断 Key 是否可能存在 | 内存占用极小,性能极高 | 有误判率,不支持删除 |
| 空值缓存 | 缓存层 | 将空结果也缓存,设置短 TTL | 实现简单,直接保护数据库 | 占用额外缓存空间,存在短暂不一致 |
| 限流降级 | 全局 / 接口级 | 限制单位时间请求量,超出则降级 | 兜底保护,防止系统崩溃 | 影响正常用户,需精细配置 |
3.4 防护策略详解
3.4.1 参数校验:第一道防线
在请求进入业务逻辑前,对参数进行基础合法性校验,快速拒绝明显无效的请求。
// 示例:参数校验拦截非法请求
public Response<User> getUserById(Long userId) {
// 1. 基础校验
if (userId == null || userId <= 0) {
return Response.fail("参数非法");
}
// 2. 格式校验(如 UUID 格式)
if (!Pattern.matches("^[A-Za-z0-9-]+$", userId.toString())) {
return Response.fail("参数格式错误");
}
// ... 后续查询逻辑
}
最佳实践:参数校验应前置到 API 网关或 Spring 的 @Validated 注解,避免无效请求进入应用层。
3.4.2 布隆过滤器:高效预判
布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于集合中。它由位数组和多个哈希函数组成。
- 原理:将数据库中所有存在的 Key 预加载到布隆过滤器中。查询时,先检查布隆过滤器:如果返回“不存在”,则直接返回空,避免查库;如果返回“可能存在”,则继续查缓存或数据库。
- 特点:有误判率(可能把不存在的 Key 误判为存在),但不会漏判(存在的 Key 一定被识别)。
// 示例:Guava 布隆过滤器使用
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
// 初始化:预计插入 100 万条数据,误判率 1%
BloomFilter<Long> bloomFilter = BloomFilter.create(
Funnels.longFunnel(), 1_000_000, 0.01);
// 预热:将所有存在的用户 ID 加入
for (Long id : userIdListFromDB) {
bloomFilter.put(id);
}
// 查询时判断
public User getUser(Long userId) {
// 1. 布隆过滤器拦截
if (!bloomFilter.mightContain(userId)) {
return null; // 一定不存在,直接返回
}
// 2. 查缓存
User user = cache.get(userId);
if (user != null) return user;
// 3. 查数据库
user = db.get(userId);
if (user != null) {
cache.set(userId, user);
}
return user;
}
注意:布隆过滤器不支持删除操作。如果数据被删除,需要重建过滤器或使用 Counting Bloom Filter 等变种。
3.4.3 空值缓存:简单有效
当查询数据库发现数据不存在时,仍然将该 Key 写入缓存,但 Value 设置为空(如 null 或特殊标记),并设置一个较短的过期时间(如 30-60 秒)。
// 示例:空值缓存实现
public User getUserWithNullCache(Long userId) {
// 1. 查缓存
Object cacheValue = cache.get(userId);
if (cacheValue != null) {
// 判断是否是空值标记
if (NULL_MARK.equals(cacheValue)) {
return null; // 缓存中标记为空,直接返回
}
return (User) cacheValue;
}
// 2. 查数据库
User user = db.get(userId);
if (user == null) {
// 3. 缓存空值,TTL 设置为 30 秒
cache.set(userId, NULL_MARK, 30);
return null;
}
// 4. 缓存真实数据
cache.set(userId, user, 3600);
return user;
}
优化建议:空值缓存应与布隆过滤器配合使用。布隆过滤器拦截大部分不存在 Key,空值缓存作为兜底,处理布隆过滤器误判的那部分请求。
3.4.4 限流降级:终极兜底
当上述防护均未能完全阻挡穿透流量时,限流降级作为最后一道防线,保护数据库不被击垮。
- 限流:对查询接口进行速率限制(如令牌桶、滑动窗口),超出阈值的请求直接返回“服务繁忙”或降级结果。
- 降级:当检测到数据库压力过大时,主动返回默认值、缓存中的旧数据或错误提示,避免数据库崩溃。
// 示例:基于 Sentinel 的限流降级配置(伪代码)
@SentinelResource(value = "getUser", blockHandler = "blockHandler")
public User getUser(Long userId) {
// ... 业务逻辑
}
// 限流后的降级方法
public User blockHandler(Long userId, BlockException ex) {
// 返回降级结果:从本地缓存获取或返回默认值
return localCache.getOrDefault(userId, User.getDefaultUser());
}
3.5 四层防护协作流程
下图展示了请求经过四层防护的完整链路:
3.6 方案对比与选型建议
在实际项目中,应根据业务场景和数据特点选择合适的防护组合:
| 业务场景 | 推荐方案 | 原因 |
|---|---|---|
| 高并发、Key 数量大(亿级) | 布隆过滤器 + 参数校验 | 内存占用低,性能损耗小 |
| Key 数量小、变化频繁 | 空值缓存 + 限流降级 | 实现简单,无需维护布隆过滤器 |
| 安全合规要求高(防恶意攻击) | 四层全上 | 层层拦截,最大限度保护数据库 |
| 读多写少、数据稳定 | 布隆过滤器 + 空值缓存 | 布隆过滤器预热后几乎无变化 |
核心结论:没有银弹。缓存穿透防护需要组合使用多种策略,形成“校验 → 预判 → 缓存 → 限流”的纵深防御体系。布隆过滤器适合大规模、相对静态的数据集;空值缓存适合小规模或动态变化的数据;参数校验和限流降级是所有系统都应具备的基础能力。