关于布隆过滤器,网络上已经有非常多的材料来进行讨论了,这里笔者不想过多的从其原理和实现的技术细节来过于深入的探讨,主要是想通过一个基于原理的实现,来表达和分享一下相关的思考和想法。
这里先强调一些,本文中的实现和代码主要用于算法思想和原理的探讨,没有经过充分的测试和实践的检验,千万不要用于实际的业务系统!如果执意要使用,请自行负责和承担相关的风险。
需求
计算机计算和应用的发展,需要我们能够处理越来越大量的信息和数据。其中一个非常典型的问题就是,如何判断一个数据在数据集合中的"存在性"。
这个问题逻辑上在计算机科学的领域都有经典的解决方案和技术实现,从简单的遍历查找、到二分查找到更复杂的B+树,跳表等等,都是被广泛使用的。这些算法已经在很大程度上进行了优化,比如以二分查找为例(图),相关的理论和分析表明,它的时间复杂度是O(log2n),已经比线性的遍历查找优化了很多。
现在的问题是,现在的应用系统数据规模越来越大,或者对于某些应用的场景,它的发展就是需要处理更大的数据,来提高更好的功能和体验。虽然上面那些算法已经进行了优化,使处理时间并不随规模而线性增长,但当数据大到相应的规模,还是对系统的运行和应用体验会造成很大的影响。更大规模的数据,还会带来一个问题就是对资源占用的影响,这个增加可能是一个线性的增加,在程序中的直接表现就是运算时内存的加载和占用,更大的数据,直接给系统运行带来的就是内存使用的增加,如果不够,可能会造成程序或者系统崩溃,或者性能严重恶化。而且从原理上来看,对于大数据带来的问题,原来的这些技术方案基本上已经基本没有改进的空间。要想维持原有的应用体验,要么提升硬件配置,要么可能需要改变解决问题的思路。
布隆过滤器,就是这样一个新的解决思路和方案。
概述和原理
布隆过滤器(Bloom Filter,BF)这个技术很早就已经提出来了。1970年,当时在贝尔实验室工作的计算机科学家Burton Howard Bloom,通过研究数据结构和算法,发表了论文《Space/Time Trade-offs in Hash Coding with Allowable Errors》,并提出了相应的算法程序,以此这个算法并命名为布隆过滤器。
布隆过滤器和先前的查询技术方案最大的不同是,它可以高效的确认信息的"不存在性"。具体而言,布隆过滤器应用的基本工作过程是:
我们可以基于已有的信息条目集合(比如一些编码或者用户名)创建一个过滤器对象,然后对于一个新的待检测信息条目,可以在这个过滤器中,快速的检测它"不存在"于这个集合当中。这个不存在是确定的,而如果存在,则有可能确实存在,也有可能不真实存在,有一个"误报"的概率。
只是这样的描述,其实并不是特别直观,但如果我们结合其算法原理(图和实例),就可以很容易的理解了。
首先,布隆过滤器的实体,基本上是一个非常大的bit数组(位图),数组的元素就是一个bit,它的值就只有0或者1,初始状态下为0。这里没有明确数组的长度,因为它其实是根据预计的信息集合的大小和我们希望控制的误报的概率来确定的。我们需要管理的数据越多,希望误检的概率越小,则过滤器的大小应该越大。但应该理解,这个大小,不影响其工作原理和我们的讨论。
然后,就是对过滤器进行维护工作了。这是需要一个(或多个)hash函数,对于每一个信息条目,都可以算出一个hash值,这个值最后的表现形式应该就是一系列位置信息,然后将这些位置所对应的在过滤器数组上的元素值改为1(或运算)。对于新增的信息条目,也是一样的处理。这时候我们就得到了一个可以准备工作的布隆过滤器了。它就是一个部分元素值为1,大部分元素都是0的数组。
最后,就是使用和检测阶段。对于一个新的信息条目,我们使用相同的方式进行hash计算,就会得到一个位置的数组。然后使用这个数组中的位置检查过滤器对应的位置上的值,如果有一个值为0,则检查工作结束,可以确认,要检测的信息不在信息集合中(这两个值的映射不同)!当然,如果所有对应位置的值都是1,则不能确定存在性的状态(可以进一步进入后续检测,但这已经不是BF的范畴了)。
特点和问题
讨论并明确了BF的工作原理之后,我们就会很容易理解其技术特点:
- 空间效率高
布隆过滤器只需要一个二进制向量来表示一个集合,因此空间效率比传统的集合数据结构高得多,而且这种简单的数据结构,也利用硬件实现和软件操作。例如,在后面的示例中,甚至不使用位数组,而使用整数数组,来批量处理数据,效率更高。
- 查询时间快
布隆过滤器的查询时间是常数时间,因此比传统的集合数据结构快得多。它的操作里面,就是一些寻址和比较的操作,比循环和迭代计算效率要高很多。
- 误识别率
布隆过滤器存在误识别率,但这个问题,可以通过调整过滤器参数,以及增加后续确认流程来改善和解决。通过简单的增加算法和数据规模,就可以大幅度扩展过滤器的容量,和降低误报概率。
另外,在业务实现上,主要是和现有的查询方式结合使用。先判断不存在性,在合适的应用场景下,就应该已经可以过滤很多业务请求,然后再进行确定性的处理,这样应该可以很好的优化业务性能。
- 无法删除
显然,布隆过滤器是一种非对称的映射关系,所以理论上是无法基于添加的原始信息进行映射的删除的。一个可能的改善方案就是定期重建布隆过滤器,来排除已经删除的记录。所幸,BF的正向计算性能很高,资源占用也不大,这个操作代价应该也是可以接受的。
在布隆过滤器的基础上,针对现有的局限,其实已经有了更优化的改进方案,包括状态计数器、动态布隆过滤器、错误校正布隆过滤器,和布谷鸟过滤器(Cuckoo filter)等等,这已经超出本文要讨论的范围了,也许笔者会择机另行研究和撰文讨论。
实现和参考代码
下面,就是笔者根据以上的构想,编写的一个实现示例。我们前面的分析已经表明,布隆过滤器的核心,就是一个高效、可靠、稳定的信息映射方式,这通常基于一个设计良好的哈希函数来实现。密码学中常见的哈希函数是MD5或者SHA,但好像并不适合布隆过滤器使用,它们太大了,并且计算的工作量也比较大,效率较低,所以,布隆过滤器或类似的计算,广泛使用一种murmurHash算法。
这一部分笔者也不打算重新发明轮子,也直接使用了这一算法作为BF的核心。但在后续的应用场景,进行了一些自己的处理,这里并不是说明这个方法就是最好的,主要是为了方便理解和说明。
bloom.js
const
murmurHash3 = require("./murmurhash3js.min"); // number of hash
class mbloom {
constructor (){
this._MDATA = new Uint16Array(0xFFFF);
// init
for(let i=0; i< 0xFFFF; i++ ) this._MDATA[i] = 0 ;
};
add (str) {
let ihash = murmurHash3.x86.hash32(str);
let fl = ihash & 0xFFFF; // low value
let fh = (ihash >> 16) & 0xFFFF ; // high value
this._MDATA[fl] |= (1 << (fh & 0xF));
this._MDATA[fh] |= (1 << (fl & 0xF));
};
check(str) {
let ihash = murmurHash3.x86.hash32(str);
let fl = ihash & 0xFFFF; // low value
let fh = (ihash >> 16) & 0xFFFF ; // high value
if ((this._MDATA[fl] & (1 << (fh & 0xF))) == 0) return false;
if ((this._MDATA[fh] & (1 << (fl & 0xF))) == 0) return false;
return true;
};
size() {
let isum =0,ivalue;
for(let i=0; i< 0xFFFF; i++ ) {
ivalue = this._MDATA[i];
while(ivalue) {
if (ivalue & 1) isum++;
ivalue >>= 1;
}
}
return 0 | (isum / 2) ;
};
}
console.log(process.memoryUsage());
let bfilter = new mbloom();
const test =()=>{
let s = "What's up " + Math.random();
// add to filter
bfilter.add(s);
// add(s);
console.log("add:", s);
// // check
// let r = checkExist(s);
console.log("check:" ,s, bfilter.check(s),bfilter.check(s+"x"));
console.log("size:", bfilter.size());
console.log(process.memoryUsage());
}; setInterval(test,2000);
// 初始资源占用
{
rss: 27369472,
heapTotal: 6369280,
heapUsed: 5406704,
external: 413776,
arrayBuffers: 23407
}
// 循环执行10次之后的资源占用
{
rss: 30793728,
heapTotal: 6901760,
heapUsed: 5557152,
external: 558389,
arrayBuffers: 147680
}
这个实现的要点如下:
- 为了方便使用,将算法封装到mbloom类中
- 默认的值空间为长度为65535(16位整数)的Uint16的数组
- 输入的字符串,会被计算并映射(使用murmurHash方法)成为一个32位正整数
- 将这个整数分解成为两个16整数,并一步映射到数组中的标识位(一个字符串使用了两个标识位)
- 数据检查时,使用类似的处理,来检查对应标识位是否匹配
- 这里主要使用16位int,主要是为了避免32位int的转换操作和js处理的一些限制
至此,起码是有一个可用的布隆过滤器了。而且可以看到,使用布隆过滤器,占用的资源是非常优化的,而且比较稳定。要想扩展规模也是非常简单的。可以使用不同的种子参数,多调用几次摘要方法,并映射到扩展的数组中就可以了。
关于murmurHash
MurmurHash是一种非加密型哈希函数,Austin Appleby在2008年发明,适用于一般的哈希检索操作。MurmurHash的算法是公开的,并拥有很多变种和版本,当前的主力版本是MurmurHash3,可用于产生32-bit或128-bit哈希值,并为64位处理器做了优化。和密码学中常用的摘要方法相比,笔者认为它有其自身的特点和优势:
- 速度,很多测试表明,它的速度比MD5快数倍
- 实现和扩展简单,它的算法是公开的
- 它支持额外的种子参数,可以基于同一算法扩展多个摘要值
- 相对而言,它主要为短小的信息而设计,而非完全通用摘要方法
- 对于规律性较强的key,其随机分布特征(离散性)表现更良好
示例中的引用代码在这里下载:
raw.githubusercontent.com/pid/murmurH...
应用场景
正确及合理的使用布隆过滤器,它的应用场景其实是非常广泛的:
- 黑名单:很多地方需要维护大规模的黑名单,垃圾邮件、网页等等,这种应用场景更看重性能,并能容忍一定的误报
- 缓存:布隆过滤器可以用于缓存数据,以提高查询速度
小结
上面我们已经看到了一个简陋的BF程序实现,它应当可以帮助我们更加了解其设计的原理和思想。
但笔者认为,BF的核心并不在它设计的算法和实现技巧,而是一个重要的软件工程化的思维方式。这是一种很高明的逆向思维的方式,其实认真想想,也是非常有道理的。存在信息的空间总是有限的,而不存在的空间则是无限的,所以不存在的可能性远远大于确定的,可以先从这一点出发,确定其不存在性,然后再来检查存在性。而且在工程上也找到了高效的存储和计算方式。这种思维和处理问题的方式,这对我们使用信息技术处理实际的业务问题,是很有启发意义的。
另一个对笔者有触动的地方,就是它引起了对哈希,或者说摘要这种计算方法的重新认知。最初的认知是知道可以将一段信息转换为一个固定长度的Hex字符串,但后来知道,这个字符串本质不是字符串,其实是一个字节数组;然后在进一步了解到字节数组也可以简单的理解成就是一个整数(非常大的)。摘要这个词描述的非常准确,它体现出了对信息的一种精炼,还有一种说法就是压缩(因为变小了),但这种摘要会丢失非常多的原始信息,是不能还原的。设计良好的摘要算法,可以在一个信息空间内,降低碰撞的机率,从而可以认为这个摘要信息是可以代表原始信息的而且具有唯一性。而布隆过滤器的设计将这个思维发扬到了极致,直接将信息映射到一个向量空间中的个位,就是将信息压缩到一个一个位置,而且是可以和其他信息共享的,算法保证不同信息之间,在这个空间中的碰撞机率很小,并且可以量化并保持稳定,这就是数学的力量。