Files
cs-note/hhs/Redis/14-BloomFilter.md
T
2026-05-25 23:50:33 +08:00

10 KiB
Raw Blame History

tags, create time
tags create time
Redis
缓存
数据结构
BloomFilter
2026-05-24 10:00

Redis Bloom Filter 布隆过滤器

概述

布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素是否存在于集合中。核心特性:

"Definitely not or probably yes" —— 说不存在则一定不存在;说存在则大概率存在(有极小误判概率)。

这个特性让它成为解决缓存穿透问题的利器。

[!question] 为什么需要布隆过滤器? 攻击者用大量不存在的 key 轰击系统,每个请求穿透缓存直打 DB,最终压垮数据库。布隆过滤器能在请求到达 DB 之前,用极低的内存成本拦截掉"肯定不存在"的请求。

一、核心原理

布隆过滤器的底层:一个 bit 数组 + 多个独立的哈希函数。

  • bit 数组:长度为 m,初始全部为 0
  • 哈希函数:k 个相互独立的哈希函数,每个函数将输入映射到 [0, m-1] 的某个位置

添加元素:对元素分别用 k 个哈希函数计算,将 bit 数组中对应位置全部置为 1。查询元素:用同样的 k 个哈希函数计算,所有对应位置都是 1 则判定"可能存在";任意一位是 0 则判定"一定不存在"。

[!tip] 为什么会有误判? 不同元素经过哈希计算后可能映射到相同位置(哈希冲突)。随着插入元素增多,查询一个不存在的元素时,恰好所有位置都被其他元素"碰巧"置为 1 的概率就会上升。

graph LR
    A["element x"] --> H1["Hash1(x) = 3"]
    A --> H2["Hash2(x) = 7"]
    A --> H3["Hash3(x) = 11"]
    H1 --> B1["bit[3] = 1"]
    H2 --> B2["bit[7] = 1"]
    H3 --> B3["bit[11] = 1"]

二、Redis 中的两种方案

方案 A:RedisBloom 模块(服务端)

RedisBloom 是 Redis 官方模块,默认不随 Redis 一起安装,需要单独编译或通过 Docker 加载:

# Docker 方式(推荐)
docker run -p 6379:6379 redis/redis-stack-server  # 内置 RedisBloom

核心命令:

命令 说明
BF.RESERVE key error_rate capacity 创建自定义布隆过滤器(生产环境推荐)
BF.ADD key item 添加元素(自动创建默认参数的过滤器)
BF.EXISTS key item 查询元素,返回 1=可能存在 / 0=一定不存在
BF.MADD key item1 item2 ... 批量添加
BF.MEXISTS key item1 item2 ... 批量查询

[!tip] 生产环境务必用 BF.RESERVE 预创建 BF.ADD 首次调用时会以默认参数自动创建过滤器(容量约 100,误判率 0.01),数据量稍大就会急剧膨胀误判率。正确做法是先 BF.RESERVE user_filter 0.0001 1000000 显式声明参数。

// go-redis 调用 RedisBloom
func checkWithRedisBloom(ctx context.Context, rdb *redis.Client, key, value string) (bool, error) {
    result, err := rdb.Do(ctx, "BF.EXISTS", key, value).Int() // 1=可能存在, 0=一定不存在
    if err != nil {
        return false, err
    }
    return result == 1, nil
}

方案 B:Go 侧布隆过滤器库(客户端)

使用 github.com/bits-and-blooms/bloom/v3 在应用内存中维护过滤器。

import "github.com/bits-and-blooms/bloom/v3"

filter := bloom.NewWithEstimates(1_000_000, 0.0001) // 100万元素, 误判率0.01%
filter.AddString("user:10086")                       // 添加
exists := filter.TestString("user:10086")             // 查询: true = 可能存在

三、缓存穿透防护实战

这是布隆过滤器最经典的应用场景。下面用一个完整的 Go 示例展示三层防护架构。

3.1 架构流程

graph TD
    Req["客户端请求"] --> BF{"布隆过滤器判断"}
    BF -- "不存在" --> Ret1["直接返回空, 拒绝请求"]
    BF -- "可能存在" --> Cache{"查询 Redis 缓存"}
    Cache -- "命中" --> Ret2["返回缓存数据"]
    Cache -- "未命中" --> DB["查询 MySQL"]
    DB -- "找到数据" --> SetCache["写入 Redis 缓存"]
    SetCache --> Ret3["返回数据"]
    DB -- "未找到" --> Ret4["返回空并缓存空值"]

3.2 完整代码示例

package main

import (
    "context"
    "encoding/json"
    "time"

    "github.com/bits-and-blooms/bloom/v3"
    "github.com/redis/go-redis/v9"
)

type User struct {
    ID   int64  `json:"id"`
    Name string `json:"name"`
}

type BloomCacheService struct { // 三层防护服务
    rdb    *redis.Client
    filter *bloom.BloomFilter
}

// NewBloomCacheService 初始化:加载全量 ID 到布隆过滤器
func NewBloomCacheService(rdb *redis.Client) *BloomCacheService {
    filter := bloom.NewWithEstimates(1_000_000, 0.0001) // 100万用户, 误判率0.01%

    // 从 Redis Set 批量加载已有 ID(生产环境可从 DB 加载)
    ctx := context.Background()
    ids, _ := rdb.SMembers(ctx, "user:all_ids").Result()
    for _, id := range ids {
        filter.AddString(id)
    }
    return &BloomCacheService{rdb: rdb, filter: filter}
}

