2. 缓存穿透:什么是缓存穿透?穿透的原因分析,布隆过滤器原理与实战
2.1 先说说我踩过的坑
我记得刚做交易系统那会儿,遇到过一个诡异的问题。
某个商品详情接口,白天跑得好好的。一到晚上,突然就慢得像蜗牛。数据库CPU直接飙到100%。
我查了半天,发现是有人在刷接口。他传的商品ID,全是根本不存在的。比如ID = -1,ID = 999999999。
缓存里当然没有。每次请求都穿透到数据库。数据库扛不住,就挂了。
这就是典型的缓存穿透。
2.2 什么是缓存穿透?
说白了,就是请求的数据在缓存里没有,在数据库里也没有。
正常流程是这样的:
- 请求来了,先查缓存
- 缓存有,直接返回
- 缓存没有,查数据库
- 数据库有,回写缓存,返回结果
但穿透的情况是:
- 请求来了,查缓存——没有
- 查数据库——也没有
- 每次请求都重复这个流程
你想想看,如果这种请求量一大,数据库就成筛子了。全是洞,全在漏。
核心定义:缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都无法命中,导致每次请求都直接打到数据库。
2.3 穿透的原因分析
我总结了一下,常见原因就三种:
| 原因类型 | 具体场景 | 危害程度 |
|---|---|---|
| 恶意攻击 | 故意传不存在的ID,比如负数、超大数 | 高 |
| 业务漏洞 | 代码bug导致生成了无效key | 中 |
| 数据不一致 | 缓存和数据库数据不同步,缓存失效后查不到 | 低 |
我在项目中遇到过最狠的一次,是有人写了个脚本,每秒发几千个请求,全是查不存在的订单号。数据库直接被打挂了。嗯,那次之后我就把布隆过滤器安排上了。
2.4 布隆过滤器原理
布隆过滤器是什么?
你可以把它想象成一个非常节省内存的“存在性检测器”。
它的核心思想是:用多个哈希函数,把数据映射到一个很长的二进制向量上。
原理很简单:
- 准备一个长度为m的bit数组,初始全是0
- 准备k个不同的哈希函数
- 插入数据时,用k个哈希函数算出k个位置,把这些位置都设为1
- 查询数据时,同样用k个哈希函数算位置,如果所有位置都是1,说明可能存在;只要有一个是0,说明一定不存在
注意:布隆过滤器有误判率。它说“不存在”就一定不存在。它说“存在”可能实际不存在。这叫“假阳性”。
为什么会这样?因为哈希冲突。不同的数据可能映射到相同的位置。所以布隆过滤器不能保证100%准确。
但没关系。在缓存穿透的场景下,我们只需要它帮我们挡住那些肯定不存在的请求。误判的那几个,大不了查一下数据库,影响不大。
2.5 布隆过滤器实战
我建议用Google的Guava库,它内置了布隆过滤器的实现。用起来非常方便。
先加依赖:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.1-jre</version>
</dependency>
然后写个工具类:
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import java.nio.charset.Charset;
public class BloomFilterUtil {
// 预计插入的数据量
private static final int EXPECTED_INSERTIONS = 1000000;
// 误判率,0.01表示1%
private static final double FPP = 0.01;
private static BloomFilter<String> bloomFilter =
BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()),
EXPECTED_INSERTIONS, FPP);
/**
* 初始化布隆过滤器,加载所有合法ID
*/
public static void init(List<String> allValidIds) {
for (String id : allValidIds) {
bloomFilter.put(id);
}
}
/**
* 判断ID是否可能存在
*/
public static boolean mightContain(String id) {
return bloomFilter.mightContain(id);
}
}
在查询接口里这样用:
public Product getProduct(String productId) {
// 第一步:布隆过滤器拦截
if (!BloomFilterUtil.mightContain(productId)) {
// 一定不存在,直接返回空
return null;
}
// 第二步:查缓存
Product product = cache.get(productId);
if (product != null) {
return product;
}
// 第三步:查数据库
product = dao.getById(productId);
if (product != null) {
cache.put(productId, product);
}
return product;
}
个人经验:布隆过滤器的误判率不要设得太低。0.01(1%)就够用了。设得太低会占用更多内存。我曾经为了追求0.0001的误判率,内存直接翻了三倍。没必要。
2.6 布隆过滤器的参数选择
这里有个表格,帮你快速选参数:
| 误判率 | 每个元素占用bit数 | 哈希函数个数 | 适用场景 |
|---|---|---|---|
| 1% | 约10 bit | 7个 | 一般业务场景 |
| 0.1% | 约14 bit | 10个 | 对准确性要求高 |
| 0.01% | 约20 bit | 14个 | 金融交易等严苛场景 |
我个人习惯用1%的误判率。100万个ID,大概占用1.2MB内存。非常划算。
2.7 其他解决方案
除了布隆过滤器,还有几种办法也能防穿透:
- 缓存空值:查不到的数据,也缓存一个空对象,设置较短的过期时间。比如5分钟。这样同样的请求就不会穿透了。
- 参数校验:在入口处就过滤掉明显不合法的参数。比如ID不能为负数,不能超过某个范围。
- 限流降级:对异常请求进行限流,保护后端。
我曾经在一个项目里,把布隆过滤器和缓存空值结合起来用。布隆过滤器挡住大部分非法请求,缓存空值兜底。效果非常好。
2.8 避坑指南
最后说几个我踩过的坑:
- 布隆过滤器不支持删除。如果你要删除数据,得重建。所以适合数据相对固定的场景。
- 初始化时要加载全量数据。漏掉一个,就可能导致正常请求被误拦。
- 布隆过滤器不是银弹。它只解决“不存在”的问题。如果数据存在但缓存过期,还是得查数据库。那是缓存击穿的问题,后面会讲。
一句话总结:缓存穿透就是查不存在的数据,布隆过滤器是性价比最高的解决方案。用得好,数据库稳如老狗。