一篇读懂布隆过滤器:用 1% 的误判换 90% 的内存

缓存、爬虫、数据库、浏览器——大量系统的某个角落都在回答同一个问题:这个元素,我之前见过吗? 用户名是否已被注册?这条 URL 爬过没有?这个 key 在缓存里存在吗?用哈希表回答当然精确,但如果「见过」的集合有 90 亿条 URL,全量 key 的内存账单会先压垮你。布隆过滤器(Bloom Filter)是 1970 年给出的答案:每元素不到 10 个 bit,换来一个不对称的承诺——它可能把没见过的说成「可能见过」(可控制在 1% 甚至更低),但绝不会把见过的说成没见过。很多场景里,这个不对称正好够用。

一个不对称的承诺

先看清这个承诺的形状。布隆过滤器对「元素在集合中吗」只有两种回答:

  • 「一定不在」:100% 可信;
  • 「可能 在」:大概率在,小概率是误判(false positive)。

假阴性(false negative,把见过的说成没见过)在标准布隆过滤器里数学上不可能——这一点是全部应用的根基。为什么这个残缺的承诺反而有用?因为多数真实场景里,两种答案的后续代价天差地别:爬虫判断「没爬过」才去爬(误判的代价只是少爬一条);缓存层判断「一定不存在」就直接返回(挡住穿透,误判的代价只是多查一次数据库);恶意 URL 初筛为「可能恶意」才进昂贵的完整检查。过滤器的回答只是便宜的初筛,误判由下游的精确查询兜底——设计一个系统用布隆过滤器,本质是在设计「误判之后发生什么」。

工作原理:k 个哈希,一位不增

结构简单到不像话:一个 m 位的位数组,外加 k 个哈希函数。

插入:元素过 k 个哈希函数得到 k 个位置,全部置 1。 查询:同样算出 k 个位置,任一位为 0 → 一定不在(如果插入过,这些位必然全被置 1——假阴性由此封死);全为 1 → 可能在(可能只是碰巧,别的元素把这些位都占了)。

关键在空间账怎么算。整个数组 m 位、塞 n 个元素、目标误判率 ε,最优配置是:

m = −n · ln ε / (ln 2)²      →  每元素约 −2.08 · ln ε 个 bit
k = (m/n) · ln 2             →  只取决于目标误判率

代入数字:1% 误判率只要 9.6 bit/元素,k = 7;误判率每压低 10 倍,每元素加约 4.8 bit。内存账用真实量级算一遍:1 亿个平均 32 字节的 key,哈希表哪怕不计 value、只存 key 加指针开销也要 4.8 GB 上下;布隆过滤器 1% 误判率只要 0.12 GB——四十倍的差距,而 0.1% 误判率也才 0.18 GB。对比例不空缺的理想哈希表,布隆过滤器不存元素本身、开销是每元素常数个 bit,代价就是那句不对称的承诺。

误判率有一个闭式公式,也是所有调参的依据:一位在插入 n 个元素后仍是 0 的概率约 e^(−kn/m)(某次插入没碰它的概率 1−1/m 连乘 n·k 次),查询时 k 个特定位恰好全被别人置 1 的概率就是:

ε ≈ (1 − e^(−kn/m))^k

Wikipedia 词条还给了 Goel–Gupta(2010)的严格上界,含义可以理解为「近似公式最多多算半个元素、少算一位」——对工程调参来说,近似式足够准。

k 不是越多越好:一条 U 形曲线

公式里最反直觉的是 k 的取舍。多一个哈希函数,「误判需要的位被占满」当然更难;但每个元素也会多占几位,数组更满,留给别人的空位反而更少。两种力量一正一反,误判率对 k 是一条 U 形曲线。固定每元素 9.6 bit,把 k 从 1 扫到 20,理论误判率是:

k 1 3 5 7 10 14 20
误判率 9.9% 1.94% 1.11% 1.00% 1.30% 2.47% 7.04%

最低点正好落在 k = (m/n)·ln 2 ≈ 6.65 取整的 7 上——k 的公式不是经验值,是这条 U 形曲线的最低点。实用推论:哈希函数不是白来的,超过最优点后每加一个都是纯损失;反过来,离最优点差一两个(k=5 对 k=7),代价只有 11%,工程上完全可接受。

实战范式:挡在缓存前面的第一道闸

布隆过滤器最高频的落地场景是缓存穿透:恶意请求拿着大量不存在的 key 打接口,每一发都绕过缓存直击数据库,轻则慢、重则雪崩。标准的三层布防:

  1. 启动/定时把全量合法 key 灌入布隆过滤器(放内存,1 亿 key 约 0.12 GB);
  2. 请求先问过滤器——「一定不在」直接返回,数据库零流量;
  3. 「可能在」才走正常链路:查缓存,未命中再查库;查完发现确实不存在(那 1% 误判),就把空结果短暂缓存,挡住同一个 key 的连发。

这套布防的精妙处在分工:布隆过滤器负责「批量说不存在」,空值缓存负责「盯住个别误判」,两者互补,谁也不能单独替代谁。新 key 的加入是实时的(add 一个位图操作),删除则处理不了——已失效的 key 只能等误判率上飘后整表重建,或者直接上 Counting 变体。把这套结构用在注册查重、爬虫去重、消息去重上,模式一模一样,换的只是「合法 key 集合」是谁。

