布隆过滤器

布隆过滤器可以理解为一个不怎么精确的 set 结构,当你使用它的 contains 方法判断某 个对象是否存在时,它可能会误判。但是布隆过滤器也不是特别不精确,只要参数设置的合 理,它的精确度可以控制的相对足够精确,只会有小小的误判概率。

当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存 在。打个比方,当它说不认识你时,肯定就不认识;当它说见过你时,可能根本就没见过 面,不过因为你的脸跟它认识的人中某脸比较相似 (某些熟脸的系数组合),所以误判以前见过你。

套在上面的使用场景中,布隆过滤器能准确过滤掉那些已经看过的内容,那些没有看过 的新内容,它也会过滤掉极小一部分 (误判),但是绝大多数新内容它都能准确识别。这样就 可以完全保证推荐给用户的内容都是无重复的。

Redis中的布隆过滤器

Redis 官方提供的布隆过滤器到了 Redis 4.0 提供了插件功能之后才正式登场。布隆过滤 器作为一个插件加载到 Redis Server 中,给 Redis 提供了强大的布隆去重功能。

安装redis bloom2.4.2

  1. 方式一

下载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。

  1. 方式二
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

redission springboot 布隆过滤器 redis布隆过滤器实现_redis

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。

redission springboot 布隆过滤器 redis布隆过滤器实现_布隆过滤器_02

再新增两行代码之后再次运行,输出结果为:

27 50000 0.0005

我们看到了误判率大约 0.05%,比预计的 0.1% 低一半,不过布隆的概率是有误差 的,只要不比预计误判率高太多,都是正常现象。

注意事项:

布隆过滤器的 initial_size 估计的过大,会浪费存储空间,估计的过小,就会影响准确 率,用户在使用之前一定要尽可能地精确估计好元素数量,还需要加上一定的冗余空间以避 免实际元素可能会意外高出估计值很多。

布隆过滤器的 error_rate 越小,需要的存储空间就越大,对于不需要过于精确的场合, error_rate 设置稍大一点也无伤大雅。比如在新闻去重上而言,误判率高一点只会让小部分文 章不能让合适的人看到,文章的整体阅读量不会因为这点误判率就带来巨大的改变。

布隆过滤器原理

redission springboot 布隆过滤器 redis布隆过滤器实现_布隆过滤器_03

每个布隆过滤器对应到 Redis 的数据结构里面就是一个大型的位数组和几个不一样的无 偏 hash 函数。所谓无偏就是能够把元素的 hash 值算得比较均匀。

向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位 置。再把位数组的这几个位置都置为 1 就完成了 add 操作。

向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出 来,看看位数组中这几个位置是否都位 1,只要有一个位为 0,那么说明布隆过滤器中这个 key 不存在。如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这 些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组比较稀疏,这个概率就会 很大,如果这个位数组比较拥挤,这个概率就会降低。

使用时不要让实际元素远大于初始化大小,当实际元素开始超出初始化大小时,应该对 布隆过滤器进行重建,重新分配一个 size 更大的过滤器,再将所有的历史元素批量 add 进 去 (这就要求我们在其它的存储器中记录所有的历史元素)。因为 error_rate 不会因为数量超 出就急剧增加,这就给我们重建过滤器提供了较为宽松的时间。