简介

对于一个已知的集合 S, 一个 bloom 过滤器能够在常量级别的空间复杂度条件下判断一个元素 x 是否 不是 在集合 S 中,具体地:

  1. bloom 过滤器返回 False, 代表 x 不属于 S, 也就是说自古以来 x 从来就没有被添加到 S 过;
  2. bloom 过滤器返回 True, 代表 x 有可能 在 S 中。

Bloom 过滤器的假阳性指的是这样的事件:一个 S 集合上的 bloom 过滤器对一个元素 x 判定为 True,然而 x 实际上并不在集合 S 中(它从未被添加到集合 S 中,假如将 S 视作一个集合数据结构的话)。

在假阳性是可以接受的场合,应用 bloom filter 是也许可以被接受的。例如,假设一个小型在线论坛网站提供用户注册服务,它将所有的 User 对象都保存在服务器进程的一个全局数组 User users[MAX_USR]; 中,它不允许两个用户注册相同用户名的账户,为此服务器进程在收到注册请求时需要检查那个保存所有 User 的全局数组看一个用户名是不是已经被使用了,为了不让每一次用户注册请求都使服务器进程遍历一遍这个全局数组,我们可以应用 bloom 过滤器,如果 bloom 过滤器返回 False, 则说明这个用户名一定没有人取过,只有当 bloom 过滤器返回 True 时,才需要去扫表。并且,通过降低 bloom 过滤器的假阳性率(这是一定程度上可以做到的),我们可以降低在面对一个随机的注册请求时,服务器真正扫表的概率。

这个例子并不是一个很恰当的例子,因为读者很容易想到“为什么不直接用 Set 类数据结构”。事实上这里的用户名属于空间占用比较小的那一类数据,体现不出 bloom 过滤器的优越性。而对于体积较大的元素来说,bloom 过滤器才会比较节省空间,因为 bloom 过滤器本身需要的空间只取决于 m 和 k, 也就是说它只需要一个大小为 m 的 bitmap 以及存储 k 个函数的空间(k 通常不大),而跟要判定的那个元素本身的大小无关。举例来说,假设有一个在线阅读网站希望给读者推荐文章,但是又不希望给读者推荐那些已经推荐过,或者读者读过的文章,这个时候文章本身的内容或者文章的标题摘要等信息就可视为元素 x, 它相对于用户名一般来说更加占用空间,这时候用 bloom 过滤器就可以以一种比较节省空间的方式判断一篇文章是否没被推荐过或者可能已经被推荐过了。

这里再举一例,一个 cache server 通常是位于 client 和真实 server 中间的反向代理 web server,真实 server 也被称为 upstream, client 发给真实 server 的请求会被首先路由到 cache server, 如果 cache server 之前缓存过 client 正在请求的内容并且缓存仍然是有效的,那么 cache server 就可以直接给 client 返回缓存,而无需自己再去代表 client 向 upstream 请求 client 请求的内容,然而 cache server 本身的存储能力是有限的,它自己必须要决定哪些内容需要缓存,以及哪些不需要缓存。Akamai (一家提供 CDN 服务的公司)发现,大约有接近四分之三的内容只请求一次,换句话说缓存这类内容是没有多大意义的,因为 client 大概率只请求这一次这个内容。这时 bloom 过滤器就可以派上用场了,具体来说,可以使用 bloom 过滤器来判断一个请求是否是第一次到来的,如果是,那么 upstream 返回的内容就不用缓存,并且对 bloom 过滤器实例做 insert 这个请求的操作,把这个请求添加到 bloom 过滤器内部集合中,那么下次再遇到一个请求时,如果 bloom 过滤器返回 False, 则说明这个请求一定是第一次到来的,这时可以不用缓存,否则说明之前可能来过,可以进行缓存。

当设计与实现搜索引擎爬虫时,也可以应用 bloom 过滤器来实现「哪个链接是没有爬过的」的快速、低成本判定,在假阳性率很低的情况下,即便我们知道 bloom 过滤器返回 True 不一定代表这个链接是已经爬过的,我们也可以将它视为已经爬过的,因为阳性是假的也不会有什么不良后果,毕竟还有一大堆的页面等着爬取。假如发生了一起假阳性,也仅仅是意味着我们认为一个没有爬过的链接是爬过的,但这也仅仅是跳过了一个链接,而搜索引擎每天要处理成千上万的链接,所以说 bloom 过滤器的假阳性对于搜索引擎爬虫来说是可以接受的(理论上、再加上假阳性率不那么高)。

具体来说,一个 bloom 过滤器实例支持两个操作:

  1. 添加操作,该操作将一个给定元素 x 赋予它背后代表的那个集合 S 的成员属性,也就是说该操作将一个元素 x 加到 bloom 过滤器背后代表的那个集合 S;
  2. 查询操作,该操作判断一个给定元素 x 是不是在之前已经被添加到了集合 S 中,如果此 bloom 过滤器近乎百分百(有非常大的把握)确定 x 不在 S 中,那么它返回 False, 否则它返回 True, 代表它不确定。

一个 bloom 过滤器实例内部维护一个长度为 m 的 bitmap, 一个 bitmap 是一个有 m 个格子的数据结构,m 个格子中的每一个都可以写入 0 或者 1, 也可以读取、判断任意一个格子存的是 0 还是 1.

一个 bloom 过滤器实例在创建时就需要和 k 个散列函数 (hash 函数)相关联,这 k 个散列函数的统计性质能够直接影响到 bloom 过滤器工作表现的好与坏 (how well it performs)。

对于添加操作,bloom 过滤器实例会依次利用 k 个散列函数,将那个待判定元素 x 作为散列函数的输入值,算出 k 个 index, i_1, i_2, …, i_k, 都是落在 [0, m) 左闭右开区间,然后对 bitmap 的这 k 个 index 对应的位置写入 1.

对于查询操作,bloom 过滤器实例还是会依次利用 k 个散列函数,还是将那个 x 作为散列函数的输入,算出 k 个 index i_1, i_2, …, i_k, 但是这回不再写入 bitmap 了,而是检查 bitmap 的这 k 个 index 对应的格子是否全为 1, 如果:1)不是,也就是说这 k 个格子中至少有一个格子不是 1,那么返回 False, 否则:2)返回 True.

实现

为了演示 bloom 过滤器的使用,我们使用 C++ 语言来实现 bloom 过滤器的各种操作,我们首先定义一个结构体,用来存储 bloom 过滤器内部状态数据:

template <int m, int k>
struct bloom_filter {
    std::array<int, k> seeds;
    std::bitset<m> bm;
};

其中,m 和 k 由模板参数给定,我们没有存 k 个 hash 函数的函数指针,而是存了 k 个 seeds, 具体来说,接下来要用到的 k 个 hash 函数是这样定义的