防止缓存穿透方案

缓存可以说是我们对数据库的一道保护墙,缓存穿透就是冲破了我们的保护墙,每个缓存都有一个缓存的 Key,当相同的 Key 过来时,我们就直接取缓存中的数据返回给调用方,而不用去查询数据库,如果调用方传来的永远都是我们缓存中不存在的 Key,这样每次都需要去数据库中查询一次,就会导致数据库压力增大,这样缓存就失去意义了,这就是所谓的缓存穿透。

缓存穿透的危害

我们已经了解了什么是缓存穿透,其危害显而易见,当大量的请求过来时,首先会从缓存中去寻找数据,当缓存中没有对应的数据时又转到了数据库中去寻找,瞬时数据库的压力会很大,相当于没有用到缓存,同时还增加了去缓存中查找数据的时间。

解决方案

缓存穿透有几种解决方案:

1)如果查询数据库也为空的时候,把这个 key 缓存起来,这样在下次请求过来的时候就可以走缓存了。当然这种方案有个弊端,那就是请求过来的 key 必须大部分相同,如果受到攻击的话,每次的 key 肯定不是固定的,只要不固定 key,这个方案就没用。

2)可以用缓存 key 的规则来做一些限制,当然这种只适合特定的使用场景,比如我们查询商品信息,我们把商品信息存储在 Mongodb 中,Mongodb 有一个 _id 是自动生成的,它有一定的生成规则,如果是直接根据 id 查询商品,在查询之前我们可以对这个 id 做认证,看是不是符合规范,当不符合的时候就直接返回默认的值,既不用去缓存中查询,也不用操作数据库了。这种方案可以解决一部分问题,使用场景比较少。

3)利用布隆过滤器来实现对缓存 key 的检验,需要将所有可能缓存的数据 Hash 到一个足够大的 BitSet 中,在缓存之前先从布隆过滤器中判断这个 key 是否存在,然后做对应的操作。

布隆过滤器介绍

布隆过滤器(Bloom Filter)是 1970 年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。

布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率且删除困难。

布隆过滤器的使用场景比较多,比如我们现在讲的防止缓存穿透、垃圾邮件的检测等。Google chrome 浏览器使用 Bloom Filter 识别恶意链接,Goolge 在 BigTable 中也使用 Bloom Filter 以避免在硬盘中寻找不存在的条目。我公司使用布隆过滤器来对爬虫抓取的 Url 进行重复检查等。

代码示例

不得不说 Google Guava 真是一个万能的库,很多东西都封装好了,Bloom Filter 也有封装好的实现,我们直接使用即可。

下面我们通过一个小案例来看看 BloomFilter 怎么使用,代码如下所示。
public static void main(String[] args) {
    // 总数量
    int total = 1000000;
    BloomFilter<String> bf = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), total);
    // 初始化 10000 条数据到过滤器中
    for (int i = 0; i < total; i++) {
        bf.put("" + i);
    }
    // 判断值是否存在过滤器中
    int count = 0;
    for (int i = 0; i < total + 10000; i++) {
        if (bf.mightContain("" + i)) {
            count++;
        }
    }
    System.out.println("匹配数量 " + count);
}
通过 BloomFilter.create 创建一个布隆过滤器,初始化 1000000 条数据到过滤器中,然后在初始化数据的基础上加上 10000 条,分别去过滤器中检查是否存在,按照正常的逻辑来说,匹配的数量肯定是 1000000,事实上输出的结果如下:

匹配数量 1000309

大家肯定很好奇,明明多加的那 10000 条数据是不存在的,为什么匹配出的数量多出来 309 条?那是因为布隆过滤器是存在一定错误率的,我们可以调节布隆过滤器的错误率,在 create 的时候可以指定第 3 个参数来指定错误率,具体代码如下所示。

BloomFilter<String> bf = BloomFilter.create(Funnels.stringFunnel(Charsets. UTF_8), total, 0.0003);

错误率调节是一个 double 类型的参数,默认值是 0.03,值越小错误率越小,同时存储空间会越大,这个可以根据需求去调整。那么错误率是怎么计算的呢,我们总共是 1010000 条数据去测试是否匹配,默认值是 0.03,那么错误率就是 1010000×(0.03/100)=303,刚刚测试的错误数量是 309,可见处于这个范围内。

那么调整之后的错误率有没有效果呢,我们重新执行下程序看看结果是否跟我们预想的一样,如果有效果的话,错误数量应该是 1010000×(0.0003/100)=3.03,可以允许错误数量在 3 到 4 个之间。

匹配数量 1000004

利用布隆过滤器我们可以预先将缓存的数据存储到过滤器中,比如用户 ID。当根据 ID 来查询数据的时候,我们先从过滤器中判断是否存在,存在的话就继续下面的流程,不存在直接返回空即可,因为我们认为这是一个非法的请求。

缓存穿透不能完全解决,我们只能将其控制在一个可以容忍的范围内,如果是用 Spring Cache 来缓存的话我们可能还用不了布隆过滤器,如果想要结合 Spring Cache 来使用的话我们必须对其扩展才行。