布隆过滤器过期了,将来属于布谷鸟过滤器?

为了解决布隆过滤器不能删除元素的问题,布谷鸟过滤器横空出世。论文《Cuckoo Filter:Better Than Bloom》做者将布谷鸟过滤器和布隆过滤器进行了深刻的对比。相比布谷鸟过滤器而言布隆过滤器有如下不足:查询性能弱、空间利用效率低、不支持反向操做(删除)以及不支持计数。c++

查询性能弱是由于布隆过滤器须要使用多个 hash 函数探测位图中多个不一样的位点,这些位点在内存上跨度很大,会致使 CPU 缓存行命中率低。git

空间效率低是由于在相同的误判率下,布谷鸟过滤器的空间利用率要明显高于布隆,空间上大概能节省 40% 多。不过布隆过滤器并无要求位图的长度必须是 2 的指数,而布谷鸟过滤器必须有这个要求。从这一点出发,彷佛布隆过滤器的空间伸缩性更强一些。github

不支持反向删除操做这个问题着实是击中了布隆过滤器的软肋。在一个动态的系统里面元素老是不断的来也是不断的走。布隆过滤器就比如是印迹,来过来就会有痕迹,就算走了也没法清理干净。好比你的系统里原本只留下 1kw 个元素,可是总体上来过了上亿的流水元素,布隆过滤器很无奈,它会将这些流失的元素的印迹也会永远存放在那里。随着时间的流失,这个过滤器会愈来愈拥挤,直到有一天你发现它的误判率过高了,不得不进行重建。redis

布谷鸟过滤器在论文里声称本身解决了这个问题,它能够有效支持反向删除操做。并且将它做为一个重要的卖点,诱惑大家放弃布隆过滤器改用布谷鸟过滤器。算法

可是通过我一段时间的调查研究发现,布谷鸟过滤器并无它声称的那么美好。它支持的反向删除操做很是鸡肋,以致于你根本没办法使用这个功能。在向读者具体说明这个问题以前,仍是先给读者仔细讲解一下布谷鸟过滤器的原理。数组

布谷鸟哈希缓存

布谷鸟过滤器源于布谷鸟哈希算法,布谷鸟哈希算法源于生活 —— 那个热爱「鸠占鹊巢」的布谷鸟。布谷鸟喜欢滥交(自由),历来不本身筑巢。它将本身的蛋产在别人的巢里,让别人来帮忙孵化。待小布谷鸟破壳而出以后,由于布谷鸟的体型相对较大,它又将养母的其它孩子(仍是蛋)从巢里挤走 —— 从高空摔下夭折了。bash

最简单的布谷鸟哈希结构是一维数组结构,会有两个 hash 算法将新来的元素映射到数组的两个位置。若是两个位置中有一个位置为空,那么就能够将元素直接放进去。可是若是这两个位置都满了,它就不得不「鸠占鹊巢」,随机踢走一个,而后本身霸占了这个位置。数据结构

p1 = hash1(x) % l
p2 = hash2(x) % l
复制代码

不一样于布谷鸟的是,布谷鸟哈希算法会帮这些受害者(被挤走的蛋)寻找其它的窝。由于每个元素均可以放在两个位置,只要任意一个有空位置,就能够塞进去。因此这个伤心的被挤走的蛋会看看本身的另外一个位置有没有空,若是空了,本身挪过去也就皆大欢喜了。可是若是这个位置也被别人占了呢?好,那么它会再来一次「鸠占鹊巢」,将受害者的角色转嫁给别人。而后这个新的受害者还会重复这个过程直到全部的蛋都找到了本身的巢为止。函数

正如鲁迅的那句名言「占本身的巢,让别人滚蛋去吧!」

可是会遇到一个问题,那就是若是数组太拥挤了,连续踢来踢去几百次尚未停下来,这时候会严重影响插入效率。这时候布谷鸟哈希会设置一个阈值,当连续占巢行为超出了某个阈值,就认为这个数组已经几乎满了。这时候就须要对它进行扩容,从新放置全部元素。

还会有另外一个问题,那就是可能会存在挤兑循环。好比两个不一样的元素,hash 以后的两个位置正好相同,这时候它们一人一个位置没有问题。可是这时候来了第三个元素,它 hash 以后的位置也和它们同样,很明显,这时候会出现挤兑的循环。不过让三个不一样的元素通过两次 hash 后位置还同样,这样的几率并非很高,除非你的 hash 算法太挫了。

布谷鸟哈希算法对待这种挤兑循环的态度就是认为数组太拥挤了,须要扩容(实际上并非这样)。

优化