本地实测:公式准到什么程度

按上面的配置写一个最小实现(工程上常用 Kirsch–Mitzenmacher 的双哈希技巧:用两个基础哈希 h1 + i·h2 线性组合出 k 个位置,省掉 k 次完整哈希):

import hashlib, math, struct

class BloomFilter:
    def __init__(self, n: int, eps: float):
        self.m = math.ceil(-n * math.log(eps) / math.log(2) ** 2)  # 总位数
        self.k = max(1, round(self.m / n * math.log(2)))           # 哈希个数
        self.bits = bytearray((self.m + 7) // 8)

    def _pos(self, item: str):
        h = hashlib.sha256(item.encode()).digest()
        h1 = struct.unpack_from("<Q", h, 0)[0]
        h2 = struct.unpack_from("<Q", h, 8)[0] | 1   # 取奇数防周期退化
        return [(h1 + i * h2) % self.m for i in range(self.k)]

    def add(self, item):
        for p in self._pos(item):
            self.bits[p >> 3] |= 1 << (p & 7)

    def __contains__(self, item):
        return all(self.bits[p >> 3] >> (p & 7) & 1 for p in self._pos(item))

插入 10 万个随机元素后实测,理论值与实测值几乎重合:

目标误判率 k 实测 bit/元素 理论误判率 实测误判率
1% 7 9.59 0.01004 0.00971
0.1% 10 14.38 0.00100 0.00099

同时验证了 10 万个已插入元素全部查询为真——零假阴性。20 行代码,教科书公式,实测即所见。

它活在哪儿

「便宜的初筛 + 昂贵的精确查询」这个模式,在各路系统里反复上演:

系统 用它挡什么 一句话说明
Chrome(曾用) 恶意 URL 全量比对 本地布隆初筛,命中才请求完整安全列表
Firefox 证书吊销与恶意扩展 级联布隆过滤器(CRLite),阳性再查精确结构
Bigtable / HBase / Cassandra / ScyllaDB / PostgreSQL 不存在的行/列的磁盘读 先查内存里的过滤器,一定不存在就不碰磁盘
Akamai CDN 「只被访问一次」的对象 第二次请求才落缓存,磁盘写入率几乎减半
Bitcoin(曾用) 钱包同步过滤交易 后因隐私泄露风险弃用(误报集合可暴露钱包地址)
Grafana Tempo / Medium 不存在的 trace / 已推荐过的内容 初筛避免昂贵的精确查询与重复推荐

注意 Cassandra 这类数据库的用法是标准范式:过滤器说「不在」就跳过磁盘 IO,说「可能在」才去走正常的查询路径——误判的代价被精确地限制为一次多余的磁盘读。Bitcoin 的弃用则是另一面的警示:过滤器不加密,误报的「可能见过」集合本身可能泄露信息,用它做涉及隐私的初筛要格外小心。

代价与边界

布隆过滤器不是免费午餐,四条边界要刻在脑子里:

第一,不能删除。元素的 k 个位可能被其他元素共享,清掉任何一位都可能制造假阴性——这会击穿整个结构的根基。要删除就得用 Counting Bloom Filter(Fan 等人,2000):把每个位换成 3–4 位的计数器,插入递增、删除递减,查询查非零。空间涨 3–4 倍,还引入计数器溢出与「需要预知容量」的新问题。

第二,容量必须预估。装填超过设计容量后误判率快速上飘,全满之后所有查询都返回「可能在」,过滤器报废。规划时按增长上限留余量,或用可扩容的变体。

第三,有 44% 的理论税。信息论下界是每元素 log₂(1/ε) 个 bit,布隆过滤器实际用 1.44 · log₂(1/ε)——比理论最优多 44%,这是「只用一个位数组、不存元素」这个简洁性换来的。另外元素数很小或能精确枚举时,一个普通位数组(每候选元素 1 bit)可能比布隆更省:千级规模、全集可枚举的场景,1000 个 bit 的确定性数组就能做到零误判,布隆的空间优势要 n 足够大、全集不可枚举时才真正显现。

第四,查询结果是初筛不是结论。返回「可能在」后必须由业务侧精确确认(查数据库、查完整列表)。把布隆过滤器的「可能」直接当成「是」,是这类结构在生产事故里最常见的原因。

选型时还有一张变体地图值得知道:需要删除,看 Cuckoo Filter(2014,指纹 + 两个候选桶,空间与最优布隆相当且支持删除);数据集静态(建好不再加),看 Xor / Binary Fuse Filter(Graf 与 Lemire 2020 起的系列,比布隆更小更快,代价是建成后不能插入);数据量会持续增长,看 Scalable Bloom Filter(分段扩容)。而默认的心智模型仍然是布隆本身——生态最熟、实现最多、面试最爱问,2026 年它依然是这个领域的通用语。

结语

布隆过滤器把一个工程真理做成了数据结构:多数时候你并不需要精确答案,只需要一个便宜且不会说谎一半的初筛。它用假阳性的自由换来了假阴性的绝迹,用 9.6 bit/元素换掉了全量存储。什么时候该想起它?当你发现自己在为「见过吗」这个问题存全量 key、而误判的后果只是一次多余查询的时候——那一刻,这 55 年前的老结构就是最优解。

参考资料

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?