前提

这是《冷饭新炒》系列的第六篇文章。

冷饭新炒:理解布隆过滤器算法的实现原理_redis

本文会翻炒一个用途比较广的算法 - 布隆过滤器算法

布隆过滤器的一些概念

主要包括:


  • 简介
  • 算法
  • 参数
  • 优势和劣势

布隆过滤器简介

布隆过滤器是一种空间高效概率性的数据结构(百科中原文是​​a space-efficient probabilistic data structure​​​),该数据结构于​​1970​​​年由​​Burton Howard Bloom​​提出,作用是测试一个元素是否某个集合的一个成员。布隆过滤器是可能出现​​false positive​​(这个是专有名词"假阳性",可以理解为误判的情况,下文如果用到这个名词会保留英文单词使用)匹配的,换言之,布隆过滤器在使用的时候有可能返回结果"可能存在于集合中"或者"必定不存在于集合中"。

布隆过滤器算法描述

在场景复杂的网络爬虫中,爬取到的网页​​URL​​​依赖有可能成环,例如在​​URL-1​​​页面中展示了​​URL-2​​​,然后又在​​URL-2​​​中的页面展示了​​URL-1​​​,这个时候需要一种方案记录和判断历史访问过的​​URL​​。这个时候可能会想到下面的方案:


  • 方案一:使用数据库存储已经访问过的​​URL​​​,例如​​MySQL​​​表中基于​​URL​​​建立唯一索引或者使用​​Redis​​​的​​SET​​数据类型
  • 方案二:使用​​HashSet​​​(其实这里不局限于​​HashSet​​​,链表、树和散列表等数据结构都能满足)存储已经访问过的​​URL​
  • 方案三:基于方案一和方案二进行优化,存储​​URL​​​的摘要,使用摘要算法如​​MD5​​​、​​SHA-n​​​算法针对​​URL​​字符串生成摘要
  • 方案四:使用​​Hash​​​函数处理对应的​​URL​​​生成一个哈希码,再把哈希码通过一个映射函数映射到一个固定容量的​​BitSet​​中的某一个比特

对于方案一、方案二和方案三,在历史访问​​URL​​​数据量极大的情况下,会消耗巨大的存储空间(磁盘或者内存),对于方案四,如果​​URL​​​有​​100​​​亿个,那么要把冲突几率降低到​​1%​​​,那么​​BitSet​​​的容量需要设置为​​10000​​亿。

冷饭新炒:理解布隆过滤器算法的实现原理_redis_02

所以上面的四种方案都有明显的不足之处,而布隆过滤器算法的基本思路跟方案四差不多,最大的不同点就是方案四中只提到使用了一个散列函数,而布隆过滤器中使用了​​k​​​(​​k >= 1​​)个相互独立的高效低冲突的散列函数。

一个初始化的布隆过滤器是一个所有比特都设置为​​0​​​的长度为​​m​​​的比特数组,也就是认知中的​​Bit Array​​​、​​Bit Set​​​或者​​Redis​​​中的​​Bit Map​​​概念。然后需要引入​​k​​​个不同的散列函数,某个新增元素通过这​​k​​​个散列函数处理之后,映射到比特数组​​m​​​个比特中的​​k​​​个,并且把这些命中映射的​​k​​​个比特位设置为​​1​​​,产生一个均匀的随机分布。通常情况下,​​k​​​的一个较小的常数,取决于所需的误判率,而布隆过滤器容量​​m​​​与散列函数个数​​k​​和需要添加元素数量呈正相关。

冷饭新炒:理解布隆过滤器算法的实现原理_redis_03

