mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2887 字
8 分钟
布隆过滤器(Bloom Filter)
2026-06-13

一、为什么需要布隆过滤器#

你有一张十亿行的用户表,查询 user_id = "xyz789" 是否存在。最直接的办法——去磁盘读索引,二分查找,一次 I/O 大约 10 毫秒。听起来不多,但如果 80% 的查询命中的根本就是不存在的 key,每次都老老实实走磁盘就太浪费了。

我们需要一个「哨兵」:在查磁盘之前先问它一句——这个 key 可能存在吗?如果它说「一定不存在」,直接返回,省掉磁盘 I/O;如果它说「可能存在」,再去磁盘确认。关键在于:说「不存在」的时候必须 100% 靠谱,说「存在」的时候允许偶尔误报。误报的代价不过是一次多余的磁盘读取,漏报的代价却是数据丢失——这个不对称性正好是布隆过滤器的设计土壤。

二、现实类比#

想象一个记性不太好的门卫。他手里有一张名单,但不是逐字抄写的那种,而是只记关键特征。有人来访时,他先扫一眼名单上对应的特征标记:如果该有的标记一个都没有,他会斩钉截铁地说「不在名单上,请回」;如果标记都对得上,他会说「可能在名单上,进去吧」——但他偶尔会认错人,因为不同的人可能凑巧共享了某些特征标记。

这个门卫有两个特点:

  • 绝不漏人:名单上的人,对应的特征标记一定全打了勾,所以不会说出「不在名单上」
  • 偶尔错放:不在名单上的人,可能凑巧所有标记都对上了,被误认为名单上的人

这就是布隆过滤器的精髓:假阴性不可能,假阳性有可能

三、核心思想#

布隆过滤器的底层是一个 m 位的位数组(初始全为 0),加上 k 个相互独立的哈希函数。插入元素时,用 k 个哈希函数分别计算出 k 个位置,把这些位全部置 1;查询时,同样计算 k 个位置,如果所有位都是 1,返回「可能存在」,只要有任何一个位是 0,返回「一定不存在」。

flowchart LR subgraph 插入 "apple" A1["h1(apple) = 1"] --> B1["bit[1] = 1"] A2["h2(apple) = 4"] --> B2["bit[4] = 1"] A3["h3(apple) = 7"] --> B3["bit[7] = 1"] end subgraph 位数组 m=10 direction LR b0["0"] --- b1["1"] --- b2["0"] --- b3["0"] --- b4["1"] --- b5["0"] --- b6["0"] --- b7["1"] --- b8["0"] --- b9["0"] end

上图中,插入 apple 后,位 1、4、7 被置为 1。接下来查询 banana,假如 h1(banana) = 1h2(banana) = 3h3(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 无关,只取决于你愿意接受多大的误判率。

Note

两个相同大小的布隆过滤器按位 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")); // true
console.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 渲染引擎用 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 个元素):哈希表的空间开销可以接受,布隆过滤器的常数开销反而不划算
  • 需要枚举所有元素:布隆过滤器只能回答「在不在」,无法遍历集合中的元素

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

布隆过滤器(Bloom Filter)
https://blog.souloss.com/posts/programming/data-structures/data-structures-bloom-filter/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时