详解Redis布隆过滤器和缓存穿透解决方案
一、使用场景
1.布隆过滤器的特性是:去重,多数去重场景都跟这个特性有关。比如爬虫的时候去掉相同的URL,推送消息去掉相同的消息等。
2.解决缓存击穿的问题。
3.反垃圾邮件,从数十亿个垃圾邮件列表中判断某邮箱是否垃圾邮箱(同理,垃圾短信).
二、概念
其内部维护一个全为0的bit数组,需要说明的是,布隆过滤器有一个误判率的概念,误判率越低,则数组越长,所占空间越大。误判率越高则数组越小,所占的空间越小。
我们可以通过一个int型的整数的32比特位来存储32个10进制的数字,那么这样所带来的好处是内存占用少、效率很高(不需要比较和位移)比如我们要存储5(101)、3(11)四个数字,那么我们申请int型的内存空间,会有32个比特位。这四个数字的二进制分别对应从右往左开始数,比如第一个数字是5,对应的二进制数据是101, 那么从右往左数到第5位,把对应的二进制数据存储到32个比特位上。
第一个5就是 00000000000000000000000000101000
输入3时候 00000000000000000000000000001100
如何生成一个布隆过滤器?
原理如下假设集合里面有3个元素{x, y, z},哈希函数的个数为3。首先将位数组进行初始化,将里面每个位都设置位0。对于集合里面的每一个元素,将元素依次通过3个哈希函数进行映射,每次映射都会产生一个哈希值,这个值对应位数组上面的一个点,然后将位数组对应的位置标记为1。接下来按照该方法处理所有的输入对象,每个对象都可能把bitMap中一些白位置涂黑,也可能会遇到已经涂黑的位置,遇到已经为黑的让他继续为黑即可。处理完所有的输入对象之后,在bitMap中可能已经有相当多的位置已经被涂黑。至此,一个布隆过滤器生成完成,这个布隆过滤器代表之前所有输入对象组成的集合。(向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。)
如何去判断一个元素是否存在bit array中呢?
原理是一样,根据k个哈希函数去得到的结果,如果所有的结果都是1,表示这个元素可能(假设某个元素通过映射对应下标为4,5,6这3个点。虽然这3个点都为1,但是很明显这3个点是不同元素经过哈希得到的位置,因此这种情况说明元素虽然不在集合中,也可能对应的都是1)存在。如果一旦发现其中一个比特位的元素是0,表示这个元素一定不存在至于k个哈希函数的取值为多少,能够最大化的降低错误率(因为哈希函数越多,映射冲突会越少),这个地方就会涉及到最优的哈希函数个数的一个算法逻辑。(向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出来,看看位数组中这几个位置是否都为 1,只要有一个位为 0,那么说明布隆过滤器中这个 key 不存在。如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组比较稀疏,判断正确的概率就会很大,如果这个位数组比较拥挤,判断正确的概率就会降低。)
它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。
三、项目实战
1.命令模式,Redis 官方提供的布隆过滤器到了 Redis 4.0 提供了插件功能之后。
可以使用docker容器进行安装,docker容器安装可以参照之前的文章。
# 拉取镜像
docker pull redislabs/rebloom
# 运行容器
docker run -p 6379:6379 redislabs/rebloom
# 连接容器中的 redis 服务
docker exec -it 1a7ca288bcbe redis-cli
命令:
bf.add boolean:aaron:test user1
bf.add boolean:aaron:test user2
bf.add boolean:aaron:test user3
bf.exists boolean:aaron:test user1
#批量操作
bf.madd boolean:aaron:test user4 user5 user6
bf.mexists boolean:aaron:test user4 user5 user6 user7
2.py代码
import redis
#redis 连接
pool = redis.ConnectionPool(host='192.168.XXX.XXX', port=6379)
r = redis.Redis(connection_pool=pool)
#布隆过滤器
def boolean_test():
r.delete("boolean:aaron:test")
#指定错误率
r.execute_command("bf.reserve", "boolean:aaron:test", 0.001, 500000)
#正式操作的命令 add 和 exists
for i in range(10000):
r.execute_command("bf.add", "boolean:aaron:test", "user%d" % i)
ret = r.execute_command("bf.exists", "boolean:aaron:test", "user%d" % i)
if ret == 0:
print(i)
break
print(ret)
#主函数,执行行数
if __name__ == '__main__':
boolean_test()
3.Java代码,以解决解决缓存击穿的问题为例。
4.3.1 阐述原因和解决思路
什么是缓存穿透?
正常情况下,我们去查询数据都是存在。那么请求去查询一条压根儿数据库中根本就不存在的数据,也就是缓存和数据库都查询不到这条数据,但是请求每次都会打到数据库上面去。这种查询不存在数据的现象我们称为缓存穿透。
穿透带来的问题
试想一下,如果有黑客会对你的系统进行攻击,拿一个不存在的id 去查询数据,会产生大量的请求到数据库去查询。可能会导致你的数据库由于压力过大而宕掉。(之前项目就是这样做的,没有考虑到!!!)
4.3.2 解决思路:
缓存空值
之所以会发生穿透,就是因为缓存中没有存储这些空数据的key。从而导致每次查询都到数据库去了。那么我们就可以为这些key对应的值设置为null 丢到缓存里面去。后面再出现查询这个key 的请求的时候,直接返回null 。这样,就不用在到数据库中去走一圈了,但是别忘了设置过期时间。
BloomFilterBloomFilter
类似于一个hbase set 用来判断某个元素(key)是否存在于某个集合中。这种方式在大数据场景应用比较多,比如 Hbase 中使用它去判断数据是否在磁盘上。还有在爬虫场景判断url 是否已经被爬取过。这种方案可以加在第一种方案中,在缓存之前在加一层 BloomFilter ,在查询的时候先去 BloomFilter 去查询 key 是否存在,如果不存在就直接返回,存在再走查缓存 -> 查 DB。
4.3.3 代码
a.使用redisTemplate
通过查看网上资料和查看官网,还有直接导入源码并没有直接相关的API。有两种方式可以间接使用redisTemplate达到布隆过滤器。
第一种方式,集合Lua脚本。
package com.example.redis.zfr.demoredis.mq;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Collections;
@Controller
public class RedisController {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 布隆过滤器
* @param id
* @return
*/
@RequestMapping("/lua/{id}")
public String sendLua(@PathVariable String id) {
//添加key值
String script = "return redis.call('bf.add',KEYS[1],ARGV[1])";
DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>(script, Boolean.class);
Boolean user4 = redisTemplate.execute(redisScript, Collections.singletonList("boolean:aaron:test"), String.valueOf("user"+id));
System.out.println(user4);
//判断是否存在
String scriptEx = "return redis.call('bf.exists',KEYS[1],ARGV[1])";
DefaultRedisScript<Boolean> redisScript1 = new DefaultRedisScript<>(scriptEx, Boolean.class);
Boolean user6 = redisTemplate.execute(redisScript1, Collections.singletonList("boolean:aaron:test"), String.valueOf("user"+id));
System.out.println(user6);
return "";
}
}
第一次打印结果是:true true 。
第二次打印结果是:false true 。因为user11已经存在了。
第二种方式,网上大部分实现方式,通过使用Google的布隆过滤器,然后结合redisTemplate。
Google布隆过滤器工具类:
1. pom坐标
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>22.0</version>
</dependency>
2.代码
package com.example.redis.zfr.demoredis.booleanfilter;
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import java.util.ArrayList;
import java.util.List;
/**
* @author 繁荣Aaron
*/
public class GoogleTest {
private static int size = 1000000;
private static BloomFilter<Integer> bloomFilter =BloomFilter.create(Funnels.integerFunnel(), size);
public static void main(String[] args) {
for (int i = 0; i < size; i++) {
bloomFilter.put(i);
}
List<Integer> list = new ArrayList<Integer>(1000);
//故意取10000个不在过滤器里的值,看看有多少个会被认为在过滤器里
for (int i = size + 10000; i < size + 20000; i++) {
if (bloomFilter.mightContain(i)) {
list.add(i);
}
}
System.out.println("误判的数量:" + list.size());
}
}
b.Bloom filter library,需要使用到Jedis客户端,将不重点介绍(实际项目使用的a方案的第一种方案)
资料地址:(https://github.com/Baqend/Orestes-Bloomfilter)
四、思考和总结
1.误判率
随着存入的元素数量增加,误算率随之增加。但是如果元素数量太少,则使用散列表足矣。
a.Google布隆过滤器
对于Google布隆过滤器来说,在不做任何设置的情况下,默认的误判率为0.03,我想降低误判率怎么做?
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), size,0.01);
即,此时误判率为0.01。在这种情况下,其实我们可以调试跟踪一下查看底层维护的bit数组的长度,得出的结论是:误判率越低,则底层维护的数组越长,占用空间越大。因此,误判率实际取值,根据服务器所能够承受的负载来决定。
b.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。相关命令推荐地址:(https://github.com/RedisLabsModules/redisbloom/blob/master/docs/Bloom_Commands.md)
BF.RESERVE <key> <error_rate> <size>
这将创建一个名为<key>的过滤器,该过滤器最多可容纳<size>项,目标错误率为<error_rate>。一旦溢出原始<size>估计值,过滤器将自动增长。
#例子:
bf.reserve boolean:aaron:test 0.001 50000
注意:
布隆过滤器的initial_size估计的过大,会浪费存储空间,估计的过小,就会影响准确率,用户在使用之前一定要尽可能地精确估计好元素数量,还需要加上一定的冗余空间以避免实际元素可能会意外高出估计值很多。
布隆过滤器的error_rate越小,需要的存储空间就越大,对于不需要过于精确的场合,error_rate设置稍大一点也无伤大雅。
2.空间占用估计
布隆过滤器的空间占用有一个简单的计算公式,布隆过滤器有两个参数,第一个是预计元素的数量 n,第二个是错误率 f。公式根据这两个输入得到两个输出,第一个输出是位数组的长度 l,也就是需要的存储空间大小 (bit),第二个输出是 hash 函数的最佳数量 k。hash 函数的数量也会直接影响到错误率,最佳的数量会有最低的错误率。
k=0.7*(l/n) # 约等于
f=0.6185^(l/n) # ^ 表示次方计算,也就是 math.pow
从公式中可以看出:
1.位数组相对越长 (l/n),错误率 f 越低,这个和直观上理解是一致的。
2.位数组相对越长 (l/n),hash 函数需要的最佳数量也越多,影响计算效率。
3.当一个元素平均需要 1 个字节 (8bit) 的指纹空间时 (l/n=8),错误率大约为 2%
4.错误率为 10%,一个元素需要的平均指纹空间为 4.792 个 bit,大约为 5bit。
5.错误率为 1%,一个元素需要的平均指纹空间为 9.585 个 bit,大约为 10bit
6.错误率为 0.1%,一个元素需要的平均指纹空间为 14.377 个 bit,大约为 15bit。
如果一个元素需要占据 15 个 bit,那相对 set 集合的空间优势是不是就没有那么明显了?这里需要明确的是,set 中会存储每个元素的内容,而布隆过滤器仅仅存储元素的指纹。元素的内容大小就是字符串的长度,它一般会有多个字节,甚至是几十个上百个字节,每个元素本身还需要一个指针被 set 集合来引用,这个指针又会占去 4 个字节或 8 个字节,取决于系统是 32bit 还是 64bit。而指纹空间只有接近 2 个字节,所以布隆过滤器的空间优势还是非常明显的。
3.用不上 Redis4.0 怎么办?
Redis 4.0 之前也有第三方的布隆过滤器 lib 使用,只不过在实现上使用 redis 的位图来实现的,性能上也要差不少。比如一次 exists 查询会涉及到多次 getbit 操作,网络开销相比而言会高出不少。另外在实现上这些第三方 lib 也不尽完美,比如 pyrebloom 库就不支持重连和重试,在使用时需要对它做一层封装后才能在生产环境中使用。
py:https://github.com/robinhoodmarkets/pyreBloom
Java:https://github.com/Baqend/Orestes-Bloomfilter
4.可以删除么?
目前我们知道布隆过滤器可以支持 add 和 isExist 操作,那么 delete 操作可以么,答案是不可以,例如上图中的 bit 位 4 被两个值共同覆盖的话,一旦你删除其中一个值例如 “user1” 而将其置位 0,那么下次判断另一个值例如 “user2” 是否存在的话,会直接返回 false,而实际上你并没有删除它。
如何解决这个问题,答案是计数删除。但是计数删除需要存储一个数值,而不是原先的 bit 位,会增大占用的内存大小。这样的话,增加一个值就是将对应索引槽上存储的值加一,删除则是减一,判断是否存在则是看值是否大于0。
但是可以使用 del 删除key,这样会把所有的值给删除掉。
5.总结
1.HyperLogLog(包含在Redis中)来计算集合中的元素。
2 布隆过滤器(在ReBloom中可用),用于跟踪集合中存在或缺失的元素。
posted on 2020-11-20 15:17 ExplorerMan 阅读(619) 评论(0) 编辑 收藏 举报