当需要新增的所有元素都添加到布隆过滤器之后,那么比特数组中的很多比特都被设置为​​1​​​。这个时候如果需要判断一个元素是否存在于布隆过滤器中,只需要通过​​k​​​个散列函数处理得到比特数组的​​k​​​个下标,然后判断比特数组对应的下标所在比特是否为​​1​​​。如果这​​k​​个下标所在比特中至少存在一个0,那么这个需要判断的元素必定不在布隆过滤器代表的集合中;如果这​​k​​​个下标所在比特全部都为​​1​​,那么那么这个需要判断的元素可能存在于布隆过滤器代表的集合中或者刚好是一个​​False Positive​​,至于误差率分析见下文的布隆过滤器的相关参数一节。​​False Positive​​出现的情况可以见下图:

冷饭新炒:理解布隆过滤器算法的实现原理_散列函数_04

当添加到布隆过滤器的元素数量比较大,并且布隆过滤器的容量设置不合理(过小),容易出现多个元素通过​​k​​​个散列函数,映射到相同的​​k​​​个位(如上图的下标​​1​​​、​​3​​​、​​9​​​所在的位),这个时候就无法准确判断这​​k​​​个位由具体那个元素映射而来。其实可以极端一点思考:假设布隆过滤器容量为​​24​​​,散列函数只有一个,那么添加最多​​25​​个不同元素,必定有两个不同的元素的映射结果落在同一个位。

布隆过滤器的相关参数

在算法描述一节已经提到过,布隆过滤器主要有下面的参数:


  • 初始化比特数组容量​​m​
  • 散列函数个数​​k​
  • 误判率​​ε​​​(数学符号​​Epsilon​​​,代表​​False Positive Rate​​)
  • 需要添加到布隆过滤器的元素数量​​n​

考虑到篇幅原因,这里不做这几个值的关系推导,直接整理出结果和关系式。


  • 误判率​​ε​​​的估算值为:​​[1 - e^(-kn/m)]^k​
  • 最优散列函数数量​​k​​​的推算值:对于给定的​​m​​​和​​n​​​,当​​k = m/n * ln2​​​的时候,误判率​​ε​​最低
  • 推算初始化比特容量​​m​​​的值,当​​k = m/n * ln2​​​的时候,​​m >= n * log2(e) * log2(1/ε)​

这里贴一个参考资料中​​m/n​​​、​​k​​​和​​False Positive Rate​​之间的关系图:

冷饭新炒:理解布隆过滤器算法的实现原理_散列函数_05

冷饭新炒:理解布隆过滤器算法的实现原理_散列函数_06

冷饭新炒:理解布隆过滤器算法的实现原理_散列函数_07

这里可以推算一下表格中最大参数所需要的空间极限,假设​​n​​​为​​10​​​亿,​​m/n = 32​​​,那么​​m​​​为​​320​​​亿,而​​k​​​为​​24​​​,此时的误判率为​​2.17e-07(0.000000217)​​​,需要空间​​3814.69727m​​。一般规律是:


  • 当​​k​​​固定的时候,​​m/n​​越大,误判率越小
  • 当​​m/n​​​固定的时候,​​k​​越大,误判率越大

通常情况下,​​k​​​需要固定,而​​n​​​是无法确定准确值,最好要评估增长趋势预先计算一个比较大的​​m​​​值去降低误判率,当然也要权衡​​m​​值过大导致空间消耗过大的问题。

既然参数的关系式都已经有推导结果,可以基于关系式写一个参数生成器:

import java.math.BigDecimal;
import java.math.RoundingMode;

public class BloomFilterParamGenerator {

public BigDecimal falsePositiveRate(int m, int n, int k) {
double temp = Math.pow(1 - Math.exp(Math.floorDiv(-k * n, m)), k);
return BigDecimal.valueOf(temp).setScale(10, RoundingMode.FLOOR);
}

public BigDecimal kForMinFalsePositiveRate(int m, int n) {
BigDecimal k = BigDecimal.valueOf(Math.floorDiv(m, n) * Math.log(2));
return k.setScale(10, RoundingMode.FLOOR);
}

public BigDecimal bestM(int n, double falsePositiveRate) {
double temp = log2(Math.exp(1) + Math.floor(1 / falsePositiveRate));
return BigDecimal.valueOf(n).multiply(BigDecimal.valueOf(temp)).setScale(10, RoundingMode.FLOOR);
}

public double log2(double x) {
return Math.log(x) / Math.log(2);
}

public static void main(String[] args) {
BloomFilterParamGenerator generator = new BloomFilterParamGenerator();
System.out.println(generator.falsePositiveRate(2, 1, 2)); // 0.3995764008
System.out.println(generator.kForMinFalsePositiveRate(32, 1)); // 22.1807097779
System.out.println(generator.bestM(1, 0.3995764009)); // 2.2382615950
}
}