// GetUser 三层查询:Bloom -> Redis -> MySQL
func (s *BloomCacheService) GetUser(ctx context.Context, userID string) (*User, error) {
    cacheKey := "user:" + userID

    // 第一层:布隆过滤器拦截
    if !s.filter.TestString(userID) {
        return nil, nil // 一定不存在
    }

    // 第二层:Redis 缓存
    data, err := s.rdb.Get(ctx, cacheKey).Bytes()
    if err == nil {
        if string(data) == "" {
            return nil, nil // 缓存的空值
        }
        var user User
        if err := json.Unmarshal(data, &user); err != nil {
            return nil, err // JSON 反序列化失败
        }
        return &user, nil
    }

    // 第三层:查询数据库
    user, err := queryMySQL(userID)
    if err != nil {
        return nil, err
    }

    if user == nil {
        s.rdb.Set(ctx, cacheKey, "", 5*time.Minute) // 缓存空值, 短 TTL
        return nil, nil
    }

    bytes, _ := json.Marshal(user)
    s.rdb.Set(ctx, cacheKey, bytes, 30*time.Minute)
    return user, nil
}

// queryMySQL 模拟数据库查询
func queryMySQL(userID string) (*User, error) {
    return nil, nil // 实际项目中查询 MySQL
}

[!question] 为什么还需要缓存空值? 布隆过滤器拦截了"肯定不存在"的请求,但如果某个 ID 曾经存在过(布隆过滤器会说"可能存在"),但实际已从 DB 删除,此时仍会穿透到 DB。缓存空值是第二道防线。

四、参数计算

选择布隆过滤器的两个关键参数:

参数 含义 影响
m bit 数组长度 越大误判率越低,但内存占用越多
k 哈希函数个数 太少冲突多,太多计算慢

公式(基于期望元素数 n 和目标误判率 p):

m = -\frac{n \ln p}{(\ln 2)^2}, \quad k = \frac{m}{n} \ln 2

[!tip] 快速估算 对于 n = 100 万、p = 0.01% 的常见场景:

  • m ≈ 19,170,117 bits ≈ 2.3 MB
  • k ≈ 13 个哈希函数

相比之下,用 Redis Set 存 100 万个字符串 key 至少需要 几十 MB。布隆过滤器的内存优势是数量级的。

import "math"

func calcBloomParams(n int, p float64) (m int, k int) {
    // m = -n * ln(p) / (ln2)^2
    ln2 := math.Ln2
    m = int(-float64(n) * math.Log(p) / (ln2 * ln2))
    // k = (m/n) * ln2
    k = int(math.Round(float64(m) / float64(n) * ln2))
    return m, k
}

五、RedisBloom vs Go-side 对比

维度 RedisBloom 模块 Go-side 布隆库
部署 需安装 RedisBloom 模块 只需引入 Go 依赖
网络开销 每次查询一次网络往返 本地内存, 零网络开销
分布式一致性 天然共享 需自行同步
性能 受网络 RT 限制, < 10 万 QPS 纯内存, 百万 QPS
持久化 随 Redis RDB/AOF 自动持久化 需额外持久化机制
适用场景 多实例共享, 数据量大 单服务高频查询

[!tip] 如何选择?

  • 微服务多实例:优先 RedisBloom,天然共享无需同步
  • 单体服务、极致性能:Go-side 布隆库,避免网络开销
  • 折中方案:Go-side 为主,定期从 Redis 同步 bit 数组快照

六、局限性

6.1 不支持删除

标准布隆过滤器不支持删除——将某位从 1 置为 0 可能影响其他元素判断。两种解决方案:

方案 原理 优点 缺点
计数布隆过滤器 用计数器替代 1 bit,删除时减 1 兼容标准布隆过滤器思路 内存开销增大 3-4 倍
Cuckoo Filter 基于 cuckoo hashing,直接存储元素指纹 支持删除、查询更快、空间更优 容量接近满时插入可能失败

RedisBloom 中 Cuckoo Filter 的命令:

CF.RESERVE key capacity   # 创建
CF.ADD key item            # 添加
CF.EXISTS key item         # 查询
CF.DEL key item            # 删除(布隆过滤器做不到)

[!tip] 何时用 Cuckoo Filter? 如果你的场景需要删除过期元素(比如用户注销后移除 ID),优先用 Cuckoo Filter。如果只做"只增不查删"的穿透防护,标准布隆过滤器更简单高效。

6.2 需要预加载

布隆过滤器必须在使用前加载全部合法元素。新增数据时需同步更新过滤器,初始化时需遍历全量数据。

[!question] 如果新增了数据但忘记更新布隆过滤器会怎样? 新增的 key 在布隆过滤器中查询会返回"不存在",导致合法请求被误拦截。这比缓存穿透更严重——用户明明有数据却查不到。所以务必确保新增数据时同步 BF.ADD 或 filter.AddString()。

[!warning] Go-side 过滤器的重启问题 Go 内存中的布隆过滤器在进程重启后完全丢失。两种应对策略:

  1. 启动时重建:从 DB 全量加载 ID 重新构建(数据量大时启动慢,可用 goroutine 并行加载)
  2. 持久化 bit 数组:将 bit 数组序列化到 Redis 或文件,启动时反序列化恢复(快但需要额外维护一致性)

如果重建成本不可接受,优先考虑 RedisBloom——数据随 Redis 持久化,应用重启无感知。

6.3 误判率随使用上升

实际元素数量远超预估值 n 时,误判率会显著上升。建议预留 20%-30% 余量,监控实际元素数量,必要时重建过滤器。

关联笔记