上面的布谷鸟哈希算法的平均空间利用率并不高,大概只有 50%。到了这个百分比,就会很快出现连续挤兑次数超出阈值。这样的哈希算法价值并不明显,因此须要对它进行改良。

改良的方案之一是增长 hash 函数,让每一个元素不止有两个巢,而是三个巢、四个巢。这样能够大大下降碰撞的几率,将空间利用率提升到 95%左右。

另外一个改良方案是在数组的每一个位置上挂上多个座位,这样即便两个元素被 hash 在了同一个位置,也没必要当即「鸠占鹊巢」,由于这里有多个座位,你能够随意坐一个。除非这多个座位都被占了,才须要进行挤兑。很明显这也会显著下降挤兑次数。这种方案的空间利用率只有 85%左右,可是查询效率会很高,同一个位置上的多个座位在内存空间上是连续的,能够有效利用 CPU 高速缓存。

因此更加高效的方案是将上面的两个改良方案融合起来,好比使用 4 个 hash 函数,每一个位置上放 2 个座位。这样既能够获得时间效率,又能够获得空间效率。这样的组合甚至能够将空间利用率提到高 99%,这是很是了不得的空间效率。

布谷鸟过滤器

布谷鸟过滤器和布谷鸟哈希结构同样,它也是一维数组,可是不一样于布谷鸟哈希的是,布谷鸟哈希会存储整个元素,而布谷鸟过滤器中只会存储元素的指纹信息(几个bit,相似于布隆过滤器)。这里过滤器牺牲了数据的精确性换取了空间效率。正是由于存储的是元素的指纹信息,因此会存在误判率,这点和布隆过滤器一模一样。

首先布谷鸟过滤器仍是只会选用两个 hash 函数,可是每一个位置能够放置多个座位。这两个 hash 函数选择的比较特殊,由于过滤器中只能存储指纹信息。当这个位置上的指纹被挤兑以后,它须要计算出另外一个对偶位置。而计算这个对偶位置是须要元素自己的,咱们来回忆一下前面的哈希位置计算公式。

fp = fingerprint(x)
p1 = hash1(x) % l
p2 = hash2(x) % l
复制代码

咱们知道了 p1 和 x 的指纹,是没办法直接计算出 p2 的。

特殊的 hash 函数

布谷鸟过滤器巧妙的地方就在于设计了一个独特的 hash 函数,使得能够根据 p1 和 元素指纹 直接计算出 p2,而不须要完整的 x 元素。

fp = fingerprint(x)
p1 = hash(x)
p2 = p1 ^ hash(fp)  // 异或
复制代码

从上面的公式中能够看出,当咱们知道 fp 和 p1,就能够直接算出 p2。一样若是咱们知道 p2 和 fp,也能够直接算出 p1 —— 对偶性。

p1 = p2 ^ hash(fp)
复制代码

因此咱们根本不须要知道当前的位置是 p1 仍是 p2,只须要将当前的位置和 hash(fp) 进行异或计算就能够获得对偶位置。并且只须要确保 hash(fp) != 0 就能够确保 p1 != p2,如此就不会出现本身踢本身致使死循环的问题。

也许你会问为何这里的 hash 函数不须要对数组的长度取模呢?其实是须要的,可是布谷鸟过滤器强制数组的长度必须是 2 的指数,因此对数组的长度取模等价于取 hash 值的最后 n 位。在进行异或运算时,忽略掉低 n 位 以外的其它位就行。将计算出来的位置 p 保留低 n 位就是最终的对偶位置。

// l = power(2, 8)
p_ = p & 0xff
复制代码

数据结构

简单起见,咱们假定指纹占用一个字节,每一个位置有 4 个 座位。

type bucket [4]byte  // 一个桶,4个座位
type cuckoo_filter struct {
  buckets [size]bucket // 一维数组
  nums int  // 容纳的元素的个数
  kick_max  // 最大挤兑次数
}
复制代码

插入算法

插入须要考虑到最坏的状况,那就是挤兑循环。因此须要设置一个最大的挤兑上限

def insert(x):
  fp = fingerprint(x)
  p1 = hash(x)
  p2 = p1 ^ hash(fp)
  // 尝试加入第一个位置
  if !buckets[p1].full():
    buckets[p1].add(fp)
    nums++
    return true
  // 尝试加入第二个位置
  if !buckets[p2].full():
    buckets[p2].add(fp)
    nums++
    return true
  // 随机挤兑一个位置
  p = rand(p1, p2)
  c = 0
  while c < kick_max:
    // 挤兑
    old_fp = buckets[p].replace_with(fp)
    fp = old_fp
    // 计算对偶位置
    p = p ^ hash(fp)
    // 尝试加入对偶位置
    if !buckets[p].full():
      buckets[p].add(fp)
      nums++
      return true
    c++
  return false
