Bloom过滤器是一个允许用户描述特定的关键词组合而不必精确表述的基于概率的过滤方法。它能让用户在有效搜索关键词的同时保护他们的隐私。
1970年,它由布隆提出的。实际上它是由一个很长的二进制向量和一系列随意映射函数组成。它是一种基于概率的数据结构,主要用来判断某个元素是否在集合内,它具有运行速度快(时间效率),占用内存小的优点(空间效率),但是有一定的误识别率和删除困难的问题。它能够告诉你某个元素一定不在集合内或可能在集合内。
在比特币简单支付验证节点(SPV节点)里,这一方法被用来向对等节点发送交易信息查询请求,同时交易地址不会被暴露。
在设计网络爬虫时,我们用它来判断一个网址是否已经被访问过。
反垃圾邮件时,用它来判断一个邮件地址是否在数十亿个垃圾邮件黑名单列表中。
它还被用于解决缓存穿透问题……
比特币节点小故事
打个比方来说,每个全节点就像是一个在陌生城市里的游客,他带着一张包含每条街道、每个地址的详细地图。
相比之下,SPV节点就像是这名陌生城市里的游客只知道一条主干道的名字,通过随机询问该城市的陌生人来获取分段道路指示。
虽然两种游客都可以通过实地考察来验证一条街是否存在,但没有地图的游客不知道每个小巷中有哪些街道,也不知道附近还有什么其他街道。没有地图的游客在“教堂街23号”的前面,并不知道这个城市里是否还有其他若干条“教堂街23号”,也不知道面前的这个是否是要找的那个。
对他来说,最好的方式就是向足够多的人问路,并且希望其中一部分人不是要试图抢劫他。
Bloom过滤器的实现是由一个可变长度(N)的二进制数组(N位二进制数构成一个位域)和数量可变(M)的一组哈希函数组成。这些哈希函数的输出值始终在1和N之间,该数值与二进制数组相对应。并且该函数为确定性函数,也就是说任何一个使用相同Bloom过滤器的节点通过该函数都能对特定输入得到同一个的结果。Bloom过滤器的准确性和私密性能通过改变长度(N)和哈希函数的数量(M)来调节。
下面,我用一个小型的十六位数组和三个哈希函数来演示Bloom过滤器的应用原理。
Bloom过滤器数组里的每一个数的初始值为零。关键词被加到Bloom过滤器中之前,会依次通过每一个哈希函数运算一次。该输入经第一个哈希函数运算后得到了一个在1和N之间的数,它在该数组(编号依次为1至N)中所对应的位被置为1,从而把哈希函数的输出记录下来。接着再进行下一个哈希函数的运算,把另外一位置为1;以此类推。当全部M个哈希函数都运算过之后,一共有M个位的值从0变成了1,这个关键词也被“记录”在了Bloom过滤器里。
增加第二个关键词就是简单地重复之前的步骤。关键词依次通过各哈希函数运算之后,相应的位变为1,Bloom过滤器则记录下该关键词。需要注意的是,当Bloom过滤器里的关键词增加时,它对应的某个哈希函数的输出值的位可能已经是1。这种情况下,该位不会再次改变。也就是说,随着更多的关键词指向了重复的位,Bloom过滤器随着位1的增加而饱和,准确性也因此降低了。该过滤器之所以是基于概率的数据结构,就是因为关键词的增加会导致准确性的降低。准确性取决于关键字的数量以及数组大小(N)和哈希函数的多少(M)。更大的数组和更多的哈希函数会记录更多的关键词以提高准确性。而小的数组及有限的哈希函数只能记录有限的关键词从而降低准确性。
为测试某一关键词是否被记录在Bloom过滤器中,我们将该关键词逐一代入各哈希函数中运算,并将所得的结果与原数组进行对比。如果所有的结果对应的位都变为了1,则表示这个关键词有可能已被该过滤器记录。之所以这一结论并不确定,是因为这些字节1也有可能是其他关键词运算的重叠结果。简单来说,Bloom过滤器正匹配代表着“可能是”。
上图是一个验证关键词“X”是否在前述Bloom过滤器中的图例。相应的比特位都被置为1,所以这个关键词很有可能是匹配的。
另一方面,如果我们代入关键词计算后的结果某位为0,说明该关键词并没有被记录在过滤器里。负匹配的结果不是可能,而是一定。也就是说,负匹配代表着“一定不是”。
上图是一个验证关键词“Y”是否存在于简易Bloom过滤器中的图例。图中某个结果字段为0,该字段一定没有被匹配。
比特币改进协议BIP0037里已经对Bloom过滤器的实现有所描述。具体请参见GitHub。
常用的数据结构,如hashmap,set,bit array都能用来快速测试一个元素是否存在于一个集合中,相对于这些数据结构,Bloom过滤器有什么优势呢?
相比于哈希表、链表等数据结构,其空间和时间的优势明显。而且Bloom过滤器的插入、查询时间都是常数O(k),也就是说每次想要插入或查询一个元素是否在集合中时,只需要使用k个哈希函数对元素求值,并将对应的比特位标记或检查对应的比特位即可。
另外, 哈希函数相互之间没有关系,方便由硬件并行实现。Bloom过滤器不需要存储元素本身,在某些对保密要求非常严格的场合有优势。
Bloom过滤器可以表示全集,其它任何数据结构都不能;
Bloom过滤器的缺点和优点一样明显。误判率是其中之一。随着存入的元素数量增加,误判率随之增加。但是如果元素数量太少,则使用散列表足矣。
另外,一般情况下不能从Bloom过滤器中删除元素。我们很容易想到把位数组变成整数数组,每插入一个元素相应的计数器加1, 这样删除元素时将计数器减掉就可以了。然而要保证安全地删除元素并非如此简单。首先我们必须保证删除的元素的确在Bloom过滤器里面,而Bloom过滤器只能给出可能在集合中或者一定不在集合中的回复,无法给出是否一定在集合中的回复。这一点单凭这个过滤器是无法保证的。另外计数器回绕也会造成问题。
在降低误算率方面,有不少工作,使得出现了很多布隆过滤器的变种。
比特币中Bloom过滤器是在BIP-0037中提到。下面通过“SPV节点如何知道有多少钱”的问题来介绍Bloom过滤器在比特币中的应用。这个问题其实就是“SPV节点如何知道有多少UTXO”
在比特币网络中主要的两种节点类型:
我们假设,SPV节点最开始只存储了私钥,没有任何其他数据。那么它要获取跟自己地址相关的UTXO,只能向比特币网络中相邻的全节点询问。询问的方式有三种:
SPV节点会以Bloom过滤器的形式告诉相邻全节点自己地址信息,那么根据Bloom过滤器的特性,会有两种结果:
Bloom过滤器只是一个工具,不需要自己实现。本着有车轮就直接拿来用的原则,我们可以使用谷歌帮我们实现的BloomFilter,它封装的非常好,使用起来也非常简洁方便。
<dependency>
<groupId>com.google.guavagroupId>
<artifactId>guavaartifactId>
<version>27.0.1-jreversion>
dependency>
由于存在漏洞,不推荐使用该版本,请自行升级为最新版本。
当前最新版本为33.0.0-jre,由于网络不好,暂时没有拉取。
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
/**
* BloomFilter 测试
*
* @author Bin
* @version 1.0
* 2023/12/23
*/
public class BloomFilterTest {
public static void main(String[] args) {
int size = 100_0000;
BloomFilter<Integer> filter = BloomFilter.create(Funnels.integerFunnel(), size);
// filter = BloomFilter.create(Funnels.integerFunnel(), size, 0.01);
// filter = BloomFilter.create(Funnels.integerFunnel(), size, 0.0001);
System.out.println("初始化Bloom过滤器,添加[1-" + size + "]中的数据到过滤器中");
for (int i = 1; i <= size; i++) {
filter.put(i);
}
test(filter, 1, size);
test(filter, size + 1, size * 2);
}
private static void test(BloomFilter<Integer> filter, int start, int end) {
int exist = 0;
int exclude = 0;
for (int i = start; i <= end; i++) {
if(filter.mightContain(i)) {
exist ++;
} else {
exclude ++;
}
}
String str = "逐个判断[%d - %d]中的数据,被判为存在和不存在的个数分别是:%d / %d\r\n";
System.out.printf(str, start, end, exist, exclude);
}
}
虽说车轮不用重复造,但是想了解底层除了看源码,还就是自己造轮子。
Talk Is Cheap, Show Me The Code.
import java.util.BitSet;
/**
* 简易版本Bloom Filter
*
* @author Bin
* @version 1.0
* 2023/12/23
*/
public class BloomFilter {
/** 二进制数组 */
private final BitSet bits;
/** 二进制向量(数组)的位数 */
private final int size;
/** 用于生成信息指纹的随机数 */
private final int[] seeds;
public BloomFilter() {
this(Integer.MAX_VALUE, new int[]{2, 3, 5, 7, 11}); // 默认大小为全部整数,种子为质数
}
public BloomFilter(int size, int[] seeds) {
if (size < 1) {
throw new IllegalArgumentException("Size must be greater than zero");
}
this.size = size;
this.seeds = seeds;
this.bits = new BitSet(size);
}
public void add(int item) {
add(Integer.toString(item));
}
public void add(String item) {
for (int seed : seeds) {
int hash = hashFunction(seed, item);
int index = hash % size;
bits.set(index, true);
}
}
public boolean contains(int item) {
return contains(Integer.toString(item));
}
public boolean contains(String item) {
if (item == null) {
return false;
}
boolean result = true;
for (int seed : seeds) {
int hash = hashFunction(seed, item);
int index = hash % size;
result &= bits.get(index);
}
return result;
}
private int hashFunction(int seed, String item) {
int hash = 0;
for (char c : item.toCharArray()) {
hash += seed * c;
}
return Math.abs(hash);
}
public static void main(String[] args) {
BloomFilter filter = new BloomFilter();
// 存入数据
int size = 1000_0000;
for (int i = 0; i < size; i++) {
filter.add(i);
}
// 查看已有数据是否存在情况
int count = 0;
for (int i = 0; i < size; i++) {
if(filter.contains(i)) {
count ++;
}
}
System.out.println("count=" + count);
// 查看其它数据是否存在情况
count = 0;
for (int i = size; i < size * 2; i++) {
if(filter.contains(i)) {
count ++;
}
}
System.out.println("count=" + count);
}
}