2、缓存穿透问题:定义与危害、解决方案
好,咱们接着聊缓存穿透。这问题我估计不少人都遇到过,说白了就是——请求绕过了缓存,直接打到了数据库上。
什么意思呢?正常流程是:请求先查缓存,缓存有就直接返回,缓存没有再去查数据库。但缓存穿透是,你查的 key 在缓存里不存在,在数据库里也不存在。每次请求都直接穿透缓存,怼到数据库上。如果这种请求量一大,数据库就扛不住了。
2.1 缓存穿透的定义
我习惯这么定义它:缓存穿透是指查询一个根本不存在的数据。缓存层和存储层都没有这个数据,但请求还是源源不断地打过来。
举个例子。你有个用户信息接口,前端传了个 user_id=999999。缓存里没有,数据库里也没有。但攻击者可以伪造大量不存在的 ID,比如 1000000、1000001……每个请求都穿透缓存,直接查数据库。数据库压力瞬间飙升。
核心问题:缓存层失效,存储层直接暴露在恶意流量下。
2.2 缓存穿透的危害
危害其实很直接,我总结了几点:
- 数据库压力暴增:大量无效查询打到数据库,连接池被占满,正常业务也受影响。
- 系统响应变慢:数据库扛不住,查询变慢,接口响应时间从毫秒级变成秒级。
- 可能引发雪崩:数据库挂了,依赖它的服务全崩,连锁反应。
我记得有一次线上事故,就是被人用脚本扫了一堆不存在的商品 ID。缓存全部穿透,数据库 CPU 直接飙到 100%,接口大面积超时。嗯,那次之后我们才认真搞了防穿透方案。
2.3 解决方案一:布隆过滤器
布隆过滤器,说白了就是一个很省内存的“存在性判断”工具。它能告诉你:这个 key 大概率存在,或者一定不存在。
原理不复杂。它用多个哈希函数,把一个 key 映射到位数组的几个位置上。查询时,如果这几个位置都是 1,就说 key 可能存在;只要有一个是 0,那 key 肯定不存在。
我的经验:布隆过滤器有误判率,但可以接受。一般控制在 1% 以内就行。误判的意思是,它可能说“存在”,但实际不存在。不过它不会说“不存在”但实际存在——这个特性很关键。
实战中怎么用?我一般这么搞:
- 系统启动时,把所有合法 key 加载到布隆过滤器里。
- 每次请求进来,先查布隆过滤器。
- 如果过滤器说“不存在”,直接返回空,不查缓存也不查数据库。
- 如果过滤器说“存在”,再走正常的缓存→数据库流程。
来看代码:
// 初始化布隆过滤器
BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.forName("UTF-8")),
1000000, // 预计元素数量
0.01 // 误判率 1%
);
// 系统启动时,加载所有合法 key
for (String key : allValidKeys) {
bloomFilter.put(key);
}
// 请求处理
public Object getData(String key) {
// 第一步:布隆过滤器判断
if (!bloomFilter.mightContain(key)) {
return null; // 一定不存在,直接返回
}
// 第二步:查缓存
Object cacheData = redis.get(key);
if (cacheData != null) {
return cacheData;
}
// 第三步:查数据库
Object dbData = db.query(key);
if (dbData != null) {
redis.set(key, dbData, 3600); // 缓存1小时
}
return dbData;
}
注意:布隆过滤器不支持删除操作。如果业务中有删除 key 的场景,需要重建过滤器,或者用计数布隆过滤器(Counting Bloom Filter)。
2.4 解决方案二:缓存空对象
这个方法更简单粗暴。既然查不到数据,那就把“空”也缓存起来。下次同样的 key 过来,缓存直接返回空,不用再查数据库了。
具体做法:
- 查询数据库发现没有数据时,在缓存里存一个空值(比如 null 或特殊标记)。
- 设置一个较短的过期时间,比如 5 分钟。
- 下次请求直接命中缓存,返回空。
代码示例:
public Object getDataWithNullCache(String key) {
// 查缓存
Object cacheData = redis.get(key);
if (cacheData != null) {
// 注意:这里要区分“缓存了空值”和“缓存没命中”
if (cacheData instanceof NullObject) {
return null; // 缓存了空值,直接返回空
}
return cacheData;
}
// 查数据库
Object dbData = db.query(key);
if (dbData == null) {
// 缓存空对象,过期时间设短一点
redis.set(key, NullObject.INSTANCE, 300); // 5分钟
return null;
}
// 正常数据,缓存
redis.set(key, dbData, 3600);
return dbData;
}
避坑指南:我曾经犯过一个错——空对象缓存时间设得太长。结果某个商品上架后,用户等了半小时才看到。后来我统一把空对象过期时间控制在 5 分钟以内,既防穿透,又保证数据及时更新。
2.5 两种方案对比
| 对比项 | 布隆过滤器 | 缓存空对象 |
|---|---|---|
| 内存占用 | 极低(位数组) | 较高(存储空 key) |
| 实现复杂度 | 中等(需要初始化) | 简单(几行代码) |
| 误判率 | 有(1% 左右) | 无 |
| 适用场景 | key 数量大,内存敏感 | key 数量少,实现简单 |
| 数据更新 | 需要重建过滤器 | 过期后自动更新 |
2.6 我的实战建议
说实话,两种方案我都在线上用过。我的习惯是:
- 如果 key 总量可控(比如几百万以内),用缓存空对象就够了,简单直接。
- 如果 key 总量很大(比如上亿),或者内存很紧张,用布隆过滤器更划算。
- 也可以组合使用:布隆过滤器做第一道防线,缓存空对象做第二道。我有个项目就是这么干的,效果很好。
最后提醒一句:缓存穿透只是缓存问题的冰山一角。后面咱们还会聊缓存击穿和缓存雪崩,那才是真正要命的。嗯,先把穿透搞定,后面就好办了。