开篇
在互联网应用里,缓存就像一个快速小仓库,能让我们快速获取数据,提升系统响应速度。不过,缓存穿透问题却常常捣乱。缓存穿透指的是,有人请求的数据在缓存和数据库里压根就没有,这样请求就会绕过缓存,直接冲击数据库。要是大量这样的请求过来,数据库的压力就会剧增,甚至可能崩溃。那怎么解决这个问题呢?布隆过滤器就是一个非常实用的工具。
啥是缓存穿透
缓存穿透的定义
当客户端请求的数据在缓存里找不到,去数据库里也找不到,每次请求都会直接打到数据库,这就是缓存穿透。打个比方,你开了个小卖部,有顾客来问你要一种根本没生产过的商品,你店里没有,进货单里也没有,每次都得去查进货单,要是很多顾客都来问这种不存在的商品,你就会忙得焦头烂额。
缓存穿透的危害场景
- 恶意攻击:有些不怀好意的人,专门构造一些不存在的数据请求,就像捣乱的顾客一直问你要不存在的商品,让你的数据库一直忙着处理这些无效请求,最后数据库就可能被拖垮。
- 业务出错:在录入数据的时候,不小心录入了错误的数据,之后客户端请求这些错误的数据,同样会造成缓存穿透。
老办法的不足
- 缓存空值:把不存在的数据也放到缓存里,设置一个短的过期时间。可这样会浪费缓存空间,要是遇到恶意攻击,大量的空值会占满缓存内存。
- 布隆过滤器:这是个很厉害的工具,能高效判断一个元素在不在集合里。
布隆过滤器的工作原理
基本组成
布隆过滤器主要由两部分构成,一个很长的二进制数组和好几个随机的哈希函数。二进制数组就像是一排开关,每个开关只有开(1)和关(0)两种状态。哈希函数就像是一个特殊的计算器,能把一个元素转换成数组里的位置。
工作流程
存入元素
当要把一个元素存到布隆过滤器里时,会用几个不同的哈希函数对这个元素进行计算,得到几个不同的数组位置,然后把这些位置对应的开关都打开(值设为 1)。
查询元素
当要查一个元素在不在布隆过滤器里时,还是用那几个哈希函数计算出对应的数组位置。如果这些位置的开关都是打开的(值都为 1),那就说明这个元素可能存在;要是有一个开关是关着的(值为 0),那就说明这个元素肯定不存在。
布隆过滤器的特点
-
优点
- 省空间:不需要把元素本身存起来,只需要记录一些开关状态,所以特别节省空间。
- 速度快:不管数据量有多大,查询一个元素只需要用哈希函数计算几次,时间很短。
-
缺点
- 有误判:有可能把不存在的元素判断成存在,这是因为不同元素的哈希计算结果可能会冲突,导致数组位置重叠。
- 不能删元素:因为删除一个元素可能会影响其他元素的判断结果,所以布隆过滤器里的元素不能删除。
布隆过滤器在防缓存穿透中的用法
具体步骤
- 初始化:在系统刚开始运行的时候,把数据库里所有可能被访问的数据都添加到布隆过滤器里。
- 处理请求:当有客户端请求过来时,先让布隆过滤器判断这个请求的数据存不存在。要是布隆过滤器说不存在,那就直接告诉客户端数据不存在,不用再去查缓存和数据库了;要是布隆过滤器说可能存在,再去查缓存和数据库。
- 简单来讲就是这样
举个栗子
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%无假阴性,,可以快速判断元素是否存在集合当中。牺牲了一点点判断的准确性,换来了空间和性能的大幅提升。它能帮我们挡住大量无效请求,减轻数据库的压力。在实际使用的时候,要根据业务需求调整布隆过滤器的参数,还要注意动态更新和在分布式环境下的使用问题。掌握了布隆过滤器,就能让我们的系统更稳定、更高效。