一、什么是布隆过滤器
布隆过滤器(bloom filter)是一种空间效率极高的概率型数据结构,用于判断一个元素是否在一个集合中。
核心特性:
- 判断为"不存在",则一定不存在(100% 准确)
- 判断为"存在",则可能存在(有一定误判率)
- 插入的元素越多,误判率越高
- 不存储元素本身,只占用固定大小的位数组
典型场景:
| 场景 | 说明 |
|---|---|
| 缓存穿透防护 | 查询不存在的数据时直接拦截,不穿透到 db |
| 爬虫 url 去重 | 数十亿 url 去重,内存只需几 gb |
| 黑名单/白名单 | 无需存储完整数据,快速判断是否命中 |
| 邮件/用户名判重 | 注册时判断用户名是否已被使用 |
| 推荐系统去重 | 已推荐过的不再重复推荐 |
二、布隆过滤器原理
2.1 数据结构
位数组(bit array): ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 0 │ 0 │ 1 │ 0 │ 1 │ 0 │ 0 │ 1 │ ...
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
bit 索引: 0 1 2 3 4 5 6 7 8 9
2.2 添加元素
添加 "hello" 时:
hash1("hello") = 2 ──► 索引 2 置 1
hash2("hello") = 5 ──► 索引 5 置 1
hash3("hello") = 9 ──► 索引 9 置 1
添加 "world" 时:
hash1("world") = 1 ──► 索引 1 置 1
hash2("world") = 4 ──► 索引 4 置 1
hash3("world") = 8 ──► 索引 8 置 1
结果位数组:
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 1 │ 0 │ 1 │ 1 │ 0 │ 0 │ 1 │ 1 │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
index: 0 1 2 3 4 5 6 7 8 9
2.3 查询元素
- 查询 “hello” → hash 得 [2,5,9] → 全是 1 → 可能存在
- 查询 “hello1” → hash 得 [2,3,7] → 位 3 是 0 → 一定不存在
- 查询 “hello2” → hash 得 [1,4,9] → 全是 1 → 可能存在(但从未添加过,这就是误判)
2.4 误判率计算
误判率 p ≈ (1 - e^(-k*n/m))^k m = 位数组长度(bit 数) n = 插入元素数量 k = 哈希函数个数 最优哈希函数个数:k ≈ (m/n) * ln(2) ≈ 0.7 * (m/n)
示例:m=10亿(128mb), n=100万,k≈7,误判率 p ≈ 千万分之一
三、方式一:redis stack / redisbloom 模块(推荐)
redis 7.x 已集成 redisbloom,无需额外安装。旧版本安装 redis stack 即可。
3.1 创建布隆过滤器
# bf.reserve <key> <error_rate> <capacity> [expansion expansion] [nonscaling] bf.reserve user_filter 0.01 1000000
| 参数 | 说明 |
|---|---|
| key | 过滤器名称 |
| error_rate | 期望误判率,越小越占内存(0~1) |
| capacity | 预计存储的元素数量 |
| expansion | 容量超限后自动扩容倍数(默认 2) |
| nonscaling | 禁止自动扩容 |
3.2 添加元素
bf.add user_filter "user_123" # 单个添加,返回 1 新增 / 0 可能重复 bf.madd user_filter "a" "b" "c" # 批量添加 → [1, 1, 1]
3.3 查询元素
bf.exists user_filter "user_123" # 单个查询,返回 1 可能存在 / 0 一定不存在 bf.mexists user_filter "a" "x" "c" # 批量查询 → [1, 0, 1]
3.4 查看信息
bf.info user_filter # 返回容量、已插入数量、子过滤器数量等 bf.card user_filter # 返回独立 item 数(去重估计值)
3.5 多语言代码示例
java(jedis)
import redis.clients.jedis.unifiedjedis;
public class bloomfilterdemo {
private static final string filter_key = "cache:bloom";
public static void main(string[] args) {
try (unifiedjedis jedis = new unifiedjedis("redis://localhost:6379")) {
jedis.bfreserve(filter_key, 0.01, 1_000_000);
jedis.bfadd(filter_key, "article_001");
jedis.bfadd(filter_key, "article_002");
string id = "article_999";
if (!jedis.bfexists(filter_key, id)) {
system.out.println(id + " 一定不存在,直接返回 null");
return;
}
// 可能存在,查缓存或 db
string cache = jedis.get("article:" + id);
if (cache != null) return;
// 查 db ...
}
}
}
go(go-redis)
import "github.com/redis/go-redis/v9"
rdb := redis.newclient(&redis.options{addr: "localhost:6379"})
rdb.bfreserve(ctx, "myfilter", 0.01, 1000000)
rdb.bfadd(ctx, "myfilter", "item1")
exists, _ := rdb.bfexists(ctx, "myfilter", "item1").result()
python(redis-py)
import redis
r = redis.redis(host='localhost', port=6379)
r.bf().reserve('myfilter', 0.01, 1000000)
r.bf().add('myfilter', 'item1')
r.bf().exists('myfilter', 'item1') # true/false
四、方式二:bitmap + lua 脚本(纯 redis,无插件)
当无法使用 redis stack 时,用 redis 原生的 bitmap(setbit/getbit)配合 lua 手动实现。
4.1 lua 添加元素脚本
-- bloom_add.lua,keys[1]=过滤器 key,argv=要添加的元素
local key = keys[1]
local bits = 1 << 31
for i = 1, #argv do
local val = argv[i]
local h1 = math.abs(redis.call('hash', val) % bits)
local h2 = math.abs(redis.call('crc16', val) % bits)
local h3 = math.abs((h1 + h2) % bits)
redis.call('setbit', key, h1, 1)
redis.call('setbit', key, h2, 1)
redis.call('setbit', key, h3, 1)
end
return 1
4.2 lua 查询元素脚本
-- bloom_exists.lua,keys[1]=过滤器 key,argv=要查询的元素,返回数组 [1,0,1,...]
local key = keys[1]
local bits = 1 << 31
local results = {}
for i = 1, #argv do
local val = argv[i]
local h1 = math.abs(redis.call('hash', val) % bits)
local h2 = math.abs(redis.call('crc16', val) % bits)
local h3 = math.abs((h1 + h2) % bits)
if redis.call('getbit', key, h1) == 0
or redis.call('getbit', key, h2) == 0
or redis.call('getbit', key, h3) == 0 then
results[i] = 0
else
results[i] = 1
end
end
return results
4.3 java 调用示例
public class redisbitmapbloomfilter {
private static final string add_script = loadscript("bloom_add.lua");
private static final string exists_script = loadscript("bloom_exists.lua");
private final jedispool pool;
private final string addsha;
private final string existssha;
public redisbitmapbloomfilter(jedispool pool) {
this.pool = pool;
try (jedis jedis = pool.getresource()) {
this.addsha = jedis.scriptload(add_script);
this.existssha = jedis.scriptload(exists_script);
}
}
public void add(string key, string... values) {
try (jedis jedis = pool.getresource()) {
jedis.evalsha(addsha, 1, key, values);
}
}
public boolean exists(string key, string value) {
try (jedis jedis = pool.getresource()) {
list<long> result = (list<long>) jedis.evalsha(
existssha, 1, key, value);
return result != null && result.get(0) == 1;
}
}
}
4.4 位数组大小与内存对照
| 位数组大小 (bit) | 内存占用 | 适用数据量 (1%误判) |
|---|---|---|
| 2^28 (2.68 亿) | 32 mb | ~100 万 |
| 2^30 (10.7 亿) | 128 mb | ~400 万 |
| 2^32 (42.9 亿) | 512 mb | ~1600 万 |
五、实战:缓存穿透防护
5.1 问题
恶意攻击者用大量不存在的 id 查询 → 每次缓存未命中 → 全部落到数据库 → db 崩溃
5.2 方案架构
请求 → 布隆过滤器 → 不存在 → 直接返回 null(拦截 ✓)
→ 可能存在 → 查缓存 → 命中 → 返回
→ 未命中 → 查 db → 回写缓存
5.3 spring boot 集成
@configuration
public class bloomfilterconfig {
@bean
public unifiedjedis unifiedjedis() {
return new unifiedjedis("redis://localhost:6379");
}
@postconstruct
public void initbloomfilter(unifiedjedis jedis, productmapper mapper) {
jedis.bfreserve("product:bloom", 0.01, 1_000_000);
// 预热:将已有数据 id 全部加入过滤器
mapper.getallids().foreach(id -> jedis.bfadd("product:bloom", id));
}
}
@service
public class productservice {
private final unifiedjedis jedis;
private final productmapper mapper;
public product getbyid(string id) {
// 1. 布隆过滤器拦截
if (!jedis.bfexists("product:bloom", id)) {
return null;
}
// 2. 缓存查询
string cachekey = "product:" + id;
string cached = jedis.get(cachekey);
if (cached != null) return json.parseobject(cached, product.class);
// 3. 数据库查询
product p = mapper.selectbyid(id);
if (p != null) jedis.setex(cachekey, 3600, json.tojsonstring(p));
return p;
}
public void add(product p) {
mapper.insert(p);
jedis.bfadd("product:bloom", string.valueof(p.getid()));
}
}
六、常见问题
q1:布隆过滤器可以删除元素吗?
标准布隆过滤器不支持删除。需删除时可使用 redisbloom 的计数布隆过滤器(cuckoo filter):
cf.reserve cfilter 0.01 1000000 cf.add cfilter "item1" # 添加 cf.del cfilter "item1" # 删除(支持!) cf.exists cfilter "item1" # 判断
q2:如何选择误判率?
| 场景 | 建议误判率 |
|---|---|
| 缓存穿透防护 | 1% |
| url / 爬虫去重 | 0.1% ~ 1% |
| 黑名单 | 0.01% |
q3:布隆过滤器满了怎么办?
| 策略 | 说明 |
|---|---|
| 自动扩容(默认) | 容量超限自动创建子过滤器,查询时遍历全部 |
| 分层过滤 | 按时间分片:bloom:2026-01、bloom:2026-02 |
| 手动重建 | 监控误判率,达阈值后重建更大的过滤器 |
七、方案对比总结
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| redisbloom 模块 | api 简洁、高性能、自动扩容 | 需 redis stack / 7.x | 生产环境首选 |
| bitmap + lua | 纯 redis,零依赖 | 实现复杂、不可扩容 | 无法装模块的过渡方案 |
| 本地 guava | 单机零网络延迟 | 多实例间不一致 | 单机应用 |
| redisson | 封装好的分布式实现 | 底层仍是 bitmap | 不想手写 lua 的场景 |
结论:
- 优先用 redisbloom;
- 装不了再用 bitmap + lua 兜底。
以上为个人经验,希望能给大家一个参考,也希望大家多多支持代码网。
发表评论