关于防止缓存穿透的布隆过滤器

138 阅读6分钟

开篇

在互联网应用里,缓存就像一个快速小仓库,能让我们快速获取数据,提升系统响应速度。不过,缓存穿透问题却常常捣乱。缓存穿透指的是,有人请求的数据在缓存和数据库里压根就没有,这样请求就会绕过缓存,直接冲击数据库。要是大量这样的请求过来,数据库的压力就会剧增,甚至可能崩溃。那怎么解决这个问题呢?布隆过滤器就是一个非常实用的工具。

啥是缓存穿透

缓存穿透的定义

当客户端请求的数据在缓存里找不到,去数据库里也找不到,每次请求都会直接打到数据库,这就是缓存穿透。打个比方,你开了个小卖部,有顾客来问你要一种根本没生产过的商品,你店里没有,进货单里也没有,每次都得去查进货单,要是很多顾客都来问这种不存在的商品,你就会忙得焦头烂额。

缓存穿透的危害场景

  • 恶意攻击:有些不怀好意的人,专门构造一些不存在的数据请求,就像捣乱的顾客一直问你要不存在的商品,让你的数据库一直忙着处理这些无效请求,最后数据库就可能被拖垮。
  • 业务出错:在录入数据的时候,不小心录入了错误的数据,之后客户端请求这些错误的数据,同样会造成缓存穿透。

老办法的不足

  • 缓存空值:把不存在的数据也放到缓存里,设置一个短的过期时间。可这样会浪费缓存空间,要是遇到恶意攻击,大量的空值会占满缓存内存。
  • 布隆过滤器:这是个很厉害的工具,能高效判断一个元素在不在集合里。

布隆过滤器的工作原理

基本组成

布隆过滤器主要由两部分构成,一个很长的二进制数组和好几个随机的哈希函数。二进制数组就像是一排开关,每个开关只有开(1)和关(0)两种状态。哈希函数就像是一个特殊的计算器,能把一个元素转换成数组里的位置。

工作流程

存入元素

当要把一个元素存到布隆过滤器里时,会用几个不同的哈希函数对这个元素进行计算,得到几个不同的数组位置,然后把这些位置对应的开关都打开(值设为 1)。

查询元素

当要查一个元素在不在布隆过滤器里时,还是用那几个哈希函数计算出对应的数组位置。如果这些位置的开关都是打开的(值都为 1),那就说明这个元素可能存在;要是有一个开关是关着的(值为 0),那就说明这个元素肯定不存在。

布隆过滤器的特点

  • 优点

    • 省空间:不需要把元素本身存起来,只需要记录一些开关状态,所以特别节省空间。
    • 速度快:不管数据量有多大,查询一个元素只需要用哈希函数计算几次,时间很短。
  • 缺点

    • 有误判:有可能把不存在的元素判断成存在,这是因为不同元素的哈希计算结果可能会冲突,导致数组位置重叠。
    • 不能删元素:因为删除一个元素可能会影响其他元素的判断结果,所以布隆过滤器里的元素不能删除。

布隆过滤器在防缓存穿透中的用法

具体步骤

  1. 初始化:在系统刚开始运行的时候,把数据库里所有可能被访问的数据都添加到布隆过滤器里。
  2. 处理请求:当有客户端请求过来时,先让布隆过滤器判断这个请求的数据存不存在。要是布隆过滤器说不存在,那就直接告诉客户端数据不存在,不用再去查缓存和数据库了;要是布隆过滤器说可能存在,再去查缓存和数据库。
  3. 简单来讲就是这样

image.png

举个栗子

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

// 模拟缓存类
class Cache {
    private final Map<String, String> cacheMap = new HashMap<>();

    public String get(String key) {
        return cacheMap.get(key);
    }

    public void set(String key, String value) {
        cacheMap.put(key, value);
    }
}

// 模拟数据库类
class Database {
    private final Map<String, String> dbMap = new HashMap<>();

    public Database() {
        // 初始化一些测试数据
        dbMap.put("key1", "value1");
        dbMap.put("key2", "value2");
    }

    public String query(String key) {
        return dbMap.get(key);
    }
}

public class BloomFilterCacheExample {
    private static final int EXPECTED_INSERTIONS = 100;
    private static final double FALSE_POSITIVE_PROBABILITY = 0.01;
    private final BloomFilter<String> bloomFilter;
    private final Cache cache;
    private final Database database;

    public BloomFilterCacheExample() {
        // 初始化布隆过滤器
        bloomFilter = BloomFilter.create(
                Funnels.stringFunnel(StandardCharsets.UTF_8),
                EXPECTED_INSERTIONS,
                FALSE_POSITIVE_PROBABILITY
        );
        cache = new Cache();
        database = new Database();
        // 将数据库中的键添加到布隆过滤器
        for (String key : database.dbMap.keySet()) {
            bloomFilter.put(key);
        }
    }

    public String getData(String key) {
        // 先用布隆过滤器判断
        if (!bloomFilter.mightContain(key)) {
            return "数据不存在";
        }
        // 查缓存
        String cacheData = cache.get(key);
        if (cacheData != null) {
            return cacheData;
        }
        // 查数据库
        String dbData = database.query(key);
        if (dbData != null) {
            // 把数据存到缓存里
            cache.set(key, dbData);
            return dbData;
        } else {
            return "数据不存在";
        }
    }

    public static void main(String[] args) {
        BloomFilterCacheExample example = new BloomFilterCacheExample();
        // 测试存在的数据
        System.out.println(example.getData("key1")); 
        // 测试不存在的数据
        System.out.println(example.getData("key3")); 
    }
}

布隆过滤器的优化和注意事项

控制误判率

布隆过滤器的误判率和二进制数组的长度、哈希函数的个数以及插入元素的数量有关。你可以调整这些参数来控制误判率,不过要注意,误判率越低,需要的空间和计算量就越大。

动态更新

在实际应用中,数据库里的数据会不断变化,所以要定期更新布隆过滤器。可以只把新增加的数据添加到布隆过滤器里。

分布式环境

在分布式系统里,很多节点都要用到同一个布隆过滤器。可以把布隆过滤器存到 Redis 这样的分布式缓存里,或者用专门的分布式布隆过滤器方案。

总结

布隆过滤器是防止缓存穿透的好帮手,哈希数组的长度越长假阳性误判的概率越小,而且100%无假阴性,,可以快速判断元素是否存在集合当中。牺牲了一点点判断的准确性,换来了空间和性能的大幅提升。它能帮我们挡住大量无效请求,减轻数据库的压力。在实际使用的时候,要根据业务需求调整布隆过滤器的参数,还要注意动态更新和在分布式环境下的使用问题。掌握了布隆过滤器,就能让我们的系统更稳定、更高效。