HyperLogLog 这里面水很深,但是你必须趟一趟
一、简介
首先抛出一个业务问题:假设产品经理让你设计一个模块,来统计 PV(Page View 页面的访问量),那么你会怎么做?我想很多人对于 PV(Page View 页面的访问量)的统计会很快的想到使用 Redis 的 incr、incrby 指令,给每个网页配置一个独立 Redis 计数器就可以了,把这个技术区的 key 后缀加上当它的日期,这样一个请求过来,就可以通过执行 incr、incrby 指令统计所有 PV。
此时当你完成这个需求后,产品经理又让你设计一个模块,统计 UV(Unique Visitor,独立访客),那么你又会怎么做呢?UV 与 PV 不一样,UV 需要根据用户 ID 去重,如果用户没有 ID 我们可能需要考虑使用用户访问的 IP 或者其他前端穿过了的唯一标志来区分,此时你可能会想到使用如下的方案来统计 UV。
存储在 MySQL 数据库表中,使用 distinct count 计算不重复的个数
使用 Redis 的 set、hash、bitmaps 等数据结构来存储,比如使用 set,我们可以使用用户 ID,通过 sadd 加入 set 集合即可
但是上面的两张方案都存在两个比较大的问题:
随着数据量的增加,存储数据的空间占用越来越大,对于非常大的页面的 UV 统计,基本不合实际
统计的性能比较慢,虽然可以通过异步方式统计,但是性能并不理想
因此针对 UV 的统计,我们将会考虑使用 Redis 的新数据类型 HyperLogLog.HyperLogLog 是用来做基数统计的算法,它提供不精确的去重计数方案(这个不精确并不是非常不精确),标准误差是 0.81%,对于 UV 这种统计来说这样的误差范围是被允许的。HyperLogLog 的优点在于,输入元素的数量或者体积非常大时,基数计算的存储空间是固定的。在 Redis 中,每个 HyperLogLog 键只需要花费 12KB 内存,就可以计算接近 2^64 个不同的基数。但是:HyperLogLog 只能统计基数的大小(也就是数据集的大小,集合的个数),他不能存储元素的本身,不能向 set 集合那样存储元素本身,也就是说无法返回元素。
HyperLogLog 指令都是 pf(PF)开头,这是因为 HyperLogLog 的发明人是 Philippe Flajolet,pf 是他的名字的首字母缩写。
二、命令
2.1 PFADD key element [element …]
将任意数量的元素添加到指定的 HyperLogLog 里面,当 PFADD key element [element …]指令执行时,如果 HyperLogLog 的估计近似基数在命令执行之后出现了变化,那么命令返回 1,否则返回 0,如果 HyperLogLog 命令执行时给定的键不存在,那么程序将先创建一个空的 HyperLogLog 结构,再执行命令。该命令可以只给定 key 不给 element,这种以方式被调用时:
如果给定的键存在且已经是一个 HyperLogLog,那么这种调用不会产生任何效果
如果给定的键不存在,那么命令会闯进一个空的 HyperLogLog,并且给客户端返回 1
返回值:如果 HyperLogLog 数据结构内部存储的数据被修改了,那么返回 1,否则返回 0
时间复杂度:O(1)
使用示例:
2.2 PFCOUNT key [key …]
PFCOUNT 指令后面可以跟多个 key,当 PFCOUNT key [key …]命令作用于单个键时,返回存储在给定键的 HyperLogLog 的近似基数,如果键不存在,则返回 0;当 PFCOUNT key [key …]命令作用于多个键时,返回所给定 HyperLogLog 的并集的近似基数,这个近似基数是通过将索引给定 HyperLogLog 合并至一个临时 HyperLogLog 来计算得出的。
返回值:返回给定 HyperLogLog 包含的唯一元素的近似数量的整数值
时间复杂度:当命令作用于单个 HyperLogLog 时,时间复杂度为 O(1),并且具有非常低的平均常数时间。当命令作用于 N 个 HyperLogLog 时,时间复杂度为 O(N),常数时间会比单个 HyperLogLog 要大的多。
使用示例:
2.3 PFMERGE destkey sourcekey [sourcekey …]
将多个 HyperLogLog 合并到一个 HyperLogLog 中,合并后 HyperLogLog 的基数接近于所有输入 HyperLogLog 的可见集合的并集,合并后得到的 HyperLogLog 会被存储在 destkey 键里面,如果该键不存在,那么命令在执行之前,会先为该键创建一个空的 HyperLogLog。
返回值:字符串回复,返回 OK
时间复杂度:O(N),其中 N 为被合并的 HyperLogLog 的数量,不过这个命令的常数复杂度比较高
使用示例:
三、原理
3.1 伯努利试验
HyperLogLog 的算法设计能使用 12k 的内存来近似的统计 2^64 个数据,这个和伯努利试验有很大的关系,因此在探究 HyperLogLog 原理之前,需要先了解一下伯努利试验。
以下是百度百科关于伯努利试验的介绍:
伯努利试验(Bernoulli experiment)是在同样的条件下重复地、相互独立地进行的一种随机试验,其特点是该随机试验只有两种可能结果:发生或者不发生。我们假设该项试验独立重复地进行了 n 次,那么就称这一系列重复独立的随机试验为n重伯努利试验,或称为伯努利概型。单个伯努利试验是没有多大意义的,然而,当我们反复进行伯努利试验,去观察这些试验有多少是成功的,多少是失败的,事情就变得有意义了,这些累计记录包含了很多潜在的非常有用的信息。
伯努利试验是数据概率论中的一部分,它的典故源于“抛硬币”。**一个硬币只有正面和反面,每次抛硬币出现正反面的概率都是 50%,我们一直抛硬币直到出现第一次正面为止,记录抛硬币的次数,这个就被称为一次伯努利试验。**伯努利试验需要做非常多的次数,数据才会变得有意义。对于 n 次伯努利试验,出现正面的次数为 n,假设每次伯努利试验抛掷的次数为 k(也就是每次出现正面抛掷的次数),第一次伯努利试验抛掷次数为 k1,第 n 次伯努利试验抛掷次数为 kn,在这 n 次伯努利试验中,抛掷次数最大值为 kmax。上述的伯努利试验,结合极大似然估算方法(极大似然估计),得出 n 和 kmax 之间的估算关系:n=2^kmax。很显然这个估算关系是不准确的,例如如下案例:第一次试验:抛掷 1 次出现正面,此时 k=1,n=1;第二次实验:抛掷 3 次出现正面,此时 k=3,n=2;第三次实验:抛掷 6 次出现正面,此时 k=6,n=3;第 n 次试验:抛掷 10 次出现正面,此时 k=10,n=n,通过估算关系计算,n=2^10 上述案例可以看出,假设 n=3,此时通过估算关系 n=2^kmax,2^6 ≠3,而且偏差很大。因此得出结论,这种估算方法误差很大。
3.2 估值优化
关于上述估值偏差较大的问题,可以采用如下方式结合来缩小误差:
增加测试的轮数,取平均值。假设三次伯努利试验为 1 轮测试,我们取出这一轮试验中最大的的 kmax 作为本轮测试的数据,同时我们将测试的轮数定位 100 轮,这样我们在 100 轮实验中,将会得到 100 个 kmax,此时平均数就是(k_max_1 + ... + k_max_m)/m,这里 m 为试验的轮数,此处为 100.
增加修正因子,修正因子是一个不固定的值,会根据实际情况来进行值的调整。
上述这种增加试验轮数,去 kmax 的平均值的方法,是 LogLog 算法的实现。因此 LogLog 它的估算公式如下:
HyperLogLog 与 LogLog 的区别在于 HyperLogLog 使用的是调和平均数,并非平均数。调和平均数指的是倒数的平均数(调和平均数)。调和平均数相比平均数能降低最大值对平均值的影响,这个就好比我和马爸爸两个人一起算平均工资,如果用平均值这么一下来我也是年薪数十亿,这样肯定是不合理的。使用平均数和调和平均数计算方式如下:
假设我的工资 20000,马云 1000000000 使用平均数的计算方式:(20000 + 1000000000) / 2 = 500010000 调和平均数的计算方式:2/(1/20000 + 1/1000000000) ≈ 40000 很明显,平均工资月薪 40000 更加符合实际平均值,5 个亿不现实。
调和平均数的基本计算公式如下:
3.3 HyperLogLog 的实现
根据 3.1 和 3.2 大致可以知道 HyperLogLog 的实现原理了,它的主要精髓在于通过记录下低位连续零位的最大长度 K(也就是上面我们说的 kmax),来估算随机数的数量 n。
任何值在计算机中我们都可以将其转换为比特串,也就是 0 和 1 组成的 bit 数组,我们从这个 bit 串的低位开始计算,直到出现第一个 1 为止,这就好比上面的伯努利试验抛硬币,一直抛硬币直到出现第一个正面为止(只是这里是数字 0 和 1,伯努利试验中使用的硬币的正与反,并没有区别)。而 HyperLogLog 估算的随机数的数量,比如我们统计的 UV,就好比伯努利试验中试验的次数。
综上所述,HyperLogLog 的实现主要分为三步:第一步:转为比特串通过 hash 函数,将输入的数据装换为比特串,比特串中的 0 和 1 可以类比为硬币的正与反,这是实现估值统计的第一步第二步:分桶分桶就是上面 3.2 估值优化中的分多轮,这样做的的好处可以使估值更加准确。在计算机中,分桶通过一个单位是 bit,长度为 L 的大数组 S,将数组 S 平均分为 m 组,m 的值就是多少轮,每组所占有的比特个数是相同的,设为 P。得出如下关系:
L = S.length
L = m * p
数组 S 的内存 = L / 8 / 1024 (KB)
在 HyperLogLog 中,我们都知道它需要 12KB 的内存来做基数统计,原因就是 HyperLogLog 中 m=16384,p=6,L=16384 * 6,因此内存为=16384 * 6 / 8 / 1024 = 12 (KB),这里为何是 6 位来存储 kmax,因为 6 位可以存储的最大值为 64,现在计算机都是 64 位或 32 位操作系统,因此 6 位最节省内存,又能满足需求。
第三步:桶分配最后就是不同的数据该如何分配桶,我们通过计算 hash 的方式得到比特串,只要 hash 函数足够好,就很难产生 hash 碰撞,我们假设不同的数值计算得到不同的 hash 值,相同的数值得到相同的 hash 值(这也是 HyperLogLog 能用来统计 UV 的一个关键点),此时我们需要计算值应该放到那个桶中,可以计算的方式很多,比如取值的低 16 位作为桶索引值,或者采用值取模的方式等等。
3.4 代码实现-BernoulliExperiment(伯努利试验)
首先来写一个 3.1 中伯努利试验 n=2^kmax 的估算值验证,这个估算值相对偏差会比较大,在试验轮次增加时估算值的偏差会有一定幅度的减小,其代码示例如下:
我们可以通过修改 main 函数中,测试的轮次,再根据输出的结果来观察,n=2^kmax 这样的结果还是比较吻合的。
3.5 代码实现-HyperLogLog
接下来根据 HyperLogLog 中采用调和平均数+分桶的方式来做代码优化,模拟简单版本的 HyperLogLog 算法的实现,其代码如下:
测试结果如下,误差基本控制在 0.08 以下,还是很高的误差,所以说算法很粗糙
版权声明: 本文为 InfoQ 作者【李子捌】的原创文章。
原文链接:【http://xie.infoq.cn/article/41af102fd3b425cb52dffe684】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论