2. 缓存穿透:什么是缓存穿透?穿透的原因分析,布隆过滤器原理与实战

2.1 先说说我踩过的坑

我记得刚做交易系统那会儿,遇到过一个诡异的问题。

某个商品详情接口,白天跑得好好的。一到晚上,突然就慢得像蜗牛。数据库CPU直接飙到100%。

我查了半天,发现是有人在刷接口。他传的商品ID,全是根本不存在的。比如ID = -1,ID = 999999999。

缓存里当然没有。每次请求都穿透到数据库。数据库扛不住,就挂了。

这就是典型的缓存穿透

2.2 什么是缓存穿透?

说白了,就是请求的数据在缓存里没有,在数据库里也没有。

正常流程是这样的:

  1. 请求来了,先查缓存
  2. 缓存有,直接返回
  3. 缓存没有,查数据库
  4. 数据库有,回写缓存,返回结果

但穿透的情况是:

  1. 请求来了,查缓存——没有
  2. 查数据库——也没有
  3. 每次请求都重复这个流程

你想想看,如果这种请求量一大,数据库就成筛子了。全是洞,全在漏。

核心定义:缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都无法命中,导致每次请求都直接打到数据库。

2.3 穿透的原因分析

我总结了一下,常见原因就三种:

原因类型 具体场景 危害程度
恶意攻击 故意传不存在的ID,比如负数、超大数
业务漏洞 代码bug导致生成了无效key
数据不一致 缓存和数据库数据不同步,缓存失效后查不到

我在项目中遇到过最狠的一次,是有人写了个脚本,每秒发几千个请求,全是查不存在的订单号。数据库直接被打挂了。嗯,那次之后我就把布隆过滤器安排上了。

2.4 布隆过滤器原理

布隆过滤器是什么?

你可以把它想象成一个非常节省内存的“存在性检测器”

它的核心思想是:用多个哈希函数,把数据映射到一个很长的二进制向量上。

原理很简单:

  1. 准备一个长度为m的bit数组,初始全是0
  2. 准备k个不同的哈希函数
  3. 插入数据时,用k个哈希函数算出k个位置,把这些位置都设为1
  4. 查询数据时,同样用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 避坑指南

最后说几个我踩过的坑:

  • 布隆过滤器不支持删除。如果你要删除数据,得重建。所以适合数据相对固定的场景。
  • 初始化时要加载全量数据。漏掉一个,就可能导致正常请求被误拦。
  • 布隆过滤器不是银弹。它只解决“不存在”的问题。如果数据存在但缓存过期,还是得查数据库。那是缓存击穿的问题,后面会讲。

一句话总结:缓存穿透就是查不存在的数据,布隆过滤器是性价比最高的解决方案。用得好,数据库稳如老狗。

缓存穿透处理流程 客户端请求 布隆过滤器 存在? 不存在 直接返回空 可能存在 缓存层 命中 返回结果 未命中 数据库 有数据,回写缓存 无数据
专注资料整理