241 lines
8.3 KiB
Markdown
241 lines
8.3 KiB
Markdown
|
|
---
|
|||
|
|
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]]
|