一块内存挡掉 99% 的无效查询:布隆过滤器实战,以及它骗了你多少年

2026-11-03 11 0

先说一个扎心的事实:很多号称"高并发"的系统,QPS 的水分全在无效请求上。爬虫拿着不存在的商品 ID 疯狂试探,脚本小子拿随机 UUID 打接口,你的缓存明明命中率 99%,却因为那 1% 的穿透把数据库按在地上摩擦。

于是加锁、限流、加机器——最后发现机器越多烧得越快,因为每个机器都在认真回答一个"根本不存在"的问题。

布隆过滤器(Bloom Filter)就是来解决这个问题的:用一块几乎不占地方的内存,把绝大多数不存在的请求挡在缓存和数据库之前。它的误判率可以低到 0.1% 以下,代价是"可能把不存在的说成存在"(false positive),但绝不会"把存在的说成不存在"(绝不会漏判)。

有意思的是,这个结论和"布隆过滤器只支持添加不支持删除"的经典印象有点冲突——我们最后会讲怎么绕过它。

一、先搞清楚它到底在做什么

布隆过滤器本质上是一个位数组(bit array)加一组哈希函数。判断"某个 key 是否可能存在"的过程朴素得令人发指:

// 伪代码
func (b *Bloom) MayContain(key string) bool {
    for _, h := range b.hashFuncs {   // 比如 7 个哈希函数
        idx := h(key) % b.m            // 映射到位数组的某个位置
        if !b.bit[idx] {
            return false              // 只要有一位是 0 → 一定不存在
        }
    }
    return true                       // 全是 1 → "可能存在"

插入也是同一套逻辑:把这几个位置全部置 1。

整个过程的复杂度是 O(k),k 是哈希函数个数,典型值 4~7,也就是说我判断一个 key 大概是几次内存访问和几个位运算,比一次 Redis 往返便宜两个数量级,比一次 SQL 便宜三个数量级。

关键洞察:它的"假阳性"不是 bug,而是它的设计代价。位数组被填得越满,误判率越高,所以我们必须精心挑选位数组大小。

二、误判率怎么算?为什么你现在的参数一定是错的

公式不复杂,但绝大多数人凭感觉设参数,比如"一百万个元素,开个 10MB 数组,够了吧"。我们来算算。

设位数组长度 m,哈希函数个数 k,插入元素个数 n。填满的概率推下来,误判率 p 的近似公式是:

p ≈ (1 - e^(-kn/m))^k

当 k 取最优值 k = m/n × ln2 时,误判率最小,此时:

p ≈ (1 - e^(-kn/m))^k ≈ 0.6185^(m/n)

我们用 Python 快速验证并给一张速查表(n = 100 万):

import math
n = 1_000_000
for ratio, p in [(10, None), (12, None), (16, None), (20, None), (8, None)]:
    m = int(n * ratio)
    k = max(1, round(m / n * math.log(2)))
    p = (1 - math.exp(-k * n / m)) ** k
    print(f"m/n={ratio:>2}  m={m/1024/1024:>5.1f}MB  k={k:>2}  p={p:.6f}")

输出大致是:

m/n=10  m=  9.5MB  k= 7  p=0.008194
m/n=12  m= 11.4MB  k= 8  p=0.003065
m/n=16  m= 15.3MB  k=11  p=0.000455
m/n=20  m= 19.1MB  k=14  p=0.000061
m/n= 8  m=  7.6MB  k= 6  p=0.016807

看出规律了吗?

  • 每给一个元素多留 1 个 bit,误判率大致乘以 0.618,指数级下降,不是线性。
  • 从 8 bit/元素 提到 10 bit/元素,误判率只从 1.68% 降到 0.82%;但从 16 提到 20,就是从 0.045% 降到 0.006%,差了 7 倍多。
  • 所以"内存够就多开点"是个错误直觉。低于 16 bit/元素 的时候,内存的边际收益还没榨干;超过 24 bit/元素,再堆内存就纯属烧钱。

实战建议:你能接受 1% 的误判率吗?1% 听起来很低,但你的接口 QPS 是 10 万/秒的话,就是 1000 次/秒的假阳性查询打到数据库。所以面向数据库保护的场景,我一般直接按 16 bit/元素 起,配 k≈11,误判率 0.05% 左右。

三、哈希函数:为什么你需要 k 个,而不是 1 个

如果只用 1 个哈希函数,所有哈希到同一位置的元素会互相"连坐"——那个位一置 1,一大票 key 全都变成"可能存在",误判率会离谱地高。

多个哈希函数能降低这个聚集效应。有个巧妙的取巧方式:不需要真的实现 k 个不同的哈希函数,用两个就够了。

h1 = murmur(key)
h2 = murmur(key + 0x9e3779b9)   // 或另一个 seed

for i := 0; i < k; i++ {
    idx := (h1 + i*h2) % m     // 双哈希法
    ...
}

这个技巧叫 double hashing,效果和 Kirsch-Mitzenmacher 那套组合哈希几乎等价,但省掉了 k-1 次哈希计算。Kirsch-Mitzenmacher 的组合哈希做一次就是:

idx = (h1 + i*h2) % m

等等,这俩不是一样吗?—— 组合哈希理论上是 g(d) = h1(x) + i·h2(x) mod m,而双哈希是 (h1 + i·h2) mod m,形式相同但第二项是常量而非再哈希一次。生产里两者效果差异可以忽略,双哈希的常数因子更小,因为我们只算两次 murmur 而不是 k 次。

四、Go 实现:一个能用的 Counting Bloom Filter

先写个最朴素的版本,注意位数组用 []uint64 而不是 []bool——用 bool 的话每个 bit 占一个字节,内存直接翻 64 倍,这坑我见过太多人踩。

package bloom

import (
    "hash/maphash"
    "math/bits"
)

type Bloom struct {
    bits   []uint64
    m      uint64
    k      uint
    seeds  [2]uint64
}

func New(n uint64, bitsPerElem float64) *Bloom {
    m := uint64(float64(n) * bitsPerElem)
    m = (m + 63) / 64 * 64 // 对齐到 64
    k := uint(float64(m)/float64(n) * 0.6931) // ln2
    if k < 1 {
        k = 1
    }
    return &Bloom{
        bits:  make([]uint64, m/64),
        m:     m,
        k:     k,
        seeds: [2]uint64{0x9e3779b97f4a7c15, 0xbf58476d1ce4e5b9},
    }
}

func (b *Bloom) idx(h1, h2 uint64, i uint) uint64 {
    return (h1 + uint64(i)*h2) % b.m
}

func (b *Bloom) set(pos uint64) {
    b.bits[pos/64] |= 1 << (pos % 64)
}

func (b *Bloom) get(pos uint64) bool {
    return b.bits[pos/64]&(1<<(pos%64)) != 0
}

func (b *Bloom) Add(key string) {
    h1, h2 := b.hash(key)
    for i := uint(0); i < b.k; i++ {
        b.set(b.idx(h1, h2, i))
    }
}

func (b *Bloom) MayContain(key string) bool {
    h1, h2 := b.hash(key)
    for i := uint(0); i < b.k; i++ {
        if !b.get(b.idx(h1, h2, i)) {
            return false
        }
    }
    return true
}

哈希部分可以复用 hash/maphash(Go 1.19+ 自带,带随机 seed,天然防哈希碰撞攻击):

var seed = maphash.MakeSeed()

func (b *Bloom) hash(key string) (uint64, uint64) {
    var s1, s2 maphash.Hash
    s1.SetSeed(seed)
    s2.SetSeed(seed)
    s1.WriteString(key)
    s2.WriteString(key + "#2")
    return s1.Sum64(), s2.Sum64()
}

这个版本能用,但有个致命问题:不支持删除。因为一旦有位是多个 key 共享的,你无法确定这个位能不能清零。删掉一个 key 却让另一个 key 失效,缓存穿透直接变成缓存雪崩。

五、想删除?上计数布隆过滤器

Counting Bloom Filter 的思路很朴素:把每个 bit 换成一个 4 位或 8 位的计数器。插入时 +1,删除时 -1,归零就等价于清零那个 bit。

const counterBits = 4  // 0~15
const countersPerWord = 64 / counterBits // 16 个计数器塞进一个 uint64

type CountingBloom struct {
    words   []uint64
    m       uint64
    k       uint
    counters []uint8 // 简化版:用 uint8 存,牺牲内存换清晰
}

更工程一点的做法是用 uint64 打包 16 个 4-bit 计数器:

func (c *CountingBloom) inc(pos uint64, delta int8) {
    w := pos / countersPerWord
    o := (pos % countersPerWord) * counterBits
    cur := (c.words[w] >> o) & 0xF
    v := int(cur) + int(delta)
    if v < 0 { v = 0 }
    if v > 15 { v = 15 }
    c.words[w] = (c.words[w] &^ (0xF << o)) | (uint64(v) << o)
}

代价很明确:4 位计数器让内存变成普通布隆过滤器的 4 倍。也就是说你原本 16 bit/元素的设计,现在实际是 64 bit/元素。对"用内存换 CPU"的场景这很划算;但如果你的内存预算很紧,请老老实实接受"不支持删除",改用 定期重建。

另一个务实的思路:逻辑删除 + 定期重建。删除时先把 key 写进一个"墓碑集合",查询时如果命中墓碑就强制穿透到 DB 并异步删除;墓碑集合超过阈值就重建整个过滤器。很多业务场景下这就够用了。

六、布谷鸟哈希:更省内存,但不支持删除

布隆过滤器的空间效率其实还有优化空间。布谷鸟哈希(Cuckoo Filter)的思路是:每个 key 有两个候选位置(用两个哈希函数算出),如果某个位置被占了,就把里面的"客人"赶走,让它去它的另一个候选位置;如果两个位置都满了且赶不动,就扩容。

关键优化在于:每个槽位(slot)只存一个 8 位 fingerprint,而不是完整 key。于是同样的误判率下,内存占用大约是布隆过滤器的 1.23 倍以内、而空间效率比标准布隆好 20%~30%(取决于你需要的误判率)。

// 简化版思路
func (c *CuckooFilter) Insert(key string) bool {
    fp := fingerprint(key)      // 8 bit
    i1 := hash1(key) % numSlots
    i2 := hash2(key) % numSlots
    if c.slots[i1].empty() { c.slots[i1] = fp; return true }
    if c.slots[i2].empty() { c.slots[i2] = fp; return true }
    // 踢人循环:把占位者挪到它的备选位置
    cur, pos := fp, i1
    for i := 0; i < maxKicks; i++ {
        cur, pos = c.slots[pos], c.alt(cur, pos)
        if c.slots[pos].empty() { c.slots[pos] = cur; return true }
    }
    return false // 装不下 → 扩容
}

布谷鸟哈希最大的硬伤:无法删除。因为一个槽位里的 fingerprint 可能对应好几个 key,你删一个把整个槽清空,其他 key 就一起失效了。这个缺陷比布隆过滤器更硬,除非你能容忍"只增不减 + 定期重建",否则老老实实用布隆。

七、Redis 里的实战:RedisBloom 模块

大多数时候你不需要自己实现。Redis 4.0 之后官方 RedisBloom 模块提供了 BF.ADD / BF.EXISTS / BF.MADD / BF.RESERVE,以及计数版本 CF.ADD / CF.DEL / CF.COUNT。

# 预估容量 100 万,误判率 0.1%,RedisBloom 会自动算好 m 和 k
BF.RESERVE bloom:users 1000000 0.001

BF.ADD bloom:users user:1001 user:1002
BF.EXISTS bloom:users user:1001   # (integer) 1
BF.EXISTS bloom:users user:9999   # (integer) 0

如果不确定未来元素数量,可以不 RESERVE,直接 BF.ADD,RedisBloom 会在 100% 填充率时自动扩容(代价是那一刻的卡顿,能想象吗)。生产环境请务必先 RESERVE,把容量规划做完。

应用层的标准接法是这样的:

func GetUser(ctx context.Context, id string) (*User, error) {
    // 第一道门:布隆过滤器
    exists, err := rdb.Exists(ctx, "bloom:users", id).Result()
    if err != nil {
        // Redis 挂了怎么办?放行,别因为过滤器把业务拖死
        log.Warn("bloom check failed, fallback to db", "err", err)
    } else if !exists {
        // 确认不存在,直接返回空,连缓存都不用查
        return nil, ErrNotFound
    }

    // 第二道门:缓存
    if v := rcache.Get(ctx, "user:"+id); v != nil {
        return v, nil
    }

    // 第三道门:数据库
    u, err := db.QueryUser(ctx, id)
    if err != nil {
        return nil, err
    }
    if u == nil {
        return nil, ErrNotFound
    }
    rcache.Set(ctx, "user:"+id, u, time.Hour)
    return u, nil
}

注意那个 fallback 分支:Redis 故障时必须"放行"而不是"拦截"。布隆过滤器是个优化,不是安全边界,把它当依赖硬失败,Redis 抖一下你的整个接口就跟着挂,这是常见的过度设计。

八、还有几个真实的坑

坑一:数据全量重建时的高峰期。重建期间新旧过滤器必然不一致:新数据只写新过滤器,旧过滤器还没这段数据,于是"明明存在"的 key 会被误判为不存在 → 缓存击穿 → 数据库雪崩。解法是双缓冲:老过滤器继续服务,后台重建新过滤器,完成后原子切换(用一个指针或者 Redis 的 RENAME)。

坑二:别把布隆过滤器当权威数据源。它回答的是"可能存在",不是"存在"。任何依赖"精确答案"的逻辑(权限校验、订单归属)都别指望它。

坑三:分布式环境的过滤器怎么建?不要每个实例各建各的本地过滤器——那样每个实例都得装载全量 key,内存 × 实例数,扩容即噩梦。正确做法是全量数据放一个共享的 RedisBloom,所有实例共享;或者本地只做"热点 key 的负缓存",用带 TTL 的 LRU 存最近查询失败的 key。

坑四:key 的规范化必须一致。过滤器和 DB 用的 key 要一模一样(包括大小写、前后缀、大小写折叠规则)。曾经有个团队把 key 统一成小写,过滤器里存小写,查询时传大写,结果过滤器 100% 失效,防护形同虚设——而且因为"有时能挡住",很难排查。

坑五:误判率不是"漏一个没关系"。如果你的 DB 查询本身很贵(多表 join、聚合),0.05% 的假阳性打到 QPS 10 万的系统上,依然是每秒 50 次重查询。这时候要么把误判率压到 0.001%,要么在 DB 前面再挡一层轻量缓存。

九、什么时候你不需要布隆过滤器

不是所有场景都值得上:

  • 数据量小(比如几万条),直接用 Set 存全量 key,内存占用可能比布隆还小,还没有误判率这一说。
  • 更新极其频繁且需要删除,布隆和布谷鸟都不支持删除,得靠墓碑 + 重建,复杂度直接翻倍。
  • 攻击有随机性:如果攻击者每次都带随机参数(`?id=random&nonce=123`),那每个请求都是新 key,过滤器挡不住任何东西——因为随机 key 会被直接判为"不存在"从而被拦截……好吧,其实这种情况反而挡得很好。但如果攻击者用真实存在的 key 变形(比如 `user:1001` vs `user_1001`),filter 会判定为不存在,恰好也拦住了。真正的绕过方式是遍历真实存在的 key 空间,那属于另一类问题。

结语

布隆过滤器的美妙之处在于:它用"承认错误"换来了极致的空间效率。1 bit 一个元素,误判率千分之一,这是数学上能压到的极限区间。它不保证正确,但它保证不漏——在"保护数据库"这个目标上,这正是我们需要的性质。

但也别把它神化:

过滤器的价值不在于"挡住多少请求",而在于"让每一个真实存在的请求,都不再需要为不存在的数据付出代价"。

如果你正在为一个读多写少的热点接口做优化,先算清楚"穿透 QPS × 单次 DB 查询成本"这笔账,如果这笔账让你肉疼,一块几十兆的内存就能把问题解决一大半。这大概是内存最划算的一次投资。

最后一句:别再对着 QPS 曲线自我感动了,先看看那 99% 的请求里,有多少压根不该到达你的数据库。

相关文章

你的服务在裸奔还是在裸泳?服务网格 Istio 实战:四个没人提前告诉你的成本
你的 Go 服务不是内存泄漏,是 GC 在追债:GOGC、GOMEMLIMIT 与那 80% 的荒谬分配
慢 SQL 优化实战:从 8 秒到 30 毫秒,我修的不是代码,是索引
你的监控把你自己监控崩了:Prometheus 标签基数爆炸,一个 user_id 标签吃掉 8G 内存
备份全都在,恢复还是失败了:PITR 实战、恢复演练,以及那些没人告诉你的备份真相
你的服务不慢,是卡住了:eBPF 火焰图实战,5 分钟定位线上 P99 飙升

发布评论