这里的计算没有考虑严格的进位和截断,所以和实际的结果可能有偏差,只提供一个参考的例子。

布隆过滤器的优势和劣势

布隆过滤器的优势:


  • 布隆过滤器相对于其他数据结构在时空上有巨大优势,占用内存少,查询和插入元素的时间复杂度都是​​O(k)​
  • 可以准确判断元素不存在于布隆过滤器中的场景
  • 散列函数可以独立设计
  • 布隆过滤器不需要存储元素本身,适用于某些数据敏感和数据严格保密的场景

布隆过滤器的劣势:


  • 不能准确判断元素必定存在于布隆过滤器中的场景,存在误判率,在​​k​​​和​​m​​固定的情况下,添加的元素越多,误判率越高
  • 没有存储全量的元素,对于一些准确查询或者准确统计的场景不适用
  • 原生的布隆过滤器无法安全地删除元素


这里留一个很简单的问题给读者:为什么原生的布隆过滤器无法安全地删除元素?(可以翻看之前的False Positive介绍)


布隆过滤器算法实现

著名的​​Java​​​工具类库​​Guava​​​中自带了一个​​beta​​​版本的布隆过滤器实现,这里参考其中的源码实现思路和上文中的算法描述进行一次布隆过滤器的实现。先考虑设计散列函数,简单一点的方式就是参考​​JavaBean​​​的​​hashCode()​​方法的设计:

public static int hashCode(Object a[]) {
if (a == null)
return 0;
int result = 1;
for (Object element : a)
result = 31 * result + (element == null ? 0 : element.hashCode());
return result;
}

上面方法的​​31​​​可以作为一个输入的​​seed​​​,每个散列函数设计一个独立的​​seed​​​,并且这个​​seed​​​值选用素数基于字符串中的每个​​char​​进行迭加就能实现计算出来的结果是相对独立的:

import java.util.Objects;

public class HashFunction {

/**
* 布隆过滤器容量
*/
private final int m;

/**
* 种子
*/
private final int seed;

public HashFunction(int m, int seed) {
this.m = m;
this.seed = seed;
}

public int hash(String element) {
if (Objects.isNull(element)) {
return 0;
}
int result = 1;
int len = element.length();
for (int i = 0; i < len; i++) {
result = seed * result + element.charAt(i);
}
// 这里确保计算出来的结果不会超过m
return (m - 1) & result;
}
}

接着实现布隆过滤器:

public class BloomFilter {

private static final int[] K_SEED_ARRAY = {5, 7, 11, 13, 31, 37, 61, 67};

private static final int MAX_K = K_SEED_ARRAY.length;

private final int m;

private final int k;

private final BitSet bitSet;

private final HashFunction[] hashFunctions;

public BloomFilter(int m, int k) {
this.k = k;
if (k <= 0 && k > MAX_K) {
throw new IllegalArgumentException("k = " + k);
}
this.m = m;
this.bitSet = new BitSet(m);
hashFunctions = new HashFunction[k];
for (int i = 0; i < k; i++) {
hashFunctions[i] = new HashFunction(m, K_SEED_ARRAY[i]);
}
}

public void addElement(String element) {
for (HashFunction hashFunction : hashFunctions) {
bitSet.set(hashFunction.hash(element), true);
}
}

public boolean contains(String element) {
if (Objects.isNull(element)) {
return false;
}
boolean result = true;
for (HashFunction hashFunction : hashFunctions) {
result = result && bitSet.get(hashFunction.hash(element));
}
return result;
}

public int m() {
return m;
}

public int k() {
return k;
}

public static void main(String[] args) {
BloomFilter bf = new BloomFilter(24, 3);
bf.addElement("throwable");
bf.addElement("throwx");
System.out.println(bf.contains("throwable")); // true
}
}

