3. 缓存穿透问题:定义、产生原因、危害分析、布隆过滤器原理与实现
3.1 缓存穿透的定义与产生原因
缓存穿透是指客户端请求的数据在缓存层和数据库层中都不存在,导致每次请求都直接穿透缓存层,直达数据库层。由于缓存层无法命中,所有请求压力全部落在数据库上,形成“穿透”效应。
| 对比维度 | 缓存穿透 | 缓存击穿 | 缓存雪崩 |
|---|---|---|---|
| 数据存在性 | 缓存和数据库均不存在 | 缓存过期,数据库存在 | 缓存大面积过期,数据库存在 |
| 请求特征 | 大量请求查询不存在的数据 | 高并发请求同一热点 key | 大量 key 同时失效 |
| 根本原因 | 恶意攻击或业务漏洞 | 热点 key 过期 | 缓存集中失效 |
| 影响范围 | 数据库连接耗尽 | 单点数据库压力 | 数据库整体崩溃 |
产生原因主要包括以下三类:
- 恶意攻击:攻击者构造大量不存在的 ID(如负数、超大值、随机字符串)发起请求,绕过缓存直接攻击数据库。
- 业务逻辑缺陷:查询参数未做合法性校验,例如用户 ID 为 null 或格式错误时直接查询数据库。
- 数据删除未同步:数据库中的数据已被删除,但缓存未及时清理,导致后续请求直接查库。
⚠️ 风险提示:缓存穿透是三种缓存异常中最容易被忽视的一种。很多团队只关注缓存击穿和雪崩,却忽略了“查不到”的请求同样能拖垮数据库。一次 DDoS 攻击利用缓存穿透,可在 10 秒内耗尽数据库连接池。
3.2 危害分析
缓存穿透的危害呈现指数级放大特征:
- 数据库连接耗尽:每个穿透请求都会占用一个数据库连接,当并发量超过连接池上限时,数据库拒绝服务。
- 响应延迟飙升:数据库 CPU 和 IO 被打满,正常请求的响应时间从毫秒级上升到秒级甚至超时。
- 级联故障:数据库崩溃后,依赖该数据库的其他服务(如订单、支付)也会连锁故障。
- 成本浪费:云数据库按连接数和 QPS 计费,穿透请求直接造成无意义的资源消耗。
📊 典型数据:某电商平台在促销期间遭遇缓存穿透攻击,10 分钟内数据库 QPS 从 2000 飙升至 50000,导致数据库连接池溢出,核心交易链路中断 23 分钟,直接经济损失超过 200 万元。
3.3 布隆过滤器原理
布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于集合中。它由一个位数组和多个哈希函数组成。
核心原理:
- 初始化:创建一个长度为 m 的位数组,所有位初始化为 0。
- 添加元素:使用 k 个哈希函数对元素计算哈希值,将位数组中对应位置置为 1。
- 查询元素:同样使用 k 个哈希函数计算哈希值,检查对应位是否全部为 1。如果任意一位为 0,则元素一定不存在;如果全部为 1,则元素可能存在(存在误判率)。
💡 关键特性:布隆过滤器不会漏报(如果元素不存在,一定返回不存在),但可能误报(如果元素存在,可能返回不存在)。误判率可以通过调整位数组大小和哈希函数数量来控制。
3.4 布隆过滤器实现
在 Spring Boot 项目中,推荐使用 Google Guava 提供的 BloomFilter 实现。以下是完整的集成步骤和代码示例。
步骤一:添加 Maven 依赖
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
</dependency>
步骤二:初始化布隆过滤器
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
@Component
public class UserIdBloomFilter {
// 预计插入 100 万条数据,误判率 1%
private static final int EXPECTED_INSERTIONS = 1_000_000;
private static final double FPP = 0.01;
private BloomFilter<Long> bloomFilter;
@PostConstruct
public void init() {
bloomFilter = BloomFilter.create(
Funnels.longFunnel(),
EXPECTED_INSERTIONS,
FPP
);
// 模拟从数据库加载已有用户 ID
for (long i = 1; i <= 100_000; i++) {
bloomFilter.put(i);
}
}
public boolean mightContain(Long userId) {
return bloomFilter.mightContain(userId);
}
public void put(Long userId) {
bloomFilter.put(userId);
}
}
步骤三:在缓存查询中使用布隆过滤器
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class UserService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private UserIdBloomFilter userIdBloomFilter;
public User getUserById(Long userId) {
// 第一步:布隆过滤器前置拦截
if (!userIdBloomFilter.mightContain(userId)) {
// 一定不存在,直接返回空,避免穿透
return null;
}
// 第二步:查询缓存
String cacheKey = "user:" + userId;
String userJson = redisTemplate.opsForValue().get(cacheKey);
if (userJson != null) {
return JSON.parseObject(userJson, User.class);
}
// 第三步:查询数据库
User user = userMapper.selectById(userId);
if (user != null) {
// 写入缓存
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 1, TimeUnit.HOURS);
}
return user;
}
}
💡 最佳实践:
- 误判率设置:建议设置为 1% 以下,每降低一个数量级,位数组大小约增加 1.44 倍。
- 动态更新:当新增用户时,需要同步调用
bloomFilter.put()更新布隆过滤器。 - 定期重建:布隆过滤器不支持删除元素,建议每天凌晨低峰期重建一次,从数据库全量加载。
- 分布式场景:使用 Redis 的 BitMap 实现分布式布隆过滤器,避免单机内存限制。
3.5 缓存穿透解决方案对比
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 布隆过滤器 | 位数组 + 哈希函数前置过滤 | 空间效率极高,性能好 | 存在误判率,不支持删除 | 数据量大、ID 连续或可枚举 |
| 缓存空对象 | 查询不到的数据也缓存空值 | 实现简单,无误判 | 占用缓存空间,需设置短过期时间 | 数据量小、穿透频率低 |
| 参数校验 | 前置拦截非法参数 | 零成本,无副作用 | 只能拦截格式异常请求 | 所有场景必须做 |
| 限流降级 | 限制单位时间穿透请求数 | 保护数据库不被打满 | 影响正常请求,治标不治本 | 突发流量保护 |
🔧 推荐组合策略:参数校验(第一道防线)→ 布隆过滤器(第二道防线)→ 缓存空对象(第三道防线)→ 限流降级(兜底策略)。四层防护可有效抵御 99.9% 的缓存穿透攻击。