最近在看《数学之美》这本书,里面有一个章节讲到布隆过滤器,虽然篇幅较短,不过挺有意思的,写篇文章纪念一下吧~
一、什么是布隆过滤器
官方解释:
布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。
二进制向量,也就是一块存储空间,存放的数据是0或1。
随机映射函数是干吗的呢?因为二进制向量中并没有存储实际的数据,那么哪个地址存0,哪个地址存1,要根据什么判断呢?
这实际上就是随机映射函数的计算结果:
- 二进制向量的每个位置都置0
- 同一个数据用n个不同的随机数产生器(F1,F2, ...,Fn)产生n个信息指纹(f1,f2, ...,fn)
- 再用一个随机数产生器G将这n个信息指纹映射到n个不同的地址(g1,g2, ...,gn)
- 最后把这n个位置置1
是不是so easy,那么它有什么用处呢?
二、布隆过滤器的应用
- 网页URL的去重
- 垃圾邮件的判别
- 集合重复元素的判别
- 查询加速(比如基于key-value的存储系统)
三、布隆过滤器的优缺点
优点
相比于其它的数据结构,布隆过滤器在空间和时间方面都有巨大的优势。布隆过滤器存储空间和插入/查询时间都是常数。
以key-value的存储系统为例:
- 节约存储空间
假设使用8个哈希函数对key进行映射,得到8个地址,即二进制向量中的8个位,也就是说一个key占用一个字节的存储空间。相比于直接存储key本身,一个字符占一个字节,节省的存储空间还是很可观的。
- 插入/查询时间复杂度O(1)
插入和查询主要还是寻址的过程:
插入的过程跟上述过程一样,得到8个映射地址,将其置位1即可;
查询时,依次对8个地址位的值进行判断,如果是0,则一定不存在,查询就可以结束了;如果8个位置都为1,那么大概率是存在的。
这样通过Index直接查找,不需要遍历整个存储结构,效率大大提高。
缺点
布隆过滤器的缺点和优点一样明显
- 存在误判
我们是通过n个随机哈希函数生成的n个地址,所以不同的key经过映射得到的n个地址有可能是相同的,也就是说不存在的key值,也有概率会判断为存在,不过概率在万分之一以下。所以就需要更长的向量、更多的映射函数。或者建立一个小的白名单,存储可能会误判的元素。
- 不能删除元素
还是因为可能会有哈希冲突,所以不能简单地把映射到的地址位复位为0表示删除元素,容易造成误删。
Redis 4.0提供了插件rebloom使用布隆过滤器。
- key:布隆过滤器的key;
- error_rate:期望的错误率(False Positive Rate),该值必须介于0和1之间。该值越小,BloomFilter的内存占用量越大,CPU使用率越高。
- capacity:布隆过滤器的初始容量,即期望添加到布隆过滤器中的元素的个数。当实际添加的元素个数超过该值时,布隆过滤器将进行自动的扩容,该过程会导致性能有所下降,下降的程度是随着元素个数的指数级增长而线性下降。
这样,向量的大小m可以表示为 ,其中n为容量capacity,p为误判率error_rate。
哈希函数可以采用BKDRHash,JSHash,RSHash等等,个数为 mln2 / n。
四、大文件上传
之前做过的一个项目,需要实现文件上传功能。为节约服务器存储空间和提高上传效率,要实现类似网盘秒传的操作,利用计算文件md5值来实现的。
当上传的文件,大小是G量级时,普通计算md5值方法,会造成浏览器明显的卡顿,用户体验不好。万分之一以下的误判率在这种业务场景下是允许的,所以我们可以借鉴布隆过滤器的思想,不再计算整个文件,而是将文件抽样,计算抽样md5。
-
将文件切片,每个切片的大小取2M
-
首、尾两个切片取全部数据,中间切片取开头、中间、结尾各2个字节
-
合并后形成新的文件块,计算它的md5,近似看成原文件的md5。这样的话,如果md5不命中,则文件在服务器上不存在;如果命中,会有极小概率的误判,这和布隆过滤器的思想是类似的。
async calculateHashSample(file) { return new Promise(resolve => { const spark = new sparkMd5.ArrayBuffer(); const reader = new FileReader(); // 计算文件大小,切片大小为2M const size = file.size; let offset = 2 * 1024 * 1024; // 第一个切片 let chunks = [file.slice(0, offset)]; let cur = offset; while (cur < size) { if (cur + offset >= size) { chunks.push(file.slice(cur, cur + offset)); // 最后一个切片 } else { // 中间的切片 前中后各取两个字节 const mid = cur + offset / 2; const end = cur + offset; chunks.push(file.slice(cur, cur + 2)); chunks.push(file.slice(mid, mid + 2)); chunks.push(file.slice(end - 2, end)); } cur += offset; } // 拼接 reader.readAsArrayBuffer(new Blob(chunks)); reader.onload = e => { spark.append(e.target.result); resolve(spark.end()); }; }); }