41.8 布隆过滤器
一句话
布隆过滤器用 m 位数组 + k 个哈希回答一个方向的问题——「这个元素一定不在」;它只会把不在的说成「可能在」,不会把在的说成不在,代价是假阳性可调不可消、不支持删除、每元素约 10 bit,所以它只能当否决开关,不能当缓存或唯一性判据。
布隆过滤器凭什么用 120MB 表示 1 亿个 key
插入 x:k 个哈希算出 k 个位置,全部置 1
查询 x:k 个位置里只要有 1 个是 0 → x 一定不在
全是 1 → x 可能在(也可能只是别人的位撞上了)
| 表示方式 | 1 亿 key 的内存 | 支持删除 | 假阴性 | 假阳性 | 能取回原值 |
|---|---|---|---|---|---|
| HashSet / 原值集合 | 6–10GB | ✅ | 无 | 无 | ✅ |
| 排序数组 + 二分 | 1–2GB | ❌(需重建) | 无 | 无 | ✅ |
| 标准布隆(p=1%) | 120MB | ❌ | 无 | 1% | ❌ |
| Counting Bloom | ~480MB | ✅ | 无 | 略高 | ❌ |
| Cuckoo Filter | ~150MB | ✅ | 无 | ~0.05% | ❌ |
🔑 省下的是「存原值」的钱,不是「存信息」的钱:布隆只记住「这些位被谁碰过」,所以它答不了「值是什么」,也删不掉单个元素。
参数:n 和 p 一确定,m 和 k 就定了
设位数组长度 m、哈希个数 k、元素数 n:
某一位仍为 0 的概率 ≈ e^(-kn/m)
误判率 p ≈ (1 - e^(-kn/m))^k
最优哈希个数 k = (m/n) · ln2 ≈ 0.693 · (m/n)
反解 m/n = -ln(p) / (ln2)^2 ≈ 1.44 · log2(1/p) (bit/key)
| 目标误判率 p | bit/key | 最优 k | 1 亿 key 的内存 |
|---|---|---|---|
| 10% | 4.8 | 3 | 60MB |
| 1% | 9.6 | 7 | 120MB |
| 0.1% | 14.4 | 10 | 180MB |
| 0.01% | 19.2 | 13 | 240MB |
| 0.0001% | 28.8 | 20 | 360MB |
🔑 误判率每降低 10 倍,内存只增约 4.8 bit/key(+48%)——这是布隆最划算的地方;但反过来,容量翻倍而参数不变,误判率会从 1% 涨到 16%(实测见下),超容量 4 倍时约 68%。所以生产上必须监控「已插入数 / 设计容量」,接近 100% 就重建。
布隆过滤器的假阳性有方向:只能否决,不能确认
| 场景 | 布隆的判断 | 该怎么用 | 安全吗 |
|---|---|---|---|
| 缓存前置过滤 | 不在 → 直接返回空 | 只拦「不在」 | ✅ |
| URL / 黑名单过滤 | 不在 → 放行;在 → 再查精确名单 | 「在」只当线索 | ✅ |
| 爬虫 URL 去重 | 已抓过 → 跳过 | 1% 会漏抓 | ⚠️ 业务可容忍才行 |
| 用户名唯一性校验 | 已存在 → 拒绝注册 | 1% 的新用户被误拒 | ❌ |
| 权限 / 白名单判定 | 在 → 放行 | 假阳性 = 越权 | ❌ |
判断口诀:「不在」是确定答案,「在」只是待验证的线索。 只要业务把「在」当最终结论用,1% 的假阳性就会变成 1% 的误杀或越权。
⚠️ 还有一类更危险的错误:假阴性。布隆过滤器承诺「没有假阴性」的前提是与权威存储同步——如果数据写进了 DB、但
Add布隆失败(进程崩溃、网络抖动、异步任务丢失),这个 key 就会被永久判成「不存在」,用户看不到自己刚创建的数据。这比假阳性严重得多:假阳性只是多查一次库,假阴性是数据「消失」。所以布隆过滤器的写入要走可靠投递(见 24.6 本地消息表),并有「定期全量重建」兜底。
布隆过滤器的死穴:不能删除
位是被 k 个元素共享的。要删除元素 x,你不能把它的 k 位清 0——那些位可能是别的元素置的,清掉就制造了假阴性。
| 方案 | 支持删除 | 内存/元素 | 误判率 | 代价 |
|---|---|---|---|---|
| 标准布隆 | ❌ | 9.6 bit | 1% | 最省;删除只能整体重建 |
| Counting Bloom | ✅ | 约 4×(每 bit 配 4 bit 计数器) | 略高 | 计数溢出后退化,仍需定期重建 |
| Cuckoo Filter | ✅ | 约 12 bit | 0.05% 量级 | 插入可能失败,需要扩容策略 |
| Scalable Bloom | ❌ | 随层数增长 | 逐层累加 | 适合 n 未知(按需加层);仍不支持删单个元素 |
| 双 buffer 定期重建 | ✅(整体) | 2× | 1% | 最简单可靠:旧布隆继续服务,新布隆建好后原子切换 |
💡 选型顺序:n 已知且只增不减 → 标准布隆;有删除 → Cuckoo;n 会持续增长但删除很少 → 定期重建 + 预留 30% 余量。Counting Bloom 现在很少用,它的空间优势被 Cuckoo 吃掉了。
布隆过滤器和缓存怎么配合
请求 → 参数校验 → 布隆过滤器 ──不在──▶ 直接返回「不存在」(0 次 DB)
│
└─可能在─▶ L1 / L2 ──miss──▶ DB
它挡在缓存之前,因为它的判断比缓存还便宜(内存版一次查询约 100ns,RedisBloom 一次约 0.5ms)。放在哪一层,是个明确的取舍:
| 位置 | 查询耗时 | 容量 | 多实例一致性 | 适用 |
|---|---|---|---|---|
| 应用内存 | ~100ns | 每实例一份(1 亿 key @1% = 120MB × N) | 各自构建,需重建同步 | 数据量可控、追求极致延迟 |
RedisBloom(BF.RESERVE / BF.ADD / BF.EXISTS) | ~0.5ms | 一份(120MB) | 单一事实源 | 多实例、数据频繁变化 |
布隆只是漏斗的第一层,完整的分层顺序(浏览器 → CDN → L1 → L2 → DB)见 41.7 多级缓存。
内存布隆的容量账:1 亿 key、p=1% → 120MB/实例,20 个实例就是 2.4GB;RedisBloom 同样数据只占 120MB,但每次查询多 0.5ms。用「实例数 × 120MB」对比「每次多 0.5ms」做决定,不要拍脑袋。
它与进程内的 L1 抢同一块内存,预算要一起算(算法见 41.2 本地缓存与分布式缓存 的内存段)。
重建的窗口:1 亿 key × 7 次哈希 ≈ 7 亿次哈希运算,单核约 70 秒。这 70 秒里不能清空旧布隆——正确做法是双 buffer:旧布隆继续服务(新建但还没进布隆的数据会被误判「不存在」,靠空值缓存和写后读主兜底),新布隆建好后原子切换。
可运行实现:参数计算 + 实测误判率 + 穿透效果
package main
import (
"fmt"
"hash/fnv"
"math"
)
// ==================== 布隆过滤器 ====================
type bloom struct {
bits []uint64
m uint64 // 位数组长度
k uint64 // 哈希个数
}
// New 按目标误判率 p 反推参数:
//
// m = -n*ln(p) / (ln2)^2 (总位数)
// k = (m/n) * ln2 (最优哈希个数)
func New(n int, p float64) *bloom {
m := uint64(math.Ceil(-float64(n) * math.Log(p) / (math.Ln2 * math.Ln2)))
k := uint64(math.Round(float64(m) / float64(n) * math.Ln2))
if k < 1 {
k = 1
}
return &bloom{bits: make([]uint64, (m+63)/64), m: m, k: k}
}
// hash64 生成两个 64 位哈希:双哈希法用 h1 + i*h2 派生 k 个位置,
// 不必真的算 k 次字符串哈希。
func hash64(s string) (uint64, uint64) {
h := fnv.New64a()
h.Write([]byte(s))
h1 := h.Sum64()
h2 := (h1 >> 33) | (h1 << 31)
h2 *= 0x9e3779b97f4a7c15
h2 ^= h2 >> 29
if h2 == 0 {
h2 = 1
}
return h1, h2
}
func (b *bloom) Add(s string) {
h1, h2 := hash64(s)
for i := uint64(0); i < b.k; i++ {
pos := (h1 + i*h2) % b.m
b.bits[pos/64] |= 1 << (pos % 64)
}
}
func (b *bloom) MayContain(s string) bool {
h1, h2 := hash64(s)
for i := uint64(0); i < b.k; i++ {
pos := (h1 + i*h2) % b.m
if b.bits[pos/64]&(1<<(pos%64)) == 0 {
return false // 只要有一位是 0,就「一定不在」
}
}
return true // 全是 1:可能存在,也可能只是别人的位撞上了
}
// 用 n 个不存在的 key 实测误判率
func measureFP(b *bloom, n int) float64 {
fp := 0
for i := 0; i < n; i++ {
if b.MayContain(fmt.Sprintf("absent:%d", i)) {
fp++
}
}
return float64(fp) / float64(n)
}
func main() {
const n = 100_000
// ① 按 p=1% 设计,装 n 个 key
b := New(n, 0.01)
for i := 0; i < n; i++ {
b.Add(fmt.Sprintf("user:%d", i))
}
fmt.Printf("① 设计 p=1%%,n=%d:m=%d bit(%.1f bit/key),k=%d,实测误判率 %.2f%%\n",
n, b.m, float64(b.m)/float64(n), b.k, 100*measureFP(b, n))
// ② 同样的参数,塞 2n 个 key:观察误判率如何爆掉
b2 := New(n, 0.01)
for i := 0; i < 2*n; i++ {
b2.Add(fmt.Sprintf("user:%d", i))
}
fmt.Printf("② 超容量 2 倍(装入 %d 个):实测误判率 %.1f%%\n", 2*n, 100*measureFP(b2, n))
// ③ 接 41.3 的穿透场景:1000 个不存在的 ID,各请求 100 次
const absentKeys, repeats = 1000, 100
dbLoads := 0
for i := 0; i < absentKeys; i++ {
k := fmt.Sprintf("absent:%d", i)
for j := 0; j < repeats; j++ {
if b.MayContain(k) { // 只有假阳性才会被放行
dbLoads++
}
}
}
fmt.Printf("③ 10 万次「查不存在」:挡掉 %d 次,放行回源 %d 次\n",
absentKeys*repeats-dbLoads, dbLoads)
}
输出:
① 设计 p=1%,n=100000:m=958506 bit(9.6 bit/key),k=7,实测误判率 1.01%
② 超容量 2 倍(装入 200000 个):实测误判率 15.8%
③ 10 万次「查不存在」:挡掉 99100 次,放行回源 900 次
三行对应三个必须记住的事实:
- 设计值可信:1% 是算出来的,实测 1.01%;
- 超容量即失效:多装一倍 key,误判率从 1% 涨到 15.8%(16 倍),过滤效果基本报废;
- 漏斗是有效的:10 万次恶意查询只剩 900 次回源(0.9%),剩余部分由空值缓存兜住,完整的两级漏斗见 41.3 缓存穿透 / 击穿 / 雪崩。
工程细节:四个容易踩的点
| 细节 | 做法 | 不做的后果 |
|---|---|---|
| 容量预估 | 按「未来 12 个月的数据量 × 1.3」设计,监控填充率 | 半年后误判率从 1% 爬到 20%,过滤形同虚设 |
| 哈希函数 | 用双哈希(h1 + i*h2)或成熟库;不要用语言内置的弱哈希 | 分布不均,实际误判率高于公式 |
| 数据同步 | 写库成功后可靠地 Add;定期全量重建 | 假阴性 = 真实数据被判「不存在」 |
| 重建切换 | 双 buffer + 原子切换,旧布隆服务到新布隆就绪 | 重建窗口内所有请求回源,DB 被击穿 |
布隆过滤器在存储引擎里的用法值得对照:LSM 树的每一层有多个 SSTable,查一个不存在的 key 要挨个读盘。给每个 SSTable 配一个布隆(约 10 bit/key),一次内存查询就能排除大部分文件。这里的收益不是「省内存」,而是把一次 100µs 的磁盘 IO 换成一次 100ns 的内存查询——布隆的价值永远是「用极小的内存,省掉一次昂贵的查找」,具体到索引结构见 44.8 索引原理。
反模式
| 反模式 | 后果 |
|---|---|
| 用「布隆说存在」直接拒绝请求 | 1% 的合法用户被误杀,且投诉极难复现 |
| 用布隆校验用户名 / 订单号唯一性 | 误判 = 新用户无法注册 / 新订单被拒 |
| 容量按当前数据量设计,无余量 | 数据涨一倍,误判率涨 16 倍 |
| 期望布隆支持删除,手动清位 | 制造假阴性,把存在的数据判成不存在 |
写 DB 成功但 Add 失败不重试 | 数据「消失」,比假阳性严重一个量级 |
| 重建时先清空旧布隆 | 重建窗口内 100% 回源,DB 被击穿 |
| 多实例各自构建、从不重建 | 各实例判断不一致,问题随实例数放大 |
| 把布隆当缓存(以为能存 value) | 它只存「位」,取不出任何值 |
| 追求 p=1e-9 | 每降 10 倍要多花 4.8 bit/key,边际收益递减、内存线性上涨 |
| 多业务共用一个布隆、key 不加前缀 | 误判率叠加,A 业务的数据让 B 业务误判 |
自检
Q1 你按 1000 万用户、p=1% 上线了布隆过滤器。一年后用户涨到 2000 万,会发生什么?怎么提前发现?
▸ 答案:参数不动时,m/n 从 9.6 bit 掉到 4.8 bit,误判率从 1% 涨到约 16%(实测 15.8%)——原来 10 万次无效请求只放行 1000 次,现在放行 1.6 万次,穿透保护基本失效;若涨到 4 倍(4000 万),误判率约 68%,等于没有布隆过滤器。提前发现的唯一手段是监控填充率 / 已插入数占设计容量的比例,到 70% 就告警、到 90% 必须重建(重建为原容量的 2 倍)。不要等误判率表现出来——误判率本身很难直接观测,你只能看到 DB 回源 QPS 悄悄上涨。
Q2 团队想用布隆过滤器做「用户名是否已被注册」的前置校验,说「误判率只有 1%,可以接受」。这个设计错在哪?
▸ 答案:错在把假阳性用在了「拒绝」方向。布隆说「可能存在」时,真实情况有两种:确实存在(该拒),或者只是哈希撞了(不该拒)。1% 的假阳性意味着 1% 的新用户会被误判为「已注册」而注册失败——这不是性能问题,是功能事故,而且复现概率低、客服极难定位。正确用法是:布隆只用于「不在 → 直接放行 / 直接返回空」这种否决方向;「在」必须回落到权威存储(DB 唯一索引)去确认。唯一性校验的主场是数据库唯一约束,不是布隆。
Q3 1 亿个 key、目标误判率 0.1%,需要多少内存、几次哈希?如果要求再降 10 倍呢?值得吗?
▸ 答案:按公式 bit/key = 1.44·log2(1/p),0.1% 需要 14.4 bit/key、k=10,1 亿 key 约 180MB;再降 10 倍(0.01%)需要 19.2 bit/key、k=13,约 240MB——只多 33% 内存。所以「从 1% 降到 0.1%」很划算(+50% 内存换 10 倍过滤效果),再往下收益就开始递减,且 k 越大每次查询的哈希次数越多(k=20 时单次查询 20 次内存访问)。经验值:p 取 0.1%–1% 足够,剩下那点假阳性交给空值缓存兜底,比继续加内存便宜。
Q4 布隆重建需要 70 秒,这 70 秒里怎么保证不把 DB 打穿?
▸ 答案:双 buffer + 原子切换,绝不先清空。重建期间旧布隆继续服务:它能挡住历史数据,只是挡不住「重建开始后新写入」的 key——这部分会多回源一次,由空值缓存(TTL 30–120s)和写后读主兜住。新布隆建好后一次性替换指针(Go 里用 atomic.Pointer),旧布隆等引用归零后回收。若重建耗时不可接受,就把「全量重建」拆成「分片重建 + 增量 Add」,每片只停几十毫秒。关键指标是「重建窗口内 DB 回源 QPS 增量」,不是重建耗时本身。