这里的散列算法和有限的​​k​​​值不足以应对复杂的场景,仅仅为了说明如何实现布隆过滤器,总的来说,原生布隆过滤器算法是比较简单的。对于一些复杂的生产场景,可以使用一些现成的类库如​​Guava​​​中的布隆过滤器​​API​​​、​​Redis​​​中的布隆过滤器插件或者​​Redisson​​​(​​Redis​​​高级客户端)中的布隆过滤器​​API​​。

布隆过滤器应用

主要包括:


  • ​Guava​​​中的​​API​
  • ​Redisson​​​中的​​API​
  • 使用场景

使用Guava中的布隆过滤器API

引入​​Guava​​的依赖:

<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>30.1-jre</version>
</dependency>

使用布隆过滤器:

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;

import java.nio.charset.StandardCharsets;

public class GuavaBloomFilter {

@SuppressWarnings("UnstableApiUsage")
public static void main(String[] args) {
BloomFilter<CharSequence> bloomFilter = BloomFilter.create(Funnels.stringFunnel(StandardCharsets.US_ASCII), 10000, 0.0444D);
bloomFilter.put("throwable");
bloomFilter.put("throwx");
System.out.println(bloomFilter.mightContain("throwable"));
System.out.println(bloomFilter.mightContain("throwx"));
}
}

构造​​BloomFilter​​​的最多参数的静态工厂方法是​​BloomFilter<T> create(Funnel<? super T> funnel, long expectedInsertions, double fpp, BloomFilter.Strategy strategy)​​,参数如下:


  • ​funnel​​​:主要是把任意类型的数据转化成​​HashCode​​​,是一个顶层接口,有大量内置实现,见​​Funnels​
  • ​expectedInsertions​​:期望插入的元素个数
  • ​fpp​​​:猜测是​​False Positive Percent​​​,误判率,小数而非百分数,默认值​​0.03​
  • ​strategy​​​:映射策略,目前只有​​MURMUR128_MITZ_32​​​和​​MURMUR128_MITZ_64​​(默认策略)

参数可以参照上面的表格或者参数生成器的指导,基于实际场景进行定制。

使用Redisson中的布隆过滤器API

高级​​Redis​​​客户端​​Redisson​​​已经基于​​Redis​​​的​​bitmap​​​数据结构做了封装,屏蔽了复杂的实现逻辑,可以开箱即用。引入​​Redisson​​的依赖:

<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.15.1</version>
</dependency>

使用​​Redisson​​​中的布隆过滤器​​API​​:

import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

public class RedissonBloomFilter {

public static void main(String[] args) {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379");
RedissonClient redissonClient = Redisson.create(config);
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("ipBlockList");
// 第一个参数expectedInsertions代表期望插入的元素个数,第二个参数falseProbability代表期望的误判率,小数表示
bloomFilter.tryInit(100000L, 0.03D);
bloomFilter.add("127.0.0.1");
bloomFilter.add("192.168.1.1");
System.out.println(bloomFilter.contains("192.168.1.1")); // true
System.out.println(bloomFilter.contains("192.168.1.2")); // false
}
}

​Redisson​​​提供的布隆过滤器接口​​RBloomFilter​​很简单:

冷饭新炒:理解布隆过滤器算法的实现原理_散列函数_08

常用的方法有​​tryInit()​​​(初始化)、​​add()​​​(添加元素)和​​contains()​​​(判断元素是否存在)。相对于​​Guava​​​的内存态的布隆过滤器实现,​​Redisson​​​提供了基于​​Redis​​实现的分布式布隆过滤器,可以满足分布式集群中布隆过滤器的使用。

