布隆过滤器
布隆过滤器可以理解为一个不怎么精确的 set 结构,当你使用它的 contains 方法判断某 个对象是否存在时,它可能会误判。但是布隆过滤器也不是特别不精确,只要参数设置的合 理,它的精确度可以控制的相对足够精确,只会有小小的误判概率。
当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存 在。打个比方,当它说不认识你时,肯定就不认识;当它说见过你时,可能根本就没见过 面,不过因为你的脸跟它认识的人中某脸比较相似 (某些熟脸的系数组合),所以误判以前见过你。
套在上面的使用场景中,布隆过滤器能准确过滤掉那些已经看过的内容,那些没有看过 的新内容,它也会过滤掉极小一部分 (误判),但是绝大多数新内容它都能准确识别。这样就 可以完全保证推荐给用户的内容都是无重复的。
Redis中的布隆过滤器
Redis 官方提供的布隆过滤器到了 Redis 4.0 提供了插件功能之后才正式登场。布隆过滤 器作为一个插件加载到 Redis Server 中,给 Redis 提供了强大的布隆去重功能。
安装redis bloom2.4.2
- 方式一
下载https://github.com/RedisBloom/RedisBloom/archive/refs/tags/v2.4.2.zip
进入解压后文件夹,执行make命令生成redisbloom.so文件
$ make
Makefile:20: deps/readies/mk/main: No such file or directory
Makefile:197: /defs: No such file or directory
Makefile:224: /rules: No such file or directory
make: *** No rule to make target '/rules'. Stop.
还需要下载/deps/readies 和 /deps/t-digest-c两个项目
到github上找到deps/readies下载下来依赖的文件https://github.com/RedisLabsModules/readies/archive/refs/heads/master.zip解压到缺失目录中,/t-digest-c也是如此:
git clone https://github.com/RedisBloom/t-digest-c
再次执行make
# 再次执行命令,查看对应位置代码,发现我们make版本太低,至少为4
$ make
deps/readies/mk/main:6: *** GNU Make version is too old. Aborting.. Stop.
升级后再次make,即可获取redisbloom.so。
- 方式二
git clone --recursive https://github.com/RedisBloom/RedisBloom.git
cd RedisBloom
make setup
make
配置:
修改配置文件,添加
loadmodule /redisbloom/redisbloom.so
或者
redis-server --loadmodule /redisbloom/redisbloom.so &
基本使用
参数设置:
# 语法
BF.RESERVE {key} {error_rate} {capacity} [EXPANSION {expansion}] [NONSCALING]
- key:filter 名字
- error_rate:期望错误率,期望错误率越低,需要的空间就越大。
- capacity:初始容量,当实际元素的数量超过这个初始化容量时,误判率上升。
可选参数
- EXPANSION:当添加到布隆过滤器中的数据达到初始容量后,布隆过滤器会自动创建一个子过滤器,子过滤器的大小是上一个过滤器大小乘以expansion;expansion的默认值是2,也就是说布隆过滤器扩容默认是2倍扩容
- NONSCALING:设置此项后,当添加到布隆过滤器中的数据达到初始容量后,不会扩容过滤器,并且会抛出异常((error) ERR non scaling filter is full)
说明:BloomFilter的扩容是通过增加BloomFilter的层数来完成的。每增加一层,在查询的时候就可能会遍历多层BloomFilter来完成,每一层的容量都是上一层的两倍(默认)。默认的error_rate是 0.01,capacity是 100
BF.ADD
BF.ADD {key} {item}
eg:BF.ADD key0 v0
(integer) 1
功能:向key指定的Bloom中添加一个元素
- key:filter 名字
- item:单个元素
- 返回值:1:新添加, 0:已经被添加过,如果设置了capacity且配置为不可以扩容,会返回(error) ERR non scaling filter is full
BF.MADD
BF.MADD {key} {item ...}
eg:BF.ADD key0 v1 v2
1) (integer) 1
2) (integer) 1
功能:向key指定的Bloom中添加多个元素
- key:filter 名字
- item:单个或者多个元素
- 返回值(数组):1:新添加, 0:已经被添加过,如果设置了capacity且配置为不可以扩容,会返回(error) ERR non scaling filter is full
BF.INSERT
BF.INSERT {key} [CAPACITY {cap}] [ERROR {error}] [EXPANSION {expansion}] [NOCREATE] [NONSCALING] ITEMS {item ...}
eg: bf.insert bfinKey0 CAPACITY 5 ERROR 0.1 EXPANSION 2 NONSCALING ITEMS item1 item2
1) (integer) 1
2) (integer) 1
功能:向key指定的Bloom中添加多个元素,添加时可以指定大小和错误率,且可以控制在Bloom不存在的时候是否自动创建
- key:filter 名字
- CAPACITY:[如果过滤器已创建,则此参数将被忽略]。
- ERROR:[如果过滤器已创建,则此参数将被忽略]。
- expansion:布隆过滤器会自动创建一个子过滤器,子过滤器的大小是上一个过滤器大小乘以expansion。expansion的默认值是2,也就是说布隆过滤器扩容默认是2倍扩容。
- NOCREATE:如果设置了该参数,当布隆过滤器不存在时则不会被创建。用于严格区分过滤器的创建和元素插入场景。该参数不能与CAPACITY和ERROR同时设置。
- NONSCALING:设置此项后,当添加到布隆过滤器中的数据达到初始容量后,不会扩容过滤器,并且会抛出异常((error) ERR non scaling filter is full)。
- ITEMS:待插入过滤器的元素列表,该参数必传
BF.EXISTS
BF.EXISTS {key} {item}
eg:BF.EXISTS key0 v1
(integer) 1
功能:检查一个元素是否存在于BloomFilter
- key:filter 名字
- item:一个值
- 返回值:1:存在, 0:不存在
BF.MEXISTS
BF.MEXISTS {key} {item}
eg:BF.MEXISTS key0 v1 v2
1) (integer) 1
2) (integer) 1
功能:批量检查多个元素是否存在于BloomFilter
- key:filter 名字
- item:一个或者多个值
- 返回值(数组):1:存在, 0:不存在
BF>SCANDUMP
BF.SCANDUMP {key} {iter}
eg:BF.SCANDUMP key0 0
1) (integer) 1
2) "\x04\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x05\x00\x00\x00\x02\x00\x00\x00\x90\x00\x00\x00\x00\x00\x00\x00\x80\x04\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00{\x14\xaeG\xe1zt?\xe9\x86/\xb25\x0e&@\b\x00\x00\x00d\x00\x00\x00\x00\x00\x00\x00\x00"
功能:对Bloom进行增量持久化操作(增量保存)
- key:filter 名字
- iter:首次调用传值0,或者上次调用此命令返回的结果值;
- 返回值:返回连续的(iter, data)对,直到(0,NULL),表示DUMP完成
BF>LOADCHUNK
BF.LOADCHUNK {key} {iter} {data}
功能:加载SCANDUMP持久化的Bloom数据
- key:目标布隆过滤器的名字;
- iter:SCANDUMP返回的迭代器的值,和data一一对应;
- data:SCANDUMP返回的数据块(data chunk);
BF.INFO
BF.INFO {key}
eg:bf.info key1
1) Capacity
2) (integer) 7
3) Size
4) (integer) 416
5) Number of filters
6) (integer) 3
7) Number of items inserted
8) (integer) 5
9) Expansion rate
10) (integer) 2
功能:查询key指定的Bloom的信息
返回值:
- Capacity:预设容量;
- Size:实际占用情况,但如何计算待进一步确认;
- Number of filters:过滤器层数;
- Number of items inserted:已经实际插入的元素数量;
- Expansion rate:子过滤器扩容系数(默认2);
BF.DEBUG
BF.DEBUG {key}
eg:bf.debug key1
1) "size:5"
2) "bytes:8 bits:64 hashes:5 hashwidth:64 capacity:1 size:1 ratio:0.05"
3) "bytes:8 bits:64 hashes:6 hashwidth:64 capacity:2 size:2 ratio:0.025"
4) "bytes:8 bits:64 hashes:7 hashwidth:64 capacity:4 size:2 ratio:0.0125"
功能:查看BloomFilter的内部详细信息(如每层的元素个数、错误率等)
返回值:
- size:BloomFilter中已插入的元素数量;
- 每层BloomFilter的详细信息
- bytes:占用字节数量;
- bits:占用bit位数量,bits = bytes * 8;
- hashes:该层hash函数数量;
- hashwidth:hash函数宽度;
- capacity:该层容量(第一层为BloomFilter初始化时设置的容量,第2层容量 = 第一层容量 * expansion,以此类推);
- size:该层中已插入的元素数量(各层size之和等于 BloomFilter中已插入的元素数量size);
- ratio:该层错误率(第一层的错误率 = BloomFilter初始化时设置的错误率 * 0.5,第二层为第一层的0.5倍,以此类推,ratio与expansion无关);
Java测试
Java 客户端 Jedis-2.x 没有提供指令扩展机制,所以你无法直接使用 Jedis 来访问 Redis Module 提供的 bf.xxx 指令。RedisLabs 提供了一个单独的包 JReBloom,还可以使用 lettuce,它是另一个 Redis 的客户端,相比 Jedis 而言,它很早就支持了指令扩展。
引入依赖:
<dependency>
<groupId>com.redislabs</groupId>
<artifactId>jrebloom</artifactId>
<version>1.2.0</version>
</dependency>
进行测试:
package com.example.demo.bloom;
import io.rebloom.client.Client;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import redis.clients.jedis.JedisPool;
/**
* @Author: acton_zhang
* @Date: 2023/4/16 10:48 下午
* @Version 1.0
*/
public class BloomTest {
public static void main(String[] args) {
GenericObjectPoolConfig config = new GenericObjectPoolConfig();
config.setMaxIdle(300);
config.setMaxTotal(1000);
config.setMaxWaitMillis(30000);
config.setTestOnBorrow(true);
JedisPool pool = new JedisPool(config, "127.0.0.1", 6379, 30000, "123456");
Client client = new Client(pool);
for (int i = 0; i < 100000; i++) {
//存入数据
client.add("codehole", "user"+ i );
//判断是否存在
boolean exists = client.exists("codehole", "user" + i);
if (!exists) {
System.out.println(i);
break;
}
}
client.close();
}
}
执行上面的代码后,没有输出,塞进去了 100000 个元素,还是没有误判。
原因就在于布隆过滤器对于已经见过的元素肯定不会误判,它只会误判那些没见过的元 素。所以稍微改一下上面的脚本,使用 bf.exists 去查找没见过的元素,看看它是不是 以为自己见过了。
package com.example.demo.bloom;
import io.rebloom.client.Client;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import redis.clients.jedis.JedisPool;
/**
* @Author: acton_zhang
* @Date: 2023/4/16 10:48 下午
* @Version 1.0
*/
public class BloomTest {
public static void main(String[] args) {
GenericObjectPoolConfig config = new GenericObjectPoolConfig();
config.setMaxIdle(300);
config.setMaxTotal(1000);
config.setMaxWaitMillis(30000);
config.setTestOnBorrow(true);
JedisPool pool = new JedisPool(config, "127.0.0.1", 6379, 30000, "123456");
Client client = new Client(pool);
for (int i = 0; i < 100000; i++) {
//存入数据
client.add("codehole", "user"+ i );
//判断是否存在
boolean exists = client.exists("codehole", "user" + (i + 2));
if (exists) {
System.out.println(i);
break;
}
}
client.close();
}
}
运行后输出305,也就是到第 305 的时候,它出现了误判。
测量误判率
package com.example.demo.bloom;
import io.rebloom.client.Client;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import redis.clients.jedis.JedisPool;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
/**
* @Author: acton_zhang
* @Date: 2023/4/16 11:04 下午
* @Version 1.0
*/
public class BloomTest2 {
private String chars;
{
StringBuilder builder = new StringBuilder();
for (int i = 0; i < 26; i++) {
builder.append((char)('a' + i));
}
chars = builder.toString();
}
private String randomString(int n) {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < n; i++) {
int idx = ThreadLocalRandom.current().nextInt(chars.length());
builder.append(chars.charAt(idx));
}
return builder.toString();
}
private List<String> randomUsers(int n) {
List<String> users = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
users.add(randomString(64));
}
return users;
}
public static void main(String[] args) {
BloomTest2 bloom = new BloomTest2();
List<String> users = bloom.randomUsers(100000);
List<String> usersTrain = users.subList(0, users.size() / 2);
List<String> usersTest = users.subList(users.size() / 2, users.size());
GenericObjectPoolConfig config = new GenericObjectPoolConfig();
config.setMaxIdle(300);
config.setMaxTotal(1000);
config.setMaxWaitMillis(30000);
config.setTestOnBorrow(true);
JedisPool pool = new JedisPool(config, "127.0.0.1", 6379, 30000, "123456");
Client client = new Client(pool);
for (String user : usersTrain) {
client.add("codehole", user);
}
int falses = 0;
for (String user : usersTest) {
boolean ret = client.exists("codehole", user);
if (ret) {
falses++;
}
}
System.out.printf("%d %d %.4f\n", falses, usersTest.size(), falses * 1.0 / usersTest.size());
client.close();
}
}
输出:
504 50000 0.0101
可以看到误判率大约 1% 多点。你也许会问这个误判率还是有点高啊,有没有办法降低 一点?答案是有的。
我们上面使用的布隆过滤器只是默认参数的布隆过滤器,它在我们第一次 add 的时候自 动创建。Redis 其实还提供了自定义参数的布隆过滤器,需要我们在 add 之前使用 bf.reserve 指令显式创建。如果对应的 key 已经存在,bf.reserve 会报错。bf.reserve 有三个参数,分别是 key, error_rate 和 initial_size。错误率越低,需要的空间越大。initial_size 参数表示预计放 入的元素数量,当实际数量超出这个数值时,误判率会上升。
所以需要提前设置一个较大的数值避免超出导致误判率升高。如果不使用 bf.reserve,默 认的 error_rate 是 0.01,默认的 initial_size 是 100。
再新增两行代码之后再次运行,输出结果为:
27 50000 0.0005
我们看到了误判率大约 0.05%,比预计的 0.1% 低一半,不过布隆的概率是有误差 的,只要不比预计误判率高太多,都是正常现象。
注意事项:
布隆过滤器的 initial_size 估计的过大,会浪费存储空间,估计的过小,就会影响准确 率,用户在使用之前一定要尽可能地精确估计好元素数量,还需要加上一定的冗余空间以避 免实际元素可能会意外高出估计值很多。
布隆过滤器的 error_rate 越小,需要的存储空间就越大,对于不需要过于精确的场合, error_rate 设置稍大一点也无伤大雅。比如在新闻去重上而言,误判率高一点只会让小部分文 章不能让合适的人看到,文章的整体阅读量不会因为这点误判率就带来巨大的改变。
布隆过滤器原理
每个布隆过滤器对应到 Redis 的数据结构里面就是一个大型的位数组和几个不一样的无 偏 hash 函数。所谓无偏就是能够把元素的 hash 值算得比较均匀。
向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位 置。再把位数组的这几个位置都置为 1 就完成了 add 操作。
向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出 来,看看位数组中这几个位置是否都位 1,只要有一个位为 0,那么说明布隆过滤器中这个 key 不存在。如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这 些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组比较稀疏,这个概率就会 很大,如果这个位数组比较拥挤,这个概率就会降低。
使用时不要让实际元素远大于初始化大小,当实际元素开始超出初始化大小时,应该对 布隆过滤器进行重建,重新分配一个 size 更大的过滤器,再将所有的历史元素批量 add 进 去 (这就要求我们在其它的存储器中记录所有的历史元素)。因为 error_rate 不会因为数量超 出就急剧增加,这就给我们重建过滤器提供了较为宽松的时间。