前置知识:建议先阅读上一篇。本文将沿着"Hash 表存储 key → 位图不存 key → 布隆过滤器概率判重"的演进路线展开。
一、Hash 表的"阿喀琉斯之踵"
第一篇我们讲了 Hash 表的强大:O(1) 查询、灵活通用。但它有一个无法回避的代价:
Hash 表必须存储 key 本身。
当数据量达到亿级 甚至十亿级时,这个代价变得不可接受:
- 1 亿个 20 字节的字符串 key = 1.9 GB 内存
- 加上 value、指针、对齐填充,实际占用可能超过 5 GB
但很多场景下,我们其实并不关心 key 的具体内容 ,只关心它是否存在:
- 这个 URL 爬取过吗?
- 这个用户今天签到过吗?
- 这个邮箱在黑名单里吗?
如果我们能不存 key,只存"存在性标记" ,空间将压缩到极致。
这就是位图(Bitmap)和布隆过滤器(Bloom Filter)诞生的原因。
二、位图:用 1 bit 表示一个整数
2.1 核心思想
位图是 Hash 思想最极致的简化:
用一个 bit 位来标记某个元素对应的 Value,而 Key 就是该元素本身(即数组下标)。
假设要记录 0~999 这 1000 个整数中哪些存在:
- 传统
bool数组:1000 × 1 字节 = 1000 字节 - 位图:1000 bit = 125 字节 ,压缩 8 倍
2.2 Go 实现
go
go
package main
import "fmt"
type BitMap struct {
bits []uint64 // 每个 uint64 管理 64 个 bit
size int
}
func NewBitMap(capacity int) *BitMap {
arrSize := (capacity + 63) / 64
return &BitMap{
bits: make([]uint64, arrSize),
size: capacity,
}
}
func (bm *BitMap) Set(pos int) {
if pos < 0 || pos >= bm.size {
return
}
idx := pos / 64
offset := pos % 64
bm.bits[idx] |= 1 << offset
}
func (bm *BitMap) Clear(pos int) {
if pos < 0 || pos >= bm.size {
return
}
idx := pos / 64
offset := pos % 64
bm.bits[idx] &^= 1 << offset
}
func (bm *BitMap) Test(pos int) bool {
if pos < 0 || pos >= bm.size {
return false
}
idx := pos / 64
offset := pos % 64
return (bm.bits[idx] & (1 << offset)) != 0
}
// Brian Kernighan 算法统计 1 的个数
func (bm *BitMap) Count() int {
count := 0
for _, v := range bm.bits {
for v != 0 {
v &= v - 1
count++
}
}
return count
}
func main() {
bm := NewBitMap(1000)
bm.Set(10)
bm.Set(100)
bm.Set(999)
fmt.Printf("占用空间: %d bytes\n", len(bm.bits)*8)
fmt.Printf("10 存在? %v\n", bm.Test(10))
fmt.Printf("11 存在? %v\n", bm.Test(11))
fmt.Printf("999 存在? %v\n", bm.Test(999))
fmt.Printf("已设置数量: %d\n", bm.Count())
}
2.3 位图的应用场景
表格
| 场景 | 说明 |
|---|---|
| 用户签到 | 用 1 个 bit 表示某天是否签到,1 年只需 365 bit ≈ 46 字节 |
| ID 去重 | 10 亿个 int64 去重,位图仅需 119 MB |
| 内存分页管理 | OS 用位图标记物理页是否被占用 |
| 布隆过滤器的底层 | 布隆过滤器本质上就是"多哈希 + 位图" |
2.4 位图的局限
位图虽然极致省空间,但有一个明显短板:它只能处理整数 key。如果 key 是字符串(如 URL、邮箱),无法直接作为数组下标。
怎么办?用 Hash 函数把字符串映射成整数,再用位图存储!
但单个 Hash 函数冲突概率太高。于是,布隆过滤器登场了。
三、布隆过滤器:多哈希 + 位图的完美联姻
3.1 核心思想
布隆过滤器是 1970 年由 Burton Howard Bloom 提出的概率型数据结构:
用多个哈希函数,对一个数据分别求哈希值,将对应的 k 个 bit 位都置为 1。查询时,只有 k 个位都为 1,才认为元素"可能存在"。
它的特点非常鲜明:
表格
| 特性 | 说明 |
|---|---|
| 空间效率 | 极高,不存储元素本身 |
| 查询时间 | O(k) ,与数据量无关 |
| 误判特点 | 只误判"存在" ,不会误判"不存在" |
| 不支持删除 | bit 位可能被多个元素共享 |
3.2 为什么需要多个哈希函数?
- 1 个哈希函数:冲突概率高,不同元素映射到同一个 bit 位的概率大
- k 个哈希函数 :两个不同元素的 k 个哈希值全部相同的概率极低,呈指数级下降
布隆过滤器用 k 个二进制位来表示一个对象的存在。当保存的数据越来越多,为 true 的位也越来越多,冲突的可能性会变大。但只要调整哈希函数的个数、位图大小跟要存储数据量之间的比例,就可以将误判概率降到非常低。
3.3 数学之美:最优参数推导
布隆过滤器的误判率 p 与三个参数相关:
- n :已插入元素数量
- m :位数组长度(bit 数)
- k :哈希函数个数
误判率公式:
p≈(1−e−mkn)k
最优参数推导:
当 k=nmln2 时,误判率最小。
由此推出:
- 最优位图大小:m=−(ln2)2n⋅lnp
- 最优哈希函数个数:k=nm⋅ln2≈0.7⋅nm
3.4 Go 实现(含参数自动计算)
go
go
package main
import (
"fmt"
"hash/fnv"
"math"
)
type BloomFilter struct {
bits []uint64
size int
hashNum int
count int
}
func NewBloomFilter(expectedElements int, falsePositiveRate float64) *BloomFilter {
n := float64(expectedElements)
p := falsePositiveRate
m := math.Ceil(-n * math.Log(p) / (math.Log(2) * math.Log(2)))
k := math.Ceil(m / n * math.Log(2))
arrSize := int(math.Ceil(m / 64))
fmt.Printf("参数: m=%.0f bits, k=%.0f, 占用 %.2f KB\n",
m, k, float64(arrSize*8)/1024)
return &BloomFilter{
bits: make([]uint64, arrSize),
size: arrSize * 64,
hashNum: int(k),
}
}
// 双重哈希模拟 k 个哈希函数: h_i(x) = (h1(x) + i * h2(x)) % m
func (bf *BloomFilter) hash(data []byte, i int) int {
h1 := fnv.New64a()
h1.Write(data)
h2 := fnv.New64()
h2.Write(data)
return int((h1.Sum64() + uint64(i)*h2.Sum64()) % uint64(bf.size))
}
func (bf *BloomFilter) Add(data string) {
bytes := []byte(data)
for i := 0; i < bf.hashNum; i++ {
pos := bf.hash(bytes, i)
bf.bits[pos/64] |= 1 << (pos % 64)
}
bf.count++
}
func (bf *BloomFilter) Contains(data string) bool {
bytes := []byte(data)
for i := 0; i < bf.hashNum; i++ {
pos := bf.hash(bytes, i)
if (bf.bits[pos/64] & (1 << (pos % 64))) == 0 {
return false // 只要有一个位为 0,一定不存在
}
}
return true // 所有位都为 1,可能存在
}
func (bf *BloomFilter) FalsePositiveRate() float64 {
n := float64(bf.count)
m := float64(bf.size)
k := float64(bf.hashNum)
return math.Pow(1-math.Exp(-k*n/m), k)
}
func main() {
bf := NewBloomFilter(1_000_000, 0.01)
urls := []string{
"https://juejin.cn/post/1",
"https://juejin.cn/post/2",
"https://juejin.cn/post/3",
}
for _, url := range urls {
bf.Add(url)
}
fmt.Printf("\npost/1 已爬取? %v\n", bf.Contains("https://juejin.cn/post/1"))
fmt.Printf("post/2 已爬取? %v\n", bf.Contains("https://juejin.cn/post/2"))
fmt.Printf("post/999 已爬取? %v (可能误判)\n", bf.Contains("https://juejin.cn/post/999"))
fmt.Printf("\n当前实际误判率: %.4f%%\n", bf.FalsePositiveRate()*100)
}
运行输出:
plain
yaml
参数: m=9585059 bits, k=7, 占用 1198.13 KB
post/1 已爬取? true
post/2 已爬取? true
post/999 已爬取? false (可能误判)
当前实际误判率: 0.0000%
100 万个 URL,仅需约 1.17 MB! 如果用 map[string]struct{} 存储,至少需要几十 MB。
四、实战:用布隆过滤器解决缓存穿透
缓存穿透:大量请求查询不存在的 key,直接打到数据库,导致 DB 压力过大。
布隆过滤器前置拦截:
go
go
package main
import (
"fmt"
"hash/fnv"
"math"
"sync"
"time"
)
type SimpleCache struct {
data map[string]string
mu sync.RWMutex
}
func NewSimpleCache() *SimpleCache {
return &SimpleCache{data: make(map[string]string)}
}
func (c *SimpleCache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
func (c *SimpleCache) Set(key, val string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = val
}
type CacheWithBloomFilter struct {
cache *SimpleCache
bf *BloomFilter
}
func NewCacheWithBloomFilter(expectedKeys int, fpRate float64) *CacheWithBloomFilter {
return &CacheWithBloomFilter{
cache: NewSimpleCache(),
bf: NewBloomFilter(expectedKeys, fpRate),
}
}
func (c *CacheWithBloomFilter) Get(key string) (string, bool) {
if !c.bf.Contains(key) {
return "", false // 一定不存在,直接拦截
}
if val, ok := c.cache.Get(key); ok {
return val, true
}
return "", false // 误判,实际不存在
}
func (c *CacheWithBloomFilter) Set(key, val string) {
c.cache.Set(key, val)
c.bf.Add(key)
}
type BloomFilter struct {
bits []uint64
size int
hashNum int
mu sync.RWMutex
}
func NewBloomFilter(expectedElements int, falsePositiveRate float64) *BloomFilter {
n := float64(expectedElements)
p := falsePositiveRate
m := math.Ceil(-n * math.Log(p) / (math.Log(2) * math.Log(2)))
k := math.Ceil(m / n * math.Log(2))
arrSize := int(math.Ceil(m / 64))
return &BloomFilter{
bits: make([]uint64, arrSize),
size: arrSize * 64,
hashNum: int(k),
}
}
func (bf *BloomFilter) hash(data []byte, i int) int {
h1 := fnv.New64a()
h1.Write(data)
h2 := fnv.New64()
h2.Write(data)
return int((h1.Sum64() + uint64(i)*h2.Sum64()) % uint64(bf.size))
}
func (bf *BloomFilter) Add(data string) {
bf.mu.Lock()
defer bf.mu.Unlock()
bytes := []byte(data)
for i := 0; i < bf.hashNum; i++ {
pos := bf.hash(bytes, i)
bf.bits[pos/64] |= 1 << (pos % 64)
}
}
func (bf *BloomFilter) Contains(data string) bool {
bf.mu.RLock()
defer bf.mu.RUnlock()
bytes := []byte(data)
for i := 0; i < bf.hashNum; i++ {
pos := bf.hash(bytes, i)
if (bf.bits[pos/64] & (1 << (pos % 64))) == 0 {
return false
}
}
return true
}
func main() {
cache := NewCacheWithBloomFilter(100_000, 0.01)
hotKeys := []string{"user:1001", "user:1002", "user:1003", "article:42"}
for _, k := range hotKeys {
cache.Set(k, fmt.Sprintf("data_of_%s", k))
}
attackKeys := []string{"user:9999", "user:8888", "article:99999"}
fmt.Println("=== 缓存穿透防护测试 ===")
for _, k := range attackKeys {
start := time.Now()
val, ok := cache.Get(k)
elapsed := time.Since(start)
if ok {
fmt.Printf("[%s] 命中缓存: %s (耗时 %v)\n", k, val, elapsed)
} else {
fmt.Printf("[%s] 被布隆过滤器拦截 (耗时 %v)\n", k, elapsed)
}
}
fmt.Println("\n=== 正常查询测试 ===")
for _, k := range hotKeys {
if val, ok := cache.Get(k); ok {
fmt.Printf("[%s] 命中: %s\n", k, val)
}
}
}
核心逻辑:
-
所有写入缓存的 key,同步加入布隆过滤器
-
查询时先过布隆过滤器:
false(一定不存在)→ 直接返回,不走缓存和数据库true(可能存在)→ 继续查缓存 → 缓存未命中再查数据库
绝大多数恶意请求在纳秒级被拦截。
五、位图 vs 布隆过滤器
表格
| 维度 | 位图 | 布隆过滤器 |
|---|---|---|
| 存储内容 | 整数是否存在 | 任意对象(通过 Hash)是否可能存在 |
| 空间复杂度 | O(N) bit | O(m) bit,m≪N×key size |
| 时间复杂度 | O(1) | O(k) |
| 误判率 | 0 | 可控的低误判率 |
| 删除支持 | ✅ | ❌(bit 位共享) |
| 适用场景 | 整数集合、签到系统 | URL 判重、缓存穿透、垃圾邮件过滤 |
六、进阶:删除问题与解决方案
布隆过滤器的经典缺陷是不支持删除。因为每个 bit 位可能被多个元素共享,直接置 0 会"误删"其他元素。
方案一:计数布隆过滤器(Counting Bloom Filter)
将每个 bit 扩展为多个 bit 的计数器(如 4 bit):
Add:对应 k 个位置的计数器 +1Delete:对应 k 个位置的计数器 -1Contains:k 个位置的计数器都 > 0
代价:空间变为原来的 4~8 倍。
方案二:布谷鸟过滤器(Cuckoo Filter)
更现代的替代方案,支持删除且空间效率更高:
- 每个元素只存两个候选位置(用两个哈希函数)
- 发生冲突时,"踢走"已有元素,让它去自己的另一个候选位置
- 每个槽存的是指纹(fingerprint,而非完整 Hash),空间更省
布谷鸟过滤器在 Redis 4.0 的 RedisBloom 模块中有实现。
七、总结
表格
| 知识点 | 核心要点 |
|---|---|
| 位图 | 1 bit 标记一个整数,空间压缩 8 倍,但只支持整数 |
| 布隆过滤器 | 多哈希 + 位图,用概率换空间,只误判"存在" |
| 最优参数 | m=−nlnp/(ln2)2 ,k=m/n⋅ln2 |
| 双重哈希 | hi=(h1+i⋅h2)%m ,用两个哈希函数模拟 k 个 |
| 缓存穿透 | 布隆过滤器前置拦截,纳秒级判断"一定不存在" |
| 删除方案 | 计数布隆过滤器、布谷鸟过滤器 |
从 Hash 表存储完整 key,到位图只存 1 bit,再到布隆过滤器的概率型判重------这是一条不断牺牲信息完整性以换取空间效率的演进路线。
没有完美的数据结构,只有适合场景的选择。 当你需要在海量数据中做快速存在性判断,且能容忍极低的误判率时,布隆过滤器就是那个"恰到好处"的答案。