Contents

布隆过滤器实战:在海量数据中优雅判断存在性

一个被低估的数据结构

后端开发中经常遇到这类需求:判断一个元素是否在集合中存在。当数据量小的时候,一个 mapSet 就够了。但当数据量到亿级——比如邮箱是否已注册、URL 是否已爬取、手机号是否在黑名单——内存就吃不消了。

10 亿个 64 字符的字符串直接放内存,大约需要 60 GB。布隆过滤器用不到 2 GB 就能覆盖同样的数据量,代价只是极低的误判率。

厃普西隆:布隆过滤器的核心原理

布隆过滤器由 Burton Bloom 在 1970 年提出,核心思路是:用一个位数组加多个哈希函数,用空间换准确性

它有两个关键特征:

  • 可能误判(假阳性):说存在,可能不存在
  • 绝不漏判(假阴性):说不存在,一定不存在

这个不对称性非常重要——它适合「宁可错杀,不可放过」的场景。

工作流程

初始化一个 m 位的位数组,全置 0。插入元素时,用 k 个哈希函数算出 k 个位置,全部置 1。查询时,同样算出 k 个位置,只要有一个位是 0,则元素一定不在;如果全是 1,则元素「可能」在。

1
2
3
位数组: [0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1]
            ↑        ↑     ↑           ↑
         hash1(x) hash2(x) hash3(x)  hash4(x)

不同元素的哈希位置会重叠,这就是误判的来源。但只要位数组够大、哈希函数够多,误判率可以控制在 1% 以下。

Go 原生实现

下面是一个最小可用的布隆过滤器实现,核心逻辑不到 60 行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package bloom

import (
	"hash/fnv"
	"math"
)

// BloomFilter 布隆过滤器
type BloomFilter struct {
	bitmap []uint64    // 位数组,用 uint64 切片模拟
	k      int         // 哈希函数数量
	bits   uint        // 位数组总位数
}

// New 根据预期元素数 n 和目标误判率 p 计算最优参数
func New(n int, p float64) *BloomFilter {
	// 最优位数组大小: m = -(n * ln(p)) / (ln(2)^2)
	m := uint(math.Ceil(-1 * float64(n) * math.Log(p) / (math.Ln2 * math.Ln2)))
	// 最优哈希函数数: k = (m/n) * ln(2)
	k := int(math.Ceil(float64(m) / float64(n) * math.Ln2))
	return &BloomFilter{
		bitmap: make([]uint64, (m+63)/64),
		k:      k,
		bits:   m,
	}
}

func (bf *BloomFilter) hash(data []byte, seed uint32) uint {
	h := fnv.New32a()
	h.Write([]byte{byte(seed), byte(seed >> 8), byte(seed >> 16)})
	h.Write(data)
	return uint(h.Sum32()) % bf.bits
}

func (bf *BloomFilter) setBit(pos uint) {
	idx, offset := pos/64, pos%64
	bf.bitmap[idx] |= 1 << offset
}

func (bf *BloomFilter) getBit(pos uint) bool {
	idx, offset := pos/64, pos%64
	return bf.bitmap[idx]&(1<<offset) != 0
}

// Add 添加元素
func (bf *BloomFilter) Add(data []byte) {
	for i := 0; i < bf.k; i++ {
		bf.setBit(bf.hash(data, uint32(i)))
	}
}

// Contains 判断元素是否存在(可能有假阳性)
func (bf *BloomFilter) Contains(data []byte) bool {
	for i := 0; i < bf.k; i++ {
		if !bf.getBit(bf.hash(data, uint32(i))) {
			return false // 任一位为 0,必定不存在
		}
	}
	return true // 全为 1,可能存在
}

实际生产中不要自己造轮子——用 github.com/bits-and-blooms/bloom/v3 库,API 更完善,支持序列化和并发安全:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import "github.com/bits-and-blooms/bloom/v3"

