一、为什么需要布隆过滤器
你有一张十亿行的用户表,查询 user_id = "xyz789" 是否存在。最直接的办法——去磁盘读索引,二分查找,一次 I/O 大约 10 毫秒。听起来不多,但如果 80% 的查询命中的根本就是不存在的 key,每次都老老实实走磁盘就太浪费了。
我们需要一个「哨兵」:在查磁盘之前先问它一句——这个 key 可能存在吗?如果它说「一定不存在」,直接返回,省掉磁盘 I/O;如果它说「可能存在」,再去磁盘确认。关键在于:说「不存在」的时候必须 100% 靠谱,说「存在」的时候允许偶尔误报。误报的代价不过是一次多余的磁盘读取,漏报的代价却是数据丢失——这个不对称性正好是布隆过滤器的设计土壤。
二、现实类比
想象一个记性不太好的门卫。他手里有一张名单,但不是逐字抄写的那种,而是只记关键特征。有人来访时,他先扫一眼名单上对应的特征标记:如果该有的标记一个都没有,他会斩钉截铁地说「不在名单上,请回」;如果标记都对得上,他会说「可能在名单上,进去吧」——但他偶尔会认错人,因为不同的人可能凑巧共享了某些特征标记。
这个门卫有两个特点:
- 绝不漏人:名单上的人,对应的特征标记一定全打了勾,所以不会说出「不在名单上」
- 偶尔错放:不在名单上的人,可能凑巧所有标记都对上了,被误认为名单上的人
这就是布隆过滤器的精髓:假阴性不可能,假阳性有可能。
三、核心思想
布隆过滤器的底层是一个 m 位的位数组(初始全为 0),加上 k 个相互独立的哈希函数。插入元素时,用 k 个哈希函数分别计算出 k 个位置,把这些位全部置 1;查询时,同样计算 k 个位置,如果所有位都是 1,返回「可能存在」,只要有任何一个位是 0,返回「一定不存在」。
上图中,插入 apple 后,位 1、4、7 被置为 1。接下来查询 banana,假如 h1(banana) = 1、h2(banana) = 3、h3(banana) = 7——位 3 仍然是 0,立刻判定「一定不存在」。
3.1 为什么不能删除
假设先插入了 apple(置位 1、4、7),再插入 grape(置位 4、5、9),位 4 被两个元素共享。如果删除 apple 时把位 4 清零,grape 的证据就被破坏了——下次查询 grape 会得到「不存在」的错误结论。这正是布隆过滤器不支持删除的根本原因:一个位被多个元素共享,无法判断清零是否会影响其他元素。
3.2 误判率公式
插入 n 个元素后,某一个特定位仍然是 0 的概率为:
P(某位 = 0) = (1 - 1/m)^(kn) ≈ e^(-kn/m)查询一个不在集合中的元素时,k 个位恰好全为 1 的概率(即假阳性率)为:
ε ≈ (1 - e^(-kn/m))^k这个公式揭示了一个重要的工程经验:当 m/n ≈ 10(即每个元素分配约 10 个 bit)且 k = 7 时,假阳性率约为 1%。换算成直觉——1 亿个元素只需要大约 1.2 GB 内存,远低于哈希表动辄十几 GB 的开销。
3.3 核心操作与复杂度
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 插入(Add) | O(k) | 计算 k 次哈希,置 k 个位 |
| 查询(Test) | O(k) | 计算 k 次哈希,检查 k 个位 |
| 合并(Merge) | O(m) | 两个等大过滤器按位 OR,得到并集 |
空间复杂度 O(m),与元素数量 n 无关,只取决于你愿意接受多大的误判率。
两个相同大小的布隆过滤器按位 OR,结果等价于把两个集合的元素全部插入同一个过滤器——这正是「可合并」属性。在分布式系统中,各节点维护本地过滤器,定期合并即可得到全局视图。
四、变体与对比
4.1 布隆过滤器 vs 哈希集 vs 布谷鸟过滤器
| 特性 | 布隆过滤器 | 哈希集 | 布谷鸟过滤器(Cuckoo Filter) |
|---|---|---|---|
| 空间开销 | ~10 bit/元素(1% 误判) | ~100+ bit/元素 | ~10 bit/元素(1% 误判) |
| 假阳性 | 有,可控制在 1% 以下 | 无 | 有,可控制,同误判率下比布隆过滤器更低 |
| 假阴性 | 无 | 无 | 无 |
| 删除 | 不支持 | 支持 | 支持(删除指纹,不影响其他元素) |
| 查询性能 | O(k),k 次哈希 + 内存访问 | O(1),1 次哈希 | O(1),2 次探测 |
| 合并 | 按位 OR | 需重建 | 不支持直接合并 |
| 计数变体 | Counting Bloom(4x 空间) | 天然支持 | 天然支持 |
| 容量上限 | 超载后误判率渐增 | 动态扩容 | 有硬上限,插入失败需重建 |
布谷鸟过滤器(Cuckoo Filter)是 2014 年提出的一种替代方案,核心思路是用布谷鸟哈希存储每个元素的指纹(fingerprint)而非位标记。它的优势在于:支持删除——删除时移除对应指纹,不会像布隆过滤器那样误伤其他元素;查询只需 2 次内存访问,比布隆过滤器的 k 次更快;在误判率低于 3% 时空间效率优于布隆过滤器。但布谷鸟过滤器也有明显的代价:容量有硬上限,一旦装满再插入会触发无限踢出循环,只能重建更大的过滤器;两个过滤器不能像布隆过滤器那样按位 OR 合并。选择建议:如果需要删除元素且对合并没有需求(如 CDN 缓存淘汰、数据库 SSTable 过滤),布谷鸟过滤器更合适;如果需要合并或容量弹性更大,布隆过滤器更稳妥。
4.2 计数布隆过滤器
标准布隆过滤器不能删除,一个自然的改进是把每个位扩展为一个计数器(通常 4 bit)。插入时计数器加 1,删除时减 1,减到 0 才真正清零。代价是空间膨胀 4 倍,而且计数器溢出(超过 15)需要特殊处理。除非确实需要删除,否则不值得用。
五、多语言实现
5.1 Go 实现
package bloomfilter
import "hash/fnv"
// BloomFilter 用 m 位位数组和 k 个哈希函数实现type BloomFilter struct { bits []uint64 // 位存储,每个 uint64 含 64 位 k int // 哈希函数数量 m uint // 位数组总大小}
// New 创建布隆过滤器,m 为位数组大小,k 为哈希函数数量func New(m uint, k int) *BloomFilter { return &BloomFilter{ bits: make([]uint64, (m+63)/64), k: k, m: m, }}
// Add 将元素加入过滤器func (bf *BloomFilter) Add(data []byte) { h1, h2 := bf.baseHashes(data) for i := 0; i < bf.k; i++ { // Kirsch-Mitzenmacher 双哈希:h_i = h1 + i*h2 pos := (h1 + uint64(i)*h2) % uint64(bf.m) bf.bits[pos/64] |= 1 << (pos % 64) }}
// Test 检查元素是否可能存在func (bf *BloomFilter) Test(data []byte) bool { h1, h2 := bf.baseHashes(data) for i := 0; i < bf.k; i++ { pos := (h1 + uint64(i)*h2) % uint64(bf.m) if bf.bits[pos/64]&(1<<(pos%64)) == 0 { return false // 有位为 0,一定不存在 } } return true // 所有位为 1,可能存在}
// baseHashes 用两次 FNV 哈希生成两个独立哈希值func (bf *BloomFilter) baseHashes(data []byte) (uint64, uint64) { h := fnv.New128a() h.Write(data) sum := h.Sum(nil) return uint64(sum[0])<<56 | uint64(sum[8]), // 取前 8 字节作为 h1 uint64(sum[1])<<56 | uint64(sum[9]) // 取后 8 字节作为 h2}上面的实现用了 Kirsch-Mitzenmacher 双哈希技巧:只计算两次真实哈希,然后通过 h_i = h1 + i * h2 线性组合出 k 个哈希值。这比调用 k 次独立哈希函数快得多,而实践表明它的误判表现与 k 次独立哈希几乎没有差别。LevelDB 也是这么做的。
5.2 TypeScript 实现
class BloomFilter { private bits: Uint8Array; // 位存储,按字节组织 private k: number; // 哈希函数数量
constructor(private m: number, k: number) { this.bits = new Uint8Array(Math.ceil(m / 8)); this.k = k; }
// 用两个种子做简单哈希,模拟双哈希 private hash(data: string, seed: number): number { let h = seed; for (let i = 0; i < data.length; i++) { // FNV-1a 变体,不同种子产生不同哈希 h = ((h ^ data.charCodeAt(i)) * 0x01000193) >>> 0; } return h % this.m; }
add(data: string): void { for (let i = 0; i < this.k; i++) { const pos = this.hash(data, i * 0x811c9dc5); this.bits[pos >> 3] |= 1 << (pos & 7); } }
test(data: string): boolean { for (let i = 0; i < this.k; i++) { const pos = this.hash(data, i * 0x811c9dc5); if ((this.bits[pos >> 3] & (1 << (pos & 7))) === 0) { return false; // 有位为 0,一定不存在 } } return true; // 可能存在 }}
// 使用示例:10 万位,7 个哈希函数const bf = new BloomFilter(100_000, 7);bf.add("hello");console.log(bf.test("hello")); // trueconsole.log(bf.test("world")); // false(大概率)TypeScript 版本用了 FNV-1a 哈希的种子变体来生成多个哈希值。生产环境中建议用 crypto.getHashes() 或成熟的哈希库替代,这里仅作原理演示。
六、生产验证
LevelDB —— SSTable 读前过滤
LevelDB 在每个 SSTable 末尾附加一个布隆过滤器。查询 key 时先调用 KeyMayMatch,只有过滤器返回「可能存在」才会去磁盘读取 data block。过滤器返回「不存在」则直接跳过,省掉一次磁盘 I/O。
实现细节值得一提:LevelDB 使用 Kirsch-Mitzenmacher 双哈希技巧,只算一次 BloomHash,再通过位旋转推导 delta,循环累加生成 k 个哈希值(第 39 行注释有说明)。默认每个 key 分配 10 bit,k = 7,假阳性率约 1%。这里的不对称性很关键——假阳性只浪费一次磁盘读取,假阴性却会导致数据丢失,而布隆过滤器恰好保证了假阴性不可能发生。
Chromium Blink —— CSS 选择器快速排除
Chromium 的 Blink 渲染引擎用 8192 位的布隆过滤器(kFilterSize = 8192)来加速 CSS 选择器匹配。渲染引擎为每个 DOM 节点维护一个祖先标识过滤器,遇到 CSS 规则时先调用 FastRejectSelector,能拒绝 60-70% 的不匹配规则,避免昂贵的完整选择器遍历。
有意思的是,Blink 的实现用的是单哈希函数(本质上是 k=1 的布隆过滤器),用 std::bitset<8192> 存储。注释中解释:100 个不同字符串在 8192 位过滤器中的假阳性率约 1.2%,对于快速排除场景已经足够。这里刻意用单哈希换取更少的计算开销——毕竟 CSS 匹配在渲染热路径上,每次查询都要省着算。
Cassandra —— SSTable 读优化
Apache Cassandra 同样在 SSTable 上附加布隆过滤器,用于在读路径上快速排除不包含目标 key 的 SSTable。Cassandra 默认每元素分配约 12 bit,假阳性率约 0.6%。与 LevelDB 类似,假阳性只导致一次多余的磁盘读取,假阴性则意味着数据丢失——布隆过滤器零假负的特性完美匹配这个需求。
七、小结
什么时候用
- 数据库/缓存读前过滤:查磁盘或网络前先用布隆过滤器排除不存在的 key,假阳性只浪费一次 I/O,假阴性为零
- 网页缓存/CDN 去重:用极少的内存维护 URL 集合,快速判断资源是否已缓存
- 分布式系统:各节点维护本地过滤器,定期按位 OR 合并,获得全局集合的近似视图
- 拼写检查/垃圾邮件过滤:快速排除不在词典中的词或不在黑名单中的地址
什么时候别用
- 需要删除元素:标准布隆过滤器不支持删除,请用 Counting Bloom 或布谷鸟过滤器
- 需要精确答案:无法忍受任何误判的场景(如金融交易判重),老老实实用哈希集
- 集合很小(< 1000 个元素):哈希表的空间开销可以接受,布隆过滤器的常数开销反而不划算
- 需要枚举所有元素:布隆过滤器只能回答「在不在」,无法遍历集合中的元素
八、参考资料
- Less Hashing, Same Performance: Building a Better Bloom Filter - Kirsch, Mitzenmacher 2006, 双哈希技巧的理论分析
- LevelDB Bloom Filter 实现 - Google, SSTable 读前过滤,Kirsch-Mitzenmacher 双哈希
- Cuckoo Filter: Practically Better Than Bloom - Fan et al., 2014, 支持删除的替代方案
- Chromium SelectorFilter - Blink 渲染引擎,8192 位过滤器用于 CSS 选择器快速排除
- Bloom Filter Wikipedia - 误判率公式推导与最优参数选择
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






