HyperLogLog
0. 前言
对于一个常见的业务问题:如果你负责开发维护一个大型的网站,有一天需要实现一个需求,统计每个页面每天的UV数据,那么将如何实现?
这里补充一下UV和PV的知识
PV(访问量):即Page View, 即页面浏览量或点击量,用户每次刷新即被计算一次。是总的用户访问量。
UV(独立访客):即Unique Visitor,访问您网站的一台电脑客户端为一个访客。00:00-24:00内相同的客户端只被计算一次,此时,需要对用户进行去重,然后统计出每天有多少个用户访问量
总结:UV是PV去重后的结果。
如果是统计PV,那就很简单,给每个页面分配一个Redis的计数器,把这个计数器的Key后缀加上当天的日期,这样每一次来一个请求,执行incrby
一次,最终就可以统计每个页面的访问量数据。
但是UV需要去重,同一个用户多次访问一个页面只能计数一次,这就要求每一个网页请求都需要带上用户的ID, 无论是登录用户还是未登录用户。都需要一个唯一ID来标识。
最容易想到的方案是:使用Reis的set,为每一个页面设置set来存储所有当天访问过此页面的用户ID,当有新的请求时,使用zadd将用户ID放入set中,使用scard来统计集合的大小。这个大小就是页面的UV数据。
但是,当页面访问量比较大的时候,使用set来统计非常浪费空间。其实,实际应用中,我们不需要太精确的统计UV数据,仅仅是一个不精确的大概数据就可以。
HyperLogLog就是为了解决这种统计问题而设计的。它提供不精确的去重计数方案,标准误差在0.81%左右。这样的精确度已经可以满足上面的UV统计需求。
1. 使用方法
HyperLogLog提供了三个指令pfadd
、pfcount
和pfmerge
, 一个是增加计数,一个是获取计数,最后一个是合并计数。
>pfadd leesure user1 # 注意,这里的key长度不能设置的太短
(integer) 1
> pfadd leesure user2
(integer) 1
> pfcount leesure
(integer) 2
当加入100个元素时, 误差在0.01%, 当插入10万条数据时,误差在0.277%。对于UV统计需求来说,误差率也不算高,如果将相同的数据重新加一遍,输出的结果一样,说明HyperLogLog确实具备去重功能。
- pfmerge指令,用于将多个pf计数累加在一起形成一个新的pf。比如,将两个页面的UV值进行合并。
需要注意的是,HyperLogLog这个数据结构需要占据12KB的存储空间,所以不适合统计单个用户相关的数据。但是Redis对HyperLogLog的存储进行了优化,在计数比较小时,存储空间采用稀疏矩阵存储,空间占用很小。计数比较大时,存储空间采用稠密矩阵,此时才会占用12KB的存储空间。
BloomFilter
0. 前言
HyperLogLog数据结构可以用来估数,可以解决很多精确度要求不高的统计问题,但是如果想知道一个数据是不是已经在HyperLogLog结构里面,那么就无法判断了。
比如在推荐系统中,系统每次推荐的内容都是我们没有看过的新内容,因此要去掉那些已经看过的内容。但是如果使用HyperLogLog就不能判断当前的内容是新的数据,而且HyperLogLog不适合针对单个用户使用。
BloomFilter就是专门用来过滤掉已经存在的记录,解决去重问题。同时能在空间上能节省90%,只是稍微有一点不精确,有一定的误判概率。
1. 布隆过滤器
可以把BloomFilter理解为不怎么精确的set结构,当判断某个数据是否存在时,布隆过滤器可能会误判。只要参数设置在合理范围内,它的精确度也可以控制在相对足够精确范围内,只有小小的误判概率。
当布隆过滤器说某个值存在时,这个值可能不存在;当说某个值不存在时,这个值一定不存在。
布隆过滤器能准确过滤掉那些用户已经看过的内容,那些用户没有看过的新内容,它也可能过滤掉一小部分(误判),但是绝大多数新内容都能准确识别。这样就可以保证推荐给用户的内容都是无重复的。
2. 使用方法
Redis4.0以后,Redis官方提供了BloomFilter的插件,它作为一个插件加载到Redis Server中,给Redis提供了去重功能。因此如果想使用此方法,就需要安装插件。
这里使用docker安装
> docker
BloomFilter有两个基本指令bf.add
(查看元素)和bf.exists
(查询元素是否存在)。注意bf.add
一次只能添加一个元素,如果想要添加多个,就需要用到bf.madd
指令。一次查询多个元素需要用到bf.mexists
指令。
>bf.add leesure user1
(integer) 1
> bf.add leesure user2
(integer) 1
> bf.madd leesure user3 user4
(integer) 1
(integer) 1
> bf.mexists leesure user1 user2 user5
(integer) 1
(integer) 1
(integer) 0
注意,Java客户端Jedis-2.x没有提供指令扩展机制,所以无法直接使用Jedis来访问Redis Module提供的bf.xxx
指令。因此RedisLabs提供了一个单独的包JReBloom
,但是它是基于Jedis3.0
,但是Jedis3.0
这个包目前(2018-09)还没有进入release阶段,没有进入maven仓库。需要去github上下载源码。如果不想这么麻烦,可以使用lettuce,它是Java的另一个Redis客户端,相比于Jedis
,它很早就支持了指令扩展。
🤗BloomFilter对于已经出见过的元素肯定不会误判。它的误判只会出现在没有见过的元素中。
上面使用的BloomFilter只是默认参数的布隆过滤器,它会在第一次add的时候自动创建。Redis还提供了自定义参数的布隆过滤器。需要使用bf.reserve
指令显示创建。如果对应的key已经存在,创建会报错。
bf.reserve
有三个参数:container_nam(容器名), error_rate(容错率,默认为0.01), 和initial_size(默认为100)
注意,error_rate越低,需要的空间越大。initial size表示预计放入的元素数量,当实际数量超出这个数值时,误判率会上升,所以需要提前设置一个交达的数量避免超出导致误判率升高。
🎯注意事项:
- 布隆过滤器的initial size设置的太大,会浪费存储空间。设置的过小,会影响准确率,用户在使用之前一ing要尽可能地估计元素数量,还需要加上一定的冗余空间以避免实际元素可能会高出预估大小。
- error_rate越小,需要的存储空间就越大,对于不需要过于精确的应用场景,error_rate设置的稍微大一些也没关系。比如在新闻客户端的去重应用上,误判率高一些只会让小部分文章不会被看到,文章的整体阅读量不会因为这一点误判率就带来巨大的变化。
3. 原理⭐️
布隆过滤器的数据结构就是一个大型的位数组和几个不一样的无偏hash
函数。如图的f,g,h就是无偏hash函数。所谓无偏就是能够把元素的hash值计算得比较均匀,让元素被hash映射到的位数组中的位置比较随机。
-
add
过程: 向布隆过滤器中添加元素时,会使用多个hash函数对key进行hash运算,得到的索引值对数组长度进行取模运算得到一个位置。每一个hash函数都会算得一个不同的位置,然后把数组的这几个位置都设置为1,这就完成了add操作。 -
exists
过程:向布隆过滤器查询一个key是否存在时,也会把hash的几个位置都算出来,看看位数组中的这几个位置是不是都为1,只要有一个位为0,就说明过滤器中不存在这个key。但是如果这几个位置都是1,也不能说明这个key就一定存在,只是极有可能存在。因为这些位置被置为1 可能是因为其他的key存在导致的。 -
如果这个位数组比较稀疏,判断正确的概率就会很大,如果这个数位比较稠密,判断正确的概率就会降低。
注意,使用时,不要让实际元素数量远大于初始数量,当实际元素数量开始超过初始数量时,应该对布隆过滤器进行重建,重新分配一个size更大的过滤器,再将所有的历史元素批量add进去。
空间占用估计
布隆过滤器的空间占用计算有一个简单的计算公式,但是推导起来比较麻烦。这里直接给出。n
表示预计元素数量。k
表示最佳的hash函数数量,f
表示错误率。
k
=
0.7
∗
1
n
f
=
0.618
5
1
n
k = 0.7*\frac{1}{n} \\f = 0.6185^{\frac{1}{n}}
k=0.7∗n1f=0.6185n1
从公式可以看出:
- 位数组越长,错误率
f
越低,需要的最佳hash函数的个数也越多,因此也一定程度影响计算效率。 - 当一个元素平均需要一个字节(Byte)的指纹空间
(1/n)=8
,错误率大约为2%。 - 当错误率为10%时,一个元素需要的平均指纹空间为4.792bit;
- 当错误率为1%时,一个元素需要的平均指纹为9.585bit;
- 当错误率为0.1%时,一个元素需要的平均空间指纹为14.377bit。
可能会想,当错误率为0.1%时,一个元素需要占据15bit,相对于set集合的空间优势就不是那么明显了,但是需要明确的是:set会存储每个元素的内容,布隆过滤器仅仅存储元素的指纹。
set存储的内容就是字符串的长度,它一般会有多个字节,甚至是几十个或者上百个字节,每一个元素本身还需要一个指针被set集合引用,这个指针又会占去4Byte或者8Byte。指纹空间所需不足2个字节,所以布隆过滤器的空间优势还是非常明显的。
实际元素超出时,误判率会发生怎么样的变化
当实际元素超出预计元素时,错误率会急剧上升还是会缓慢上升?这里就需要另一个公式说明,其中:t表示实际元素和预计元素的倍数。即实际元素是预计元素的t倍。k表示最佳hash数量。f表示错误率。
f
=
(
1
−
0.
5
t
)
k
f = (1-0.5^t)^k
f=(1−0.5t)k
当t增大时,错误率f也会增大。
- 当错误率为10%,t=2时,错误率会上升指接近40%
- 当错误率为1%, t=2时,错误率会上升指接近15%
- 当错误率为0.1%,t=2时,错误率会上升指接近5%
Redis4.0之前的版本使用布隆过滤器的备选方案
Redis4.0版本布隆过滤器的可替代方案有:
- Python版本库:pyreBloom
- Java版本库:orestes-bloomfilter
4. 其他应用
- 在爬虫系统中,我们需要对URL进行去重,对于已经爬过的网页就就不需要再爬了。如果URL太多,使用一个集合去装下这些URL地址,是非常浪费空间的,此时可以使用布隆过滤器,大幅度降低存储消耗,只不过也会使得爬虫系统错过少量的未访问的接面。
- 布隆过滤器在NoSQL数据库中使用得非常广泛,如HBase, Cassandra,LevelDB和RocksDB 内部都有布隆过滤器结构,布隆过滤器可以显著降低数据库得IO请求数量,当用户来查询某个row时,可以通过布隆过滤器过滤掉大量不存在得row请求,然后再去数据库中查询
- 邮件系统的垃圾邮件过滤功能也普遍用到了布隆过滤器,因为用到了这个过滤器,所以平时也会有某些正常的邮件被放入到了垃圾邮件目录中,这就是误判导致的,但是这种误判的概率很低。