复制代码

查找算法

查找很是简单,在两个 hash 位置的桶里找一找有没有本身的指纹就 ok 了。

def contains(x):
  fp = fingerprint(x)
  p1 = hash(x)
  p2 = p1 ^ hash(fp)
  return buckets[p1].contains(fp) || buckets[p2].contains(fp)
复制代码

删除算法

删除算法和查找算法差很少,也很简单,在两个桶里把本身的指纹抹去就 ok 了。

def delete(x):
  fp = fingerprint(x)
  p1 = hash(x)
  p2 = p1 ^ hash(fp)
  ok = buckets[p1].delete(fp) || buckets[p2].delete(fp)
  if ok:
    nums--
  return ok
复制代码

一个明显的弱点

so far so good!布谷鸟过滤器看起来很完美啊!删除功能和获取元素个数的功能都具有,比布隆过滤器强大多了,并且彷佛逻辑也很是简单,上面寥寥数行代码就完事了。若是插入操做返回了 false,那就意味着须要扩容了,这也很是显而易见。

but! 考虑一下,若是布谷鸟过滤器对同一个元素进行屡次连续的插入会怎样?

根据上面的逻辑,毫无疑问,这个元素的指纹会霸占两个位置上的全部座位 —— 8个座位。这 8 个座位上的值都是同样的,都是这个元素的指纹。若是继续插入,则会当即出现挤兑循环。从 p1 槽挤向 p2 槽,又从 p2 槽挤向 p1 槽。

也许你会想到,能不能在插入以前作一次检查,询问一下过滤器中是否已经存在这个元素了?这样确实能够解决问题,插入一样的元素也不会出现挤兑循环了。可是删除的时候会出现高几率的误删。由于不一样的元素被 hash 到同一个位置的可能性仍是很大的,并且指纹只有一个字节,256 种可能,同一个位置出现相同的指纹可能性也很大。若是两个元素的 hash 位置相同,指纹相同,那么这个插入检查会认为它们是相等的。

插入 x,检查时会认为包含 y。由于这个检查机制会致使只会存储一份指纹(x 的指纹)。那么删除 y 也等价于删除 x。这就会致使较高的误判率。

论文没有欺骗咱们,它也提到了这个问题。(读者没必要理解后半句)

图片
这句话明确告诉咱们若是想要让布谷鸟过滤器支持删除操做,那么就必须不能容许插入操做屡次插入同一个元素,确保每个元素不会被插入屡次(kb+1)。这里的 k 是指 hash 函数的个数 2,b 是指单个位置上的座位数,这里咱们是 4。

在现实世界的应用中,确保一个元素不被插入指定的次数那几乎是不可能作到的。若是你以为能够作到,请思考一下要如何作!你是否是还得维护一个外部的字典来记录每一个元素的插入次数呢?这个外部字典的存储空间怎么办?

由于不能完美的支持删除操做,因此也就没法较为准确地估计内部的元素数量。

证实

下面咱们使用开源的布谷鸟过滤器库来证实一下上面的推论

go get github.com/seiflotfy/cuckoofilter
复制代码

这个布谷鸟过滤器对每一个元素存储的指纹信息为一个字节,同一个位置会有 4 个座位。咱们尝试向里面插入 15 次同一个元素。

package main

import (
	"fmt"
	"github.com/seiflotfy/cuckoofilter"
)

func main() {
	cf := cuckoo.NewFilter(100000)
	for i := 0; i < 15; i++ {
		var ok = cf.Insert([]byte("geeky ogre"))
		fmt.Println(ok)
	}
}

-------
true
true
true
true
true
true
true
true
false
false
false
false
false
false
false
复制代码

咱们发现插入它最多只能插入 8 次同一个元素。后面每一次返回 false 都会通过上百次的挤兑循环直到触碰了最大挤兑次数。

若是两个位置的 8 个座位 都存储了同一个元素,那么空间浪费也是很严重的,空间效率直接被砍得只剩下 1/8,这样的空间效率根本没法与布隆过滤器抗衡了。

若是不支持删除操做,那么布谷鸟过滤器单纯从空间效率上来讲仍是有必定的可比性的。这确实比布隆过滤器作的要好一点,可是布谷鸟过滤器这必须的 2 的指数的空间需求又再次让空间效率打了个折扣。

相关项目

布谷鸟过滤器论文:Cuckoo Filter: Practically Better Than Bloom

Redis 布谷鸟过滤器模块: github.com/kristoff-it…

最有影响力的布谷鸟过滤器 C 库:github.com/efficient/c…

相关文章
相关标签/搜索