1.什么是布隆过滤器?
布隆过滤器(Bloom Filter)是一种概率数据结构,用于判断输入数据是否在某个超大的数据集中,而且算法复杂度为常量时间O(K),其中K为哈希函数的个数。所谓概率数据结构是指它们针对大规模数据使用概率和统计的方法,以牺牲一定的准确性为代价来换取更高的性能和更少的内存消耗。
2.布隆过滤器原理
布隆过滤器的组成有以下几部分:
- 位数组(Bit Array):又叫做位图(BitMap)或位集(BitSet),它是一种能够紧凑地存储位的数组,每个位置上的元素只有0和1。其最大的特点是极大地提高了空间效率,在处理大量布尔值时非常有用。
- K个哈希函数:采用K个哈希函数将输入值映射到位数组上,具体映射方式为找到哈希索引:,其中N为位数组的长度,为第i个哈希函数。如果该位置上值为1说明存在,如果为0说明不存在。采用K个哈希函数的主要原因是减少哈希冲突。
当输入值经过哈希函数映射,只要任意一个在位数组上不存在,说明输入值在数据集中一定不存在。反之如果输入值经过K个哈希函数映射后在位数组上都存在,只能说明输入值有很大概率在数据集中是存在的,但不能百分之百保证,这是因为哈希冲突始终存在的原因。也因此布隆过滤器被称为概率数据结构,它充当了输入值的过滤器,将一定不存在的输入值过滤掉,避免系无效输入。图6-4展示了位数组的查询过程。
图6-4 位数组读取
当我们的系统接受到写入操作时,会同时更新位数组。图6-5为位数组的写入过程,其中分别计算K个哈希函数的值并映射到数组,将该位置的值置为1,即存在。
图6-5 位数组写入
3.布隆过滤器的实现
我们借助JDK中的位集java.util.BitSet来实现一个简单的布隆过滤器。JDK中的BitSet 本质上是一个long数组,数组每个元素是long类型(bit数为64),假设long数组长度为n,则相当于构造了一个长度为64×n位的位数组。
图6-6 JDK中BitSet数据结构
图6-6为BitSet示意图,还记得我们之前介绍的二维数组和一维数组的关系吗?两者之间是可以转换的,因此我们可以用二维数组来表示一个超长的一维数组,这就是BitSet的核心思想。这里的关键在于给定一个哈希索引值i,如何确定它在BitSet中的位置。根据我们前面介绍过的转换关系不难得出,索引i在BitSet的long数组的第i/64个位置的第i%64个bit位。
以BitSet为基础,一个简单的布隆过滤器的实现如下。
import java.util.BitSet;
import java.util.Random;
public class BloomFilter {
private final int size; // 过滤器bit长度
private final BitSet bitSet; // 位集
private final int[] hashSeeds; // 哈希种子数组
private final int numHashFunctions; // 哈希函数个数
// 构造函数,初始化布隆过滤器
public BloomFilter(int size, int numHashFunctions) {
this.size = size;
this.bitSet = new BitSet(size);
this.numHashFunctions = numHashFunctions;
this.hashSeeds = new int[numHashFunctions];
initializeHashSeeds();
}
/**
* 初始化哈希种子
*/
private void initializeHashSeeds() {
Random random = new Random();
for (int i = 0; i < numHashFunctions; i++) {
hashSeeds[i] = random.nextInt();
}
}
/**
* 添加元素到布隆过滤器
* @param value 添加的字符串元素值
*/
public void add(String value) {
for (int i = 0; i < numHashFunctions; i++) {
int hash = hash(value, hashSeeds[i]);
bitSet.set(Math.abs(hash % size), true);
}
}
/**
* 检查元素是否存在于布隆过滤器
* @param value 输入元素
* @return 是否存在true/false
*/
public boolean mightContain(String value) {
for (int i = 0; i < numHashFunctions; i++) {
int hash = hash(value, hashSeeds[i]);
if (!bitSet.get(Math.abs(hash % size))) {
return false;
}
}
return true;
}
/**
* 简单的哈希函数
* @param value 元素值
* @param seed 种子值
* @return 哈希值
*/
private int hash(String value, int seed) {
int result = 0;
for (int i = 0; i < value.length(); i++) {
result = result * seed + value.charAt(i);
}
return result;
}
}
我们通过测试类来验证这个过滤器,代码中我们构建了一个bit长度为1000的位数组,使用了5个哈希函数。并添加了两个元素在过滤器中,打印查找效果,且实验了元素未添加时的打印结果,结果可能为false(不存在),也可能为true(有哈希冲突但实际并不存在)。验证代码如下。
public class BloomFilterTest {
public static void main(String[] args) {
// 创建一个布隆过滤器,大小为1000,使用5个哈希函数
BloomFilter bloomFilter = new BloomFilter(1000, 5);
// 添加一些元素
bloomFilter.add("hello");
bloomFilter.add("world");
// 检查元素是否存在
System.out.println(bloomFilter.mightContain("hello")); // 输出:true
System.out.println(bloomFilter.mightContain("world")); // 输出:true
System.out.println(bloomFilter.mightContain("OK")); // 输出:false (可能)
}
}
4.布隆过滤器的应用场景
4.1 弱密码校验
你可能遇到过这样的情景:你注册成为某个网站的用户,当你设置的密码过于简单时(如:abcd1234),系统会提示弱密码并要求你重新设置。这里的弱密码校验就用到了布隆过滤器。首先后台会将庞大的弱密码落库,也可能是请求的第三方,但无论如何总有一个环节需要存储这些弱密码,数据量可能是千万级甚至上亿。如果我们直接使用用户的密码(通常都是加密过的,不允许明文传输和存储)去数据库中匹配,这个查询量是非常耗时的,因此需要使用布隆过滤器。
图6-7 布隆过滤器在弱密码校验中的应用
图6-7中,后台维护了一个弱密码库,通常都是明文的,如果是请求后台判断弱密码,那么需要采用对称加密算法将明文密码加密后,写入到布隆过滤器。用户设置了密码后,经过同一套加密算法加密传到后台,查询布隆过滤器,如果命中,说明可能是弱密码,如果有一个哈希函数未命中则一定不是弱密码。 在安全级别很高的系统,如银行或金融系统中,密码通常要求使用加盐加密,即在密码的前面或后面加上一串随机因子(字符串),然后再加密传输。随机因子的引入,使得即使相同的密码,它们加密后的值也不一样,这就导致弱密码库生成的布隆过滤器无法使用。因此,另一种思路是将布隆过滤器放到前端,在前端完成判断,无需将密码传到后台,如图6-8所示。
图6-8 布隆过滤器放置于前端
将布隆过滤器放置在前端需要注意的事项:
- 布隆过滤器不能太大,否则前端代码量会变大,最直观的感受就是原来APP是50M,加了过滤器后变为100M,这种情况就要评估是否值得这样做。
- 需要对前端进行混淆和加固,以防被逆向工程提取后推测出弱密码库。
4.2 判断注册账号是否被占用
另一个场景是判断用户注册账号是否被占用。以社交软件为例,当用户使用邮箱或手机号注册新用户时,系统会快速判断这个账号是否已经存在,如果存在则不允许注册为新用户。假设平台的用户量很大,比如上亿,此时如果直接去后台查询数据库判断,其代价是非常大的。采用布隆过滤器就能快速判断一定不存在的账户,允许用户注册。如果通过布隆过滤器发现账户已存在,则再将其传入后台查询,进行二次精准确认。这种方式能将绝大部分查重请求在布隆过滤器这一层完成,保护了后端系统。
4.3 判断恶意网址
以谷歌浏览器Chrome为例,当用户输入一个URL,Chrome 都会首先使用浏览器中的本地布隆过滤器进行检查。此过滤器中填充了经过哈希处理的恶意 URL列表。如果布隆过滤器返回是,Chrome会请求Google的服务器进一步验证。如果布隆过滤器返回不是,Chrome就不会进行服务器检查,从而节省时间和服务器资源。
4.4 大数据系统数据检索
Apache Cassandra和HBase等大数据系统使用布隆过滤器进行快速数据检索。这些数据库不再需要每次检查特定数据是否存在时都对磁盘读取,而是使用布隆过滤器进行快速的首次检查。如果布隆过滤器返回存在,系统可能会执行磁盘读取以确认。但如果返回不存在,系统就会避免磁盘读取,从而节省大量时间。
4.5 解决缓存击穿
缓存击穿是指查询请求在缓存数据库(如Redis)和持久数据库(如MySQL)中都查不到指定数据,这会让系统做“无用功”。当大量不存在的请求冲击时,很可能将数据库打垮。此时布隆过滤器就派上了用场,将数据库中的数据映射到过滤器上,请求进来时先经过过滤器完成第一道判断,如果结果是可能存在,才允许请求数据库二次查询确认。