布谷鸟过滤器:布隆过滤器的更优缓存穿透解?

0 阅读10分钟

布谷鸟过滤器:布隆过滤器的更优缓存穿透解?

前言

在海量数据处理的场景中,我们经常会遇到这样一个问题:如何快速判断一个元素是否已经存在?

例如,在缓存系统中防止缓存穿透,在爬虫系统中判断 URL 是否已经爬取过,或者在黑名单系统中快速过滤非法请求。

长期以来,布隆过滤器(Bloom Filter) 是这个领域采用的解决方案。然而,布隆过滤器存在一个致命的缺陷:它很难支持动态删除操作。

直到 2014 年,一篇名为《Cuckoo Filter: Practically Better Than Bloom》的论文提出了 布谷鸟过滤器(Cuckoo Filter) 完美解决了布隆过滤器的删除痛点问题。

那么本文将深入解析布谷鸟过滤器的工作原理,从它的数据结构到增删改查的具体流程,带你彻底搞懂这个"布隆过滤器的更优解"。

一、从布隆过滤器说起:我们为什么需要新方案?

在了解布谷鸟过滤器之前,我们有必要先回顾一下它的前辈 —— 布隆过滤器。这能帮助我们更好地理解布谷鸟过滤器解决了什么问题。

1.1 布隆过滤器的工作原理

布隆过滤器的核心思想非常简单:它利用一个超长的二进制位数组和多个哈希函数来压缩表示一个集合。

  • 插入:当插入一个元素时,我们使用 k 个不同的哈希函数,将这个元素映射到位数组中的 k 个不同的位置,并把这些位置全部置为 1。

  • 判重:当查询一个元素时,我们同样计算这 k 个位置。如果所有位置都是 1,我们就认为这个元素存在;只要有一个位置是 0,就说明这个元素一定不存在。

布隆过滤器实现图示

1.2 布隆过滤器的局限

尽管布隆过滤器很强大,但它的设计也带来了难以克服的局限:

  1. 不支持删除:这是最核心的问题。由于位数组中的每一位都可能被多个元素共享,你无法简单地把某一位置 0,因为这会影响到其他元素的查询结果。

布隆过滤器不支持删除图示

  1. 计数布隆的空间代价:为了解决删除问题,人们提出了计数布隆过滤器(Counting Bloom Filter),它把每一位从 1bit 扩展成了一个 4bit 的计数器。插入时加 1,删除时减 1。但这直接导致空间开销暴涨了 3-4 倍,严重抵消了布隆过滤器的空间优势。

  2. 查询性能随精度下降:为了降低误判率,布隆过滤器需要增加哈希函数的数量 k。这意味着每次查询都要读取 k 个不同的内存位置,很容易引发多次缓存缺失,拖慢查询速度。

正是在这样的背景下,布谷鸟过滤器应运而生。它旨在保留布隆过滤器空间效率的同时,原生支持高效的删除操作,并且提供更稳定的查询性能。

二、布谷鸟过滤器的核心架构

布谷鸟过滤器本质上是一种结合了 布谷鸟哈希(Cuckoo Hashing)指纹(Fingerprint) 技术的哈希表。

与普通哈希表不同,它并不存储元素的完整 Key,而是只存储元素的一小段指纹。这使得它能像布隆过滤器一样,通过牺牲极小的准确性来换取巨大的空间节省。

2.1 核心元素:指纹与桶数组

要理解布谷鸟过滤器,首先要搞懂它的两个基本组成部分:

  1. 指纹(Fingerprint): 对于任意一个元素 x,我们提取它哈希函数所得值的一小段,这就是指纹 f。

如果我们想要 1% 的误判率,指纹长度可能只需要 8-10 位。指纹的作用是代表元素 x 本身,因为存储完整的 x 太占空间了。

注意:既然是哈希函数,就必然存在哈希碰撞的可能。两个不同的元素可能拥有相同的指纹,这也是布谷鸟过滤器误判的来源。

  1. 桶数组(Bucket Array): 整个过滤器的底层是一个数组,数组中的每一项被称为一个 “桶(Bucket)”。与普通数组的每一项只能存一个值不同,这里的每个桶可以存放多个指纹。在标准实现中,每个桶通常可以存放 4 个指纹(即 b=4)。

这种设计意味着,当多个元素的哈希值冲突到同一个桶时,它们可以并排放在一起,而不需要链表或者重哈希。

布谷鸟过滤器数据结构示意图

2.2 核心公式:候选位置的计算

布谷鸟过滤器最巧妙的地方在于,它为每个元素计算了两个候选的桶位置。而且,最神奇的是,你只需要知道当前的桶索引和元素的指纹,就能反推出另一个候选桶的位置。

具体的计算公式如下:

其中, f=fingerprint(x)f = fingerprint(x) 是元素 x 的指纹。

这个公式利用了异或(XOR)操作的对称性。这意味着:

  • 如果你知道 h1h_1ff ,你可以算出 h2h_2

  • 反过来,如果你知道 h2h_2ff ,你同样可以算出 h1h_1

这一点至关重要。因为在布谷鸟过滤器中,我们只存储了指纹 f,并没有存储原始的 x。当我们需要把一个已经存进去的指纹 f 从桶 i 里挪走的时候,我们不需要原始的 x,只需要用 i 和 f,就能立刻算出它的另一个家在哪里。这就是所谓的部分键布谷鸟哈希(Partial-key Cuckoo Hashing)