布隆过滤器使用场景

其实布隆过滤器的使用场景可以用百科中的一张示意图来描述:

冷饭新炒:理解布隆过滤器算法的实现原理_布隆过滤器_09

基于上图具体化的一些场景列举如下:


  • 网站爬虫应用中进行​​URL​​​去重(不存在于布隆过滤器中的​​URL​​​必定是未爬取过的​​URL​​)
  • 防火墙应用中​​IP​​​黑名单判断(不局限于​​IP​​​黑名单,通用的黑名单判断场景基本都可以使用布隆过滤器,不存在于布隆过滤器中的​​IP​​必定是白名单)
  • 用于规避缓存穿透(不存在于布隆过滤器中的​​KEY​​必定不存在于后置的缓存中)

布隆过滤器变体

布隆过滤器的变体十分多,主要是为了解决布隆过滤器算法中的一些缺陷或者劣势。常见的变体如下:

变体名称

变体描述

​Counting Bloom Filter​

把原生布隆过滤器每个位替换成一个小的计数器(​​Counter​​),所谓计数器其实就是一个小的整数

​Compressed Bloom Filter​

对位数组进行压缩

​Hierarchical Bloom Filters​

分层,由多层布隆过滤器组成

​Spectral Bloom Filters​

​CBF​​的扩展,提供查询集合元素的出现频率功能

​Bloomier Filters​

存储函数值,不仅仅是做位映射

​Time-Decaying Bloom Filters​

计数器数组替换位向量,优化每个计数器存储其值所需的最小空间

​Space Code Bloom Filter​

-

​Filter Banks​

-

​Scalable Bloom filters​

-

​Split Bloom Filters​

-

​Retouched Bloom filters​

-

​Generalized Bloom Filters​

-

​Distance-sensitive Bloom filters​

-

​Data Popularity Conscious Bloom Filters​

-

​Memory-optimized Bloom Filter​

-

​Weighted Bloom filter​

-

​Secure Bloom filters​

-

这里挑选​​Counting Bloom Filter​​​(简称​​CBF​​​)变体稍微展开一下。原生布隆过滤器的基础数据结构是位向量,​​CBF​​​扩展原生布隆过滤器的基础数据结构,底层数组的每个元素使用​​4​​​位大小的计数器存储添加元素到数组某个下标时候映射成功的频次,在插入新元素的时候,通过​​k​​​个散列函数映射到​​k​​​个具体计数器,这些命中的计数器值增加​​1​​​;删除元素的时候,通过​​k​​​个散列函数映射到​​k​​​个具体计数器,这些计数器值减少​​1​​​。使用​​CBF​​判断元素是否在集合中的时候:


  • 某个元素通过​​k​​​个散列函数映射到​​k​​​个具体计数器,所有计数器的值都为​​0​​,那么元素必定不在集合中
  • 某个元素通过​​k​​​个散列函数映射到​​k​​​个具体计数器,至少有​​1​​​个计数器的值大于​​0​​,那么元素可能在集合中

冷饭新炒:理解布隆过滤器算法的实现原理_散列函数_10

小结

一句话简单概括布隆过滤器的基本功能:不存在则必不存在,存在则不一定存在。

在使用布隆过滤器判断一个元素是否属于某个集合时,会有一定的误判率。也就是有可能把不属于某个集合的元素误判为属于这个集合,这种错误称为​​False Positive​​​,但不会把属于某个集合的元素误判为不属于这个集合(相对于​​False Positive​​​,"假阳性",如果属于某个集合的元素误判为不属于这个集合的情况称为​​False Negative​​​,"假阴性")。​​False Positive​​,也就是错误率或者误判率这个因素的引入,是布隆过滤器在设计上权衡空间效率的关键。


(本文完 c-1-w e-a-20210306)