位图与布隆过滤器:海量数据下的"存在性判断"艺术

前置知识:建议先阅读上一篇。本文将沿着"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=nm​ln2 时,误判率最小。

由此推出:

  • 最优位图大小: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)
        }
    }
}

核心逻辑

  1. 所有写入缓存的 key,同步加入布隆过滤器

  2. 查询时先过布隆过滤器:

    • 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 个位置的计数器 +1
  • Delete:对应 k 个位置的计数器 -1
  • Contains: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,再到布隆过滤器的概率型判重------这是一条不断牺牲信息完整性以换取空间效率的演进路线。

没有完美的数据结构,只有适合场景的选择。 当你需要在海量数据中做快速存在性判断,且能容忍极低的误判率时,布隆过滤器就是那个"恰到好处"的答案。

相关推荐
Rnan-prince42 分钟前
堆与TopK · 从零到通透 —— 9 节全系列复盘
python·算法
明月_清风1 小时前
Hash 表从入门到精通:Go 实战与工程细节
前端·后端·算法
考虑考虑1 小时前
ElasticSearch索引命令
运维·后端·elasticsearch
nbsaas-boot1 小时前
多智能体系统的架构边界:任务编排、共享状态与故障隔离设计
人工智能·算法·动态规划
wuyk5552 小时前
12.归并排序:分治思想的稳定排序算法
开发语言·数据结构·算法·排序算法
吴声子夜歌2 小时前
ApacheCommons——commons-text(模板替换与文本算法)
java·开发语言·算法·apache
JiNan.YouQuan.Soft2 小时前
数值计算引擎:计算固体力学
人工智能·算法·机器学习
非凡ghost2 小时前
AI修图不“造假“,摄影师的出片效率加速器
服务器·前端·人工智能·电脑
2601_951615202 小时前
网页设计模板源码 web前端模板网页源码下载
前端