布隆过滤器(Bloom Filter)
如果觉得对你有帮助,能否点个赞或关个注,以示鼓励笔者呢?!博客目录 | 先点这里
- 前提概念
- 什么是布隆过滤器?
- 布隆过滤器的作用?
- 布隆过滤器的缺陷
- 数据结构
- 关键因子
- 结构设计
- 代码实现
- 结构体
- 因子计算
- 哈希函数
- 插入元素
- 是否存在
- 问题
- 布隆过滤器可以节省多少空间?
前提概念
什么是布隆过滤器?
布隆过滤器(英语:Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难
布隆过滤器的作用?
布隆过滤器有什么作用呢?通俗的讲,布隆过滤器的主要作用就是 "以更低的空间效率检索一个元素是否存在其中"
所以我们知道布隆过滤器的好处就是省空间,作用就是可以判断一个元素是否存在,那么结合实际的应用场景,通常布隆过滤器有哪些应用场景呢?
推荐去重过滤
- 比如对一个短视频推荐系统而言,在视频 feed 流场景,需求是向用户推荐了指定内容后,一段时间内不再重复出现已推荐过的内容,那么布隆过滤器就可以大派用场。
缓存穿透过滤
- 比如在互联网大并发场景下,为防止流量大面积穿透压垮 RDS,很多系统都会结合使用缓存去抗流量。所以我们也可以考虑使用布隆过滤器,针对识别出的外部非法请求,或是空结果请求,通通记录在过滤器中,并在下次访问中,提前由过滤器先行过滤
黑名单系统
- 比如某些系统需要记录百万,千万甚至更多的黑名单字符,使用传统的哈希表实现,需要使用到较大的存储空间,那么我们也可以采用布隆过滤器去提供相同的能力,并有效降低空间存储成本
布隆过滤器的弊端
我们都知道布隆过滤器相比传统 “检索元素是否存在” 的容器而言,具有节省空间存储成本的优势,在大数据领域则尤其明显。那么它就只有长处,毫无缺陷吗?
非也,布隆过滤器还有具有两个非常重要的缺陷
具有一定概率的误判率
,即假阳性率 fpp不具备删除元素的能力
具有一定的误判率是什么意思呢?意思就是可能会将 “不存在的元素误判为存在” ,而这个概率就是 fpp , 如何规避和解决?
- 首先必然存在误判,如无法接受误判,则布隆过滤器不适合使用。但可以根据业务需求降低概率,或是选择我们可接受的误判概率;
- 误判率 (假阳性率) 大小会受到哈希函数的个数,位数组的大小,以及预期元素个数等因素的影响
- 通常根据业务需求,选择可接受的 fpp 构建布隆过滤器,比如 fpp = 0.001, 每 1000 次误判 1 次
另外不具备删除元素的能力又是什么意思呢?根据布隆过滤器的设计,一个元素会被多个哈希函数计算,并得到多个不同的位置;因为是基于哈希实现,那么必然存在哈希冲突,即多个元素的某些哈希函数的哈希值是可能一致,即多个元素会共用一些位置。如果我们要删除某个元素,那么就需要将其所占有的位置重置为 0,但这可能会影响到其它共有该位置的元素的判断,所以综上布隆过滤器是不支持删除的。如果要删除,要怎么解决呢?
布隆过滤器无法解决,但是可以基于布隆过滤器进行扩展,衍生出可删除的过滤器, 比如 CBF (counting bloom filter)
、Cuckoo Filter (布谷鸟过滤器)
等, 如果要删除,则布隆过滤器的基本构型是不满足需求的,可以采用其他类似的数据结构
数据结构
关键因子
- bloom filter calculate
n
是过滤器预期支持的元素个数m
是过滤器位数组的大小,即该过滤器总共占用多少 bit 的空间c
是每个元素平均占用的空间p
是假阳性概率,即 fpp (false positive probability)k
哈希函数的个数
计算公式
- m = − n l o g ( p ) 1 l o g ( 2 ) 2 m = -nlog(p)\frac{1}{log(2)^2} m=−nlog(p)log(2)21
- c = m n c = \frac{m}{n} c=nm
- p = ( 1 − e − k n m ) k = ( 1 − e − k m n ) k = ( 1 − e − k c ) k p = (1-e^{\frac{-kn}{m}})^k = (1-e^{\frac{-k}{\frac{m}{n}}})^k = (1 - e^{\frac{-k}{c}})^k p=(1−em−kn)k=(1−enm−k)k=(1−ec−k)k
-
k
=
m
n
∗
l
n
2
=
0.7
m
n
=
0.7
c
k = \frac{m}{n} *ln2 = 0.7\frac{m}{n} = 0.7c
k=nm∗ln2=0.7nm=0.7c
通常创建一个布隆过滤器,会需要用户提供想支持的 n
和预期的 p
, 然后通过公式得到其余最佳的参数
结构设计
布隆过滤器的数据结构其实也挺简单,如果之前有了解过位图的话,那么就更简单了。整个布隆过滤器的底层实现就是基于 “位数组 + 哈希函数” 实现的
假设某个布隆过滤器的位数组大小为 1000 bits, 即 1000 个位。由 3 个 hash functions 组成
当我们往该布隆过滤器传入一个元素 (Hello World) ,那么该元素就会经过 3 个 hash functions 计算,得到 3 个 hash 值,最后哈希值经过模运算映射到位数组的具体第 x 位上,并标记为 1
当我们要判断某个元素是否在布隆过滤器时,同理,也是将该元素传入,经过哈希函数计算,并得到映射位数组的索引值,判断这个几个索引的位是否为 1
, 如果都为 1
则代表该元素已经存在了,但只要有一个位置不为 1
,我们就认为该元素并不存在
代码实现
结构体
布隆过滤器结构主要可以由三部分构成,关键因子 (n,m,p,k…),哈希函数,位数组,所以假设我们要实现一个布隆过滤器,首先就要将以上三部分内容构建好,这里写一份伪代码,基本可以简单的体现出布隆过滤器的结构设计
public abstract class BloomFilter {
// 字节数组
byte[] bytes;
// 关键因子
int n;
double p;
int m;
int k;
// 哈希函数
List<Hash> hashes;
// 插入元素
public void put(byte[] bs);
// 是否存在
public boolean contains(byte[] bs);
}
- 为什么 n, m 是
int
, 而不是long
, 因为 Java 中没有位数组的概念,所以我们就使用字节数组去实现位数组,而数组大小的最大值是 Integer.MAX_VALUE, 所以 n,m 也采用 int - 其实也并非没有位数组的概念,可以通过 Java 自带的 BitSet 去实现,会更简单,但是这里的目的是为了学习,所以就不直接使用轮子了。除此之外,还要很多实践中,会使用 long 数组去实现
因子计算
- 通过 (n,p) 计算最佳的 m
/**
* Computes m (total bits of Bloom filter)
*/
public static int optimalNumOfBits(int n, double p) {
if (p == 0) {
p = Double.MIN_VALUE;
}
return (int) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
}
- 通过 (n,m) 计算最佳的 k
/**
* Computes the optimal k
*/
public static int optimalNumOfHashFunctions(int n, int m) {
// (m / n) * log(2), but avoid truncation due to division!
return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
}
- 通过 (n,m,k) 计算最佳的 p
/**
* Computes the false positive probability
* 1. p = (1 - e ^ (-kn/m))^k
*/
public static double optimalFpp(int n, int m, int k) {
// keep 5 digits after the decimal point
int point = 100000;
return (double) Math.round((point * Math.pow(1 - Math.exp((double) (-k * n) / m), k))) / point;
}
- 通过 (n,m) 计算 c
/**
* Bits occupied each element
*/
public static double bitsOfElement(int n, int m) {
return (double) m / n;
}
- 通常我们可以通过用户传递的 n 和 p,构造出一个预期的布隆过滤器
哈希函数
哈希函数可以采用各种混合的哈希算法去搭配,我这件为了简单实现,建议采用 murmur3 hash, 通过加不同的 seed 盐去实现不同的哈希效果
// 成员变量 k 个哈希函数
private List<Hash> hashes;
// 获得 k 个索引值
public int[] hashes(byte[] bs) {
int[] indexs = new int[hashes.size()];
for (int i = 0; i < hashes.size(); i++) {
//
int index = (int) ((hashes.get(i).hashToLong(bs) & Long.MAX_VALUE) % this.m);
indexs[i] = index;
}
return indexs;
}
// 哈希接口
public interface Hash {
long hashToLong(byte[] bs);
}
这里是伪代码,Hash 接口没有实现,可以采用 murmur3 加盐实现多个哈希函数
- hashToLong 就是传入数据,获得一个 64 位的哈希值
- 为什么要再
& Long.MAX_VALUE
呢?目的是为了让 hash 值是一个正数,(与01111...
相与,自然就是正数啦)
- 为什么要再
- 为什么要
% m
, 就是布隆过滤器的总的位数, 通过 hash 值求得在过滤器中的位置
插入元素
// 插入元素&是否存在
public synchronized void put(byte[] bs) {
int[] indexs = hashes(bs);
for (int index : indexs) {
// 所在的具体字节
byte bits = (byte) (index / B);
// 所在的具体位
byte offset = (byte) (1 << (B_MASK - (index % B)));
bytes[index / B] = (byte) (bits | offset);
}
}
B = 8
, 代表一个字节 8 位; B_MASK 是掩码,B_MASK = B - 1
,为了做取模运算- 首先得到 index, 通过
index / B
得到 index 所在那个 byte - 其次通过
index % B
得到所在该 byte 的具体位索引,比如等于 2, (00100000
, 一个 byte 8 位,1
所在的位置就是 2) - 通过
index % B
知道了具体的位索引后,为了将该位置变成 1,那么我们就需要得到位加法的偏移量,即得到00100000
去做加法 - 怎么得到那?即把
00000001
左移B_MASK - (index % B)
= 7 - 2 = 5 位,左移 5 位后,得到00100000
- 假设所在字节为
10000001
, 我们要将索引为 2 的位置变为 1, 则两个 byte 做或运算,即位加法即可10000001
|00100000
=10100001
- 最后覆盖 index 所在的位即可
是否存在
// 是否存在
public synchronized boolean contains(byte[] bs) {
int[] indexs = hashes(bs);
for (int index : indexs) {
byte bits = (byte) (index / B);
byte offset = (byte) (1 << (B_MASK - (index % B)));
if ((bits & offset) == 0) {
return false;
}
}
return true;
}
- 是否存在的位运算原理跟插入元素是一致的,直接看插入即可
- 需要区分的是因为有多个哈希函数,自然就有多个 index, 只要有一个 index 不为 1,我们就认为其不存在
伪代码框架
要实现一个完整的布隆过滤器其实也不难,我这里附上一份简化的布隆过滤器伪代码,基本上除了哈希函数的选型留空,其余逻辑都有基本的实现,可以简易的参考,配合理解学习
/**
* @author SnailMann
*/
public abstract class BloomFilter {
// A byte has 8 bits
private static final int B = 8;
// B mask
private static final int B_MASK = B - 1;
// 字节数组
private byte[] bytes;
// 关键因子
private int n;
private int p;
private int m;
private int k;
List<Hash> hashes;
public BloomFilter(int n, int p) {
this.n = n;
this.p = p;
this.m = optimalNumOfBits(n, p);
this.k = optimalNumOfHashFunctions(this.n, this.m);
this.bytes = new byte[this.m];
}
// 插入元素&是否存在
public synchronized void put(byte[] bs) {
int[] indexs = hashes(bs);
for (int index : indexs) {
// 所在的具体字节
byte bits = (byte) (index / B);
// 所在的具体位
byte offset = (byte) (1 << (B_MASK - (index % B)));
bytes[index / B] = (byte) (bits | offset);
}
}
public synchronized boolean contains(byte[] bs) {
int[] indexs = hashes(bs);
for (int index : indexs) {
byte bits = (byte) (index / B);
byte offset = (byte) (1 << (B_MASK - (index % B)));
if ((bits & offset) == 0) {
return false;
}
}
return true;
}
public int[] hashes(byte[] bs) {
int[] indexs = new int[hashes.size()];
for (int i = 0; i < hashes.size(); i++) {
int index = (int) ((hashes.get(i).hashToLong(bs) & Long.MAX_VALUE) % this.m);
indexs[i] = index;
}
return indexs;
}
private static int optimalNumOfHashFunctions(int n, int m) {
return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
}
private static int optimalNumOfBits(int n, double p) {
if (p == 0) {
p = Double.MIN_VALUE;
}
return (int) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
}
public interface Hash {
long hashToLong(byte[] bs);
}
}
如果需要完整可用的实现,可以参考博主之前实现比较完善的代码
问题
布隆过滤器可以节省多少空间?
我们都知道布隆过滤器可以大大节省空间,那么它到底可以节省多少的空间呢?**这要如何去计算呢?**在我们学习了关键因子的计算后,这简直就是一件小事情
我们知道 c = m/n
,c
代表每个元素在布隆过滤器中平均所占用的空间,即多少的 bits。我们假设构建一个满足 p = 0.001, n = 10000
条件的 Filter
经过计算可以知道 m = 143776, k = 10
, 那么平均每个元素所占用的空间就是 c = m/n = 14.38 bits
。通常主键的类型会是是 32/64 整型或是字符串
- 假设我们要判断的元素是 64 位的整型,一个 64 位的数值占 64 bits, 所以每个元素我们就压缩了近 (64-14.38/64) = 63% 的空间
- 假设我们要判断的元素是 32 长度的字符串,而每个字符串就占用 32 * 8 = 256 bits, 那么每个元素我们就将近压缩了 94% 的空间
这是单个元素空间压缩的计算方式,如果觉得不够直观,那我们可以按照总量来计算, 假设 n = 100000000 (1 亿), p = 0.001, 保持 c = 14.38 ,那么 m 就会是1437758757 bits (171.39MB)。假设依然是 32 长度的字符串,那么要存储 1 亿个这样的字符,我们就需要 256 * 100000000 = 25,600,000,000 = 2.98 GB
的空间大小, 而如果我们采用布隆过滤器,则只需要 171 MB
如果你觉得这空间压缩也不过如此,那么就将数据量再放大点,加个 10000 倍,是不是就可以闻到💰的味道了?成本省下来,发个年终奖这不香吗?
参考资料
- 如果觉得对你有帮助,能否点个赞或关个注,以示鼓励笔者呢?!
- 支持 LRU 的布隆过滤器 - @ SnailMann