--- tags: [Redis, 缓存, 数据结构, BloomFilter] create time: 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 的概率就会上升。 ```mermaid 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 官方模块,通过 `BF.ADD` / `BF.EXISTS` 命令直接操作。 ```go // 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` 在应用内存中维护过滤器。 ```go 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 架构流程 ```mermaid graph TD Req["客户端请求"] --> BF{"布隆过滤器判断"} BF -- "不存在" --> Ret1["直接返回空, 拒绝请求"] BF -- "可能存在" --> Cache{"查询 Redis 缓存"} Cache -- "命中" --> Ret2["返回缓存数据"] Cache -- "未命中" --> DB["查询 MySQL"] DB -- "找到数据" --> SetCache["写入 Redis 缓存"] SetCache --> Ret3["返回数据"] DB -- "未找到" --> Ret4["返回空并缓存空值"] ``` ### 3.2 完整代码示例 ```go 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 json.Unmarshal(data, &user) 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**。布隆过滤器的内存优势是数量级的。 ```go 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 可能影响其他元素判断。解决方案是**计数布隆过滤器(Counting Bloom Filter)**,用计数器替代 1 bit,删除时计数器减 1。RedisBloom 的 Cuckoo Filter(`CF.ADD` / `CF.EXISTS`)也原生支持删除。 ### 6.2 需要预加载 布隆过滤器必须在使用前加载全部合法元素。新增数据时需同步更新过滤器,初始化时需遍历全量数据。 > [!question] 如果新增了数据但忘记更新布隆过滤器会怎样? > 新增的 key 在布隆过滤器中查询会返回"不存在",导致合法请求被误拦截。这比缓存穿透更严重——用户明明有数据却查不到。所以务必确保新增数据时同步 `BF.ADD` 或 `filter.AddString()`。 ### 6.3 误判率随使用上升 实际元素数量远超预估值 `n` 时,误判率会显著上升。建议预留 20%-30% 余量,监控实际元素数量,必要时重建过滤器。 ## 关联笔记 - [[hhs/Redis/10-缓存架构模式]] - [[hhs/Redis/02-核心数据类型]] - [[hhs/Redis/README]]