三、核心操作:增删改查的完整流程

了解了基础结构,我们再来看看布谷鸟过滤器是如何实现插入、查询和删除这三个核心操作的。整个过程非常直观。

3.1 插入:驱逐机制解决冲突

插入是布谷鸟过滤器最复杂的一步,但逻辑也很清晰。

总流程

论文中的有空位插入操作示意图

  1. 计算元素 x 的指纹 f,以及它的两个候选桶 i1i_1i2i_2

  2. 检查这两个桶。如果其中任何一个桶还有空位,直接把 f 放进去,插入完成。

有空位的元素插入操作示意图

  1. 如果两个桶都满了,那就没办法了,必须 “赶走” 一个现有的 “住户”,给新来的腾位置。这就是布谷鸟哈希的 “驱逐(Kick-out)” 机制。

驱逐过程

  • 我们随机从 i1i_1i2i_2 中选一个桶,随机从里面挑一个已经存在的指纹 e。

  • 把 e 踢出去,把我们的新指纹 f 放进去。

  • 现在,被踢出去的 e 无家可归了。没关系,我们利用上面的公式 𝑖⊕ℎ𝑎𝑠ℎ(e),根据 e 当前所在的桶 i 和 e 自己的指纹,算出 e 的另一个候选桶 j。

  • 去 j 桶看看,如果有空位,把 e 放进去,结束。

  • 如果 j 桶也满了,那就重复上面的步骤:把 j 桶里的某个住户踢走,让 e 进去,然后被踢走的那个再去找下一个家...

  • 这个过程会一直重复,直到找到空位,或者重复了太多次(比如 500 次)还没找到,这说明过滤器太满了,需要扩容。

驱逐机制示意图

这个机制听起来有点像 “击鼓传花”,但实际上它的效率非常高,平均情况下插入的时间复杂度依然是 O (1)。而且,正是因为这种 “挪窝” 的能力,布谷鸟过滤器的空间利用率可以高达 95%!也就是说,整个桶数组 95% 的位置都被填满了,还能正常工作。

3.2 查询:最多两次内存访问

和插入操作类似,但查询简单太多了。

总流程

  1. 计算元素 x 的指纹 f,以及它的两个候选桶 i1i_1i2i_2

  2. 读取桶 i1i_1 ,检查里面有没有指纹 f。

  3. 如果没找到,读取桶 i2i_2 ,检查里面有没有指纹 f。

  4. 如果两个桶里找到了 f,返回 True;否则返回 False。

关键:所在桶⊕ℎ𝑎𝑠ℎ(被踢出指纹)。这一驱逐算法保证了被踢出的指纹永远只会在自己的两个候选桶之中,绝不会跑到第三个桶。

所以可以保证:无论你的过滤器有多大,无论你想要多低的误判率,每次查询最多只需要读取两个桶

反观布隆过滤器,为了达到同样的低误判率,它可能需要计算 7、8 个哈希函数,读取 7、8 个不同的位。因此,在实际性能上,布谷鸟过滤器的查询速度往往比布隆过滤器更快,而且更稳定。

3.3 删除:原生支持,无需计数器

这是布谷鸟过滤器最引以为傲的特性。删除操作简直和查询一样简单。

总流程

  1. 计算元素 x 的指纹 f,以及它的两个候选桶 i1i_1i2i_2

  2. 在这两个桶里寻找指纹 f。

  3. 如果找到了,直接把那个 f 从桶里删掉就行了!

就这么简单?是的!因为每个 slot 中存储的指纹都是独立的。我删掉一个,不会影响桶里的其他指纹,也不会影响其他桶。这完美解决了布隆过滤器的删除难题。

而且,它还避免了其他变种过滤器的 “假删除” 问题。例如,在 d-left 计数布隆过滤器中,如果两个不同的元素恰好共享了一个桶和相同的指纹,删除其中一个可能会误删另一个。但在布谷鸟过滤器中,这两个元素的候选桶必然是相同的(因为它们的 h1 和 h2 只跟自己的哈希和指纹有关),所以即使删错了,另一个元素的指纹依然还在另一个桶里,不会导致漏报(False Negative)。

四、总结

布谷鸟过滤器通过巧妙的部分键布谷鸟哈希,将指纹存储在可动态调整的桶数组中,完美地结合了哈希表的动态性和布隆过滤器的紧凑性。

核心优势

  1. 支持动态删除:这是最核心的改进,使得过滤器可以应对动态变化的集合。

  2. 更高的空间效率:在误判率低于 3% 的常见场景下,空间占用优于标准布隆过滤器。

  3. 更快的查询速度:无论过滤器多大,每次查询最多两次内存访问,性能稳定且高效。

  4. 实现简单:相比于 quotient filter 等其他复杂的替代品,布谷鸟过滤器的逻辑非常直观,易于实现。

当然,布谷鸟过滤器也不是银弹,它不适合存储大量重复元素(因为每个桶最多存 b 个相同指纹),而且当负载过高时必须扩容。

但不可否认的是,在绝大多数我们过去使用布隆过滤器的场景中,布谷鸟过滤器都是一个更好的选择。

感谢你看到这里,如果喜欢的话可以点个关注支持一下吧!也欢迎各位在评论区留言!