「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!」
1:使用 BloomFilter 背景
BloomFilter在项目开发过程中经常使用,比如缓存穿透、爬虫过滤、猜你喜欢多路召回过滤等,判断一个或多个元素是否在一个集合内。它的空间效率和查询时间都远远超过一般的算法,当然它的缺点是有一定的误识别率和删除困难。基于性能和内存使用上考虑,最终选择BloomFilter。接下来重点梳理一下bloomfilter如何使用以及实现原理。
2:BloomFilter 单机使用
1:guava BloomFilter
其核心是hash 函数的选取以及 bit 数组的大小,主要使用:MURMUR128_MITZ_32 和 MURMUR128_MITZ_64,两者使用的都是MurmurHash3算法。详细的实现见guava的实现类:BloomFilterStrategies ,详细底层算法实现见:Bloom_filter算法分析
## 引入guava maven 库
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>30.1.1-jre</version>
</dependency>
复制代码
guava 使用 bloomFilter 比较简单。引入maven包,几行代码搞定。详细如下:
BloomFilter<CharSequence> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()),1000,0.0000001);
bloomFilter.put("abc");
boolean isContains = bloomFilter.mightContain("abc");
System.out.println(isContains );
复制代码
2:基于JVM的 BitSet BloomFilter
BitSet只面向数字比较,并且必须是正数。其他类型需要先转换成int类型,转换过程中难免会出现重复,BitSet的准确性就会降低。详细如下:
public class BloomFilter {
private BitSet bits;
private int size;
private AtomicInteger realSize = new AtomicInteger(0);
private int addedElements;
private int hashFunctionNumber;
/**
* 构造一个布隆过滤器,过滤器的容量为c * n 个bit.
*
* @param c 当前过滤器预先开辟的最大包含记录,通常要比预计存入的记录多一倍.
* @param n 当前过滤器预计所要包含的记录.
* @param k 哈希函数的个数,等同每条记录要占用的bit数.
*/
public BloomFilter(int c, int n, int k) {
if (k > 8) {
throw new IllegalArgumentException("Illegal k(maximum is 8): " + k);
}
this.hashFunctionNumber = k;
this.size = (int) Math.ceil(c * k);
this.addedElements = n;
this.bits = new BitSet(size);
}
/**
* 写入Bloom过滤器
*
* @param str 缓存字符串
*/
public void put(String str) {
realSize.incrementAndGet();
byte[] bytes = str.getBytes();
int[] positions = createHashes(bytes, hashFunctionNumber);
for (int i : positions) {
int position = Math.abs(i % size);
bits.set(position, true);
}
}
/**
* Bloom过滤器是否包含对应的字符串
*
* @param str 字符串对象
* @return true表示包含
*/
public boolean contains(String str) {
byte[] bytes = str.getBytes();
int[] positions = createHashes(bytes, hashFunctionNumber);
for (int i : positions) {
int position = Math.abs(i % size);
if (!bits.get(position)) {
return false;
}
}
return true;
}
/**
* 得到当前过滤器的错误率.
*
* @return 错误率
*/
public double getFalsePositiveProbability() {
return Math.pow((1 - Math.exp(-hashFunctionNumber * (double) addedElements / size)), hashFunctionNumber);
}
public int getSize() {
return size;
}
public int getRealSize() {
return realSize.get();
}
private int[] createHashes(byte[] bytes, int hashFunctionNumber) {
int[] result = new int[hashFunctionNumber];
for (int i = 0; i < hashFunctionNumber; i++) {
result[i] = HashFunctions.hash(bytes, i);
}
return result;
}
static class HashFunctions {
static int hash(byte[] bytes, int index) {
switch (index) {
case 0:
return RSHash(bytes);
case 1:
return JSHash(bytes);
case 2:
return ELFHash(bytes);
case 3:
return BKDRHash(bytes);
case 4:
return APHash(bytes);
case 5:
return DJBHash(bytes);
case 6:
return SDBMHash(bytes);
case 7:
return PJWHash(bytes);
}
throw new IllegalArgumentException("Invalid index: " + index);
}
static int RSHash(byte[] bytes) {
int hash = 0;
int magic = 63689;
for (byte b : bytes) {
hash = hash * magic + b;
magic = magic * 378551;
}
return hash;
}
static int JSHash(byte[] bytes) {
int hash = 1315423911;
for (byte b : bytes) {
hash ^= ((hash << 5) + b + (hash >> 2));
}
return hash;
}
static int ELFHash(byte[] bytes) {
int hash = 0;
int x;
for (byte b : bytes) {
hash = (hash << 4) + b;
if ((x = hash & 0xF0000000) != 0) {
hash ^= (x >> 24);
hash &= ~x;
}
}
return hash;
}
static int BKDRHash(byte[] bytes) {
int seed = 131;
int hash = 0;
for (byte b : bytes) {
hash = (hash * seed) + b;
}
return hash;
}
static int APHash(byte[] bytes) {
int hash = 0;
int len = bytes.length;
for (int i = 0; i < len; i++) {
if ((i & 1) == 0) {
hash ^= ((hash << 7) ^ bytes[i] ^ (hash >> 3));
} else {
hash ^= (~((hash << 11) ^ bytes[i] ^ (hash >> 5)));
}
}
return hash;
}
static int DJBHash(byte[] bytes) {
int hash = 5381;
for (byte b : bytes) {
hash = ((hash << 5) + hash) + b;
}
return hash;
}
static int SDBMHash(byte[] bytes) {
int hash = 0;
for (byte b : bytes) {
hash = b + (hash << 6) + (hash << 16) - hash;
}
return hash;
}
static int PJWHash(byte[] bytes) {
long bitsInUnsignedInt = (4 << 3);
long threeQuarters = ((bitsInUnsignedInt * 3) >> 2);
long oneEighth = (bitsInUnsignedInt >> 3);
long highBits = (long) (0xFFFFFFFF) << (bitsInUnsignedInt - oneEighth);
int hash = 0;
long test;
for (byte b : bytes) {
hash = (hash << oneEighth) + b;
if ((test = hash & highBits) != 0) {
hash = (int) ((hash ^ (test >> threeQuarters)) & (~highBits));
}
}
return hash;
}
}
}
复制代码
3:BloomFilter 集群使用
BloomFilter的单机使用的类库已足够的方便,实际开发过程中始终会面临集群的问题。因为rest或rpc 服务基本都是无状态的,不会将某一个请求固定在某台机器上。接下来我们需要引入redis的插件RedisBloom。
1:下载git 仓库,make编译。
wget https://github.com/RedisBloom/RedisBloom/archive/refs/tags/v2.2.5.tar.gz --no-check-certificate
## 下载完毕以后解压,然后make 编译
make
复制代码
当时在mac 环境下编译,执行一直失败。没有找到相关的解决方案,随后我在阿里云上找了一个按量付费的机器。安装完毕以后释放,也就几毛钱。 如下图 ,编译完成以后会生成redisbloom.so。
2:centos7.6 安装redis。
详细安装步骤如下:
## 先安排依赖
yum install -y cpp binutils glibc glibc-kernheaders glibc-common glibc-devel gcc make tcl
##centos7 默认的 gcc 版本小于 5.3 无法编译
sudo yum -y install centos-release-scl
sudo yum -y install devtoolset-9-gcc devtoolset-9-gcc-c++ devtoolset-9-binutils
##临时生效,退出 shell 或重启会恢复原 gcc 版本
sudo scl enable devtoolset-9 bash
##永久生效
sudo echo "source /opt/rh/devtoolset-9/enable" >>/etc/profile
## https 下载要上加上 --no-check-certificate
wget https://download.redis.io/releases/redis-6.2.4.tar.gz --no-check-certificate
## 解压安装
tar -zxvf redis-6.2.4.tar.gz
cd redis-6.2.4
make
make test
make install
创建根目录下的 redis 配置
sudo mkdir /etc/redis
## cp redis-6.2.4 下面的redis conf 到 /etc/redis/
sudo cp redis.conf /etc/redis/
在 /etc/systemd/system新建service文件
sudo vi /etc/systemd/system/redis.service
[Unit]
Description=Redis
After=network.target
[Service]
#Type=forking
ExecStart=/usr/local/bin/redis-server /etc/redis/redis.conf
ExecReload=/usr/local/bin/redis-server -s reload
ExecStop=/usr/local/bin/redis-server -s stop
PrivateTmp=true
[Install]
WantedBy=multi-user.target
加入开启启动
sudo systemctl daemon-reload
sudo systemctl enable redis
启动服务:
sudo systemctl restart redis
复制代码
3:redis加载redisbloom.so。
注意redisbloom.so 我直接配置在 redis.conf ,这样启动的时候就默认加载。redis的其他配置比如bind 127.0.0.1 , requirepass 123456 其他的根据自己的需求在redis.conf中设置。
4:redis bloom 测试,详细如下。
5:JRedisBloom 依赖包。
RedisBloom 提供了丰富的client ,详细见:RedisBloom 。 针对于Python、Java、Go、Php、.Net都有实现。 注意:如果你引入的是redis client 是redis.clients,只需引入jrebloom maven即可实现RedisBloom。 如果你使用的是spring-data-redis,请继续往下看。
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.6.1</version>
</dependency>
<dependency>
<groupId>com.redislabs</groupId>
<artifactId>jrebloom</artifactId>
<version>2.1.0</version>
</dependency>
复制代码
4:Spring Data Redis 如何引入BloomFilter
我们的推荐系统工程是基于的spingboot,redis引入的是spring-data-redis。当时RedisBloom没有spring-data-redis实现,已经准备再通过redis.clients 初始化。后面发现 https://github.com/redooper/spring-data-redis-bloom-filter 基于 RedisTemplate execute 实现。下载到本地打包完成,上传到本地私服即可。
注意:源码里面在创建bloomfilter的时候,没有给bloomfilter key过期时间。我们的场景在创建bloomfilter的时候,需默认给一个过期时间(finally加过期处理)。为了避免同一个时间点key大面积失效,在过期时间的基础上加一个随机时间。避免redis同一时间点,失效过多引起不必要的问题。
5:总结
BloomFilter之所以能做到在时间和空间上的效率比较高,是因为牺牲了判断的准确率、删除的便利性。所以我们在使用BloomFilter的场景中,要允许出现一定的准确性。我们在推荐系统多路召回需要把用户最近展示过的内容剔除,所以引入RedisBloom。我们也是边踩坑,边找解决方案。我始终相信,所有的问题都可以解决,无非在业务或技术方案去突破。文中如果有不准确的地方,所以大家一起指正交流。git上也有人提供布谷鸟的概念,可以针对布隆过滤器进行删除,有兴趣的同学可以了解一下。详细见redis-cuckoofilter