// 容量 1000 万,误判率 0.1%
filter := bloom.NewWithEstimates(10000000, 0.001)
filter.Add([]byte("[email protected]"))

if filter.Test([]byte("[email protected]")) {
    // 可能存在,查数据库确认
}
if !filter.Test([]byte("[email protected]")) {
    // 一定不存在,直接返回
}

Redis 集成:跨进程共享布隆过滤器

单机布隆过滤器重启就丢失,多实例之间也无法共享。Redis 4.0+ 通过 RedisBloom 模块提供了原生的布隆过滤器支持:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 创建容量 100 万、误判率 1% 的过滤器
BF.RESERVE user_emails 0.01 1000000

# 添加元素
BF.ADD user_emails "[email protected]"

# 批量添加
BF.MADD user_emails "[email protected]" "[email protected]"

# 判断是否存在
BF.EXISTS user_emails "[email protected]"    # 返回 1(可能存在)
BF.EXISTS user_emails "[email protected]"   # 返回 0(一定不存在)

Go 客户端用 github.com/redis/go-redis/v9 配合 RedisBloom:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
	"context"
	"fmt"

	redisbloom "github.com/RedisBloom/redisbloom-go"
)

func main() {
	client := redisbloom.NewClient("localhost:6379", "", nil)
	ctx := context.Background()
	_ = ctx

	// 创建过滤器(已存在则忽略错误)
	client.Reserve("url_seen", 0.01, 1000000)

	// 爬虫去重场景
	url := "https://example.com/page-123"
	exists, _ := client.Exists("url_seen", url)
	if exists {
		fmt.Println("已爬取过,跳过")
		return
	}
	// 抓取页面...
	client.Add("url_seen", url)
	fmt.Println("新增到爬取队列")
}

三个经典工程场景

场景一:缓存穿透防护

恶意请求大量不存在的 key,绕过缓存直击数据库。布隆过滤器挡在最前面:

1
请求 → 布隆过滤器拦截 → 命中?查缓存/DB → 未命中?直接返回空

启动时把数据库所有 ID 灌入布隆过滤器,请求先过布隆这一关。假阳性偶尔漏几个请求到数据库,不影响大局。

场景二:爬虫 URL 去重

10 亿 URL 全放内存要 60+ GB,布隆过滤器只需 1.7 GB(误判率 1%)。偶尔重复抓取几个页面,远比 60 GB 内存划算。

场景三:邮箱/手机号注册拦截

用户输入邮箱时,前端先查布隆过滤器。如果返回「不存在」,100% 可以放行注册;如果返回「可能存在」,再查数据库确认。大部分新邮箱直接通过,数据库压力大幅降低。

参数调优:误判率与内存的权衡

布隆过滤器没有银弹,核心是三个变量的关系:元素数 n、误判率 p、位数组大小 m、哈希函数数 k。

元素数 n 误判率 p 内存占用 哈希函数 k
100 万 1% 1.2 MB 7
100 万 0.1% 1.8 MB 10
1 亿 1% 114 MB 7
1 亿 0.01% 229 MB 16

经验法则:先定 n(容量),再定 p(误判率),m 和 k 由公式自动算出。p 调小一档,内存翻倍但误判大幅下降。

一个容易踩的坑:布隆过滤器不支持删除。如果业务需要移除元素,用 Counting Bloom Filter(每个位用计数器代替 0/1),或改用 Cuckoo Filter。

总结

布隆过滤器解决的不是「存什么」,而是「肯定不存在」这个判断。它的不对称性——假阴性为零,假阳性可控——让它在缓存防护、去重、黑名单等场景无可替代。

实践建议:

  • 优先用 RedisBloom,跨进程共享且支持持久化
  • 误判率起步设 1%,够用再降
  • 容量预估宁大勿小,满了误判率会指数上升
  • 需要删除时换 Cuckoo Filter,别硬删

掌握布隆过滤器,你就在工具箱里多了一把专门解决「海量数据存在性判断」的利器。