Bloom过滤器
- 一、概述
- 二、原理
- 三、优缺点
- 1. 优点
- 2.缺点
- 四、Bloom过滤器在比特币中的应用
- 五、项目应用步骤
- 1. pom.xml引入依赖
- 2. 样例代码
- 六、Java版简易实现
一、概述
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。
三、优缺点
1. 优点
常用的数据结构,如hashmap,set,bit array都能用来快速测试一个元素是否存在于一个集合中,相对于这些数据结构,Bloom过滤器有什么优势呢?
相比于哈希表、链表等数据结构,其空间和时间的优势明显。而且Bloom过滤器的插入、查询时间都是常数O(k),也就是说每次想要插入或查询一个元素是否在集合中时,只需要使用k个哈希函数对元素求值,并将对应的比特位标记或检查对应的比特位即可。
另外, 哈希函数相互之间没有关系,方便由硬件并行实现。Bloom过滤器不需要存储元素本身,在某些对保密要求非常严格的场合有优势。
- 对于hashmap,其本质上是一个指针数组,一个指针的开销是sizeof(void *),在64bit的系统上是64个bit,如果采用开链法处理冲突的话,又需要额外的指针开销,而对于Bloom过滤器来讲,返回可能存在的情况中,如果允许有1%的错误率的话,每个元素大约需要10bit的存储空间,整个存储空间的开销大约是hashmap的15%左右(数据来自维基百科)
- 对于set,如果采用hashmap方式实现,情况同上;如果采用平衡树方式实现,一个节点需要一个指针存储数据的位置,两个指针指向其子节点,因此开销相对于hashmap来讲是更多的
- 对于bit array,对于某个元素是否存在,先对元素做hash,取模定位到具体的bit,如果该bit为1,则返回元素存在,如果该bit为0,则返回此元素不存在。可以看出,在返回元素存在的时候,也是会有误判的,如果要获得和Bloom过滤器相同的误判率,则需要比Bloom过滤器更大的存储空间
Bloom过滤器可以表示全集,其它任何数据结构都不能;
- 全量存储但是不存储数据本身,适合有保密要求的场景
- 空间复杂度为O(m),不会随着元素增加而增加,占用空间少
- 插入和查询时间复杂度都是 O(k), 不会随着元素增加而增加,远超一般算法。
2.缺点
Bloom过滤器的缺点和优点一样明显。误判率是其中之一。随着存入的元素数量增加,误判率随之增加。但是如果元素数量太少,则使用散列表足矣。
另外,一般情况下不能从Bloom过滤器中删除元素。我们很容易想到把位数组变成整数数组,每插入一个元素相应的计数器加1, 这样删除元素时将计数器减掉就可以了。然而要保证安全地删除元素并非如此简单。首先我们必须保证删除的元素的确在Bloom过滤器里面,而Bloom过滤器只能给出可能在集合中或者一定不在集合中的回复,无法给出是否一定在集合中的回复。这一点单凭这个过滤器是无法保证的。另外计数器回绕也会造成问题。
- 相对于hashmap和set,Bloom过滤器在返回元素可能存在的情况中,有一定的误判率,这时候,调用者在误判的时候,会做一些不必要的工作,而对于hashmap和set,不会存在误判情况
- 对于bit array,Bloom过滤器在插入和查找元素是否存在时,需要做多次hash,而bit array只需要做一次hash,实际上,bit array可以看做是Bloom过滤器的一种特殊情况。
在降低误算率方面,有不少工作,使得出现了很多布隆过滤器的变种。
- 存在误算率,数据越多,误算率越高
- 一般情况下无法从过滤器中删除数据
- 二进制数组长度和 hash 函数个数确定过程复杂
四、Bloom过滤器在比特币中的应用
比特币中Bloom过滤器是在BIP-0037中提到。下面通过“SPV节点如何知道有多少钱”的问题来介绍Bloom过滤器在比特币中的应用。这个问题其实就是“SPV节点如何知道有多少UTXO”
在比特币网络中主要的两种节点类型:
- 全节点:存放所有区块数据和交易
- SPV节点:只下载区块头和交易相关部分的局部视图
我们假设,SPV节点最开始只存储了私钥,没有任何其他数据。那么它要获取跟自己地址相关的UTXO,只能向比特币网络中相邻的全节点询问。询问的方式有三种:
- 下载完整的区块链账本,自己查找
这种方法很简单,也能隐藏用户的隐私(全节点无法知道SPV节点关联的钱包的地址)。但是在手机端是不现实的,每次用户需要下载上百G的区块链数据,才能知道自己钱包有多少钱,虽然保护了用户隐私,但是浪费了存储空间和带宽。所以这种方法不行,而这也是为什么有SPV的概念存在,中本聪也是考虑到移动支付的场景的。 - 直接告诉全节点自己钱包的所有地址,全节点返回所有跟钱包地址相关的UTXO
这种方法直接等于是泄露了用户隐私,其他全节点就知道SPV节点所关联的钱包地址。但是好处是所要下载的数据少了很多,也更精确了。 - 告诉全节点部分自己钱包的地址信息,全节点返回可能相关的UTXO
这种方法实际上就是采用Bloom过滤器的方法隐藏用户隐私,从而做到即保护用户隐私,又节省存储空间和带宽。我们知道布隆过滤器的两个特点:只能告诉你某个元素可能存在集合中以及某个元素一定不存在集合中。这里可以简单理解Bloom过滤器用来过滤不属于钱包的UTXO。
SPV节点会以Bloom过滤器的形式告诉相邻全节点自己地址信息,那么根据Bloom过滤器的特性,会有两种结果:
- 没有通过Bloom过滤器过滤出来的UTXO,就【一定】不属于钱包地址
- 通过Bloom过滤器过滤出来的UTXO,【可能】属于钱包地址
这种方法在一定程度上保护用户隐私,节省了存储空间和带宽。但是根据Bloom过滤器的特点,随着钱包交易的UTXO越多,布隆过滤器误报率会越高,也就是相邻全节点返回正确的UTXO概率越低。
五、项目应用步骤
Bloom过滤器只是一个工具,不需要自己实现。本着有车轮就直接拿来用的原则,我们可以使用谷歌帮我们实现的BloomFilter,它封装的非常好,使用起来也非常简洁方便。
1. pom.xml引入依赖
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>27.0.1-jre</version>
</dependency>
由于存在漏洞,不推荐使用该版本,请自行升级为最新版本。
当前最新版本为33.0.0-jre,由于网络不好,暂时没有拉取。
2. 样例代码
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);
}
}
六、Java版简易实现
虽说车轮不用重复造,但是想了解底层除了看源码,还就是自己造轮子。
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);
}
}