鸣涧 发表于 2022-11-4 23:43:48

HyperLogLog 算法原理及其在 Redis 中的实现

HyperLogLog 算法原理及其在 Redis 中的实现
一、问题引入
大家在项目上可能会遇到过下面这些相同或者类似的需求:

[*]统计一个APP的日活量(DAU)和月活量(MAU)。日活(月活)是指在一个统计日(统计月)之内,登录或者使用产品的不同用户数量,它是产品运营情况的重要指标之一,反映了用户的活跃度。
[*]统计网站上各个网页的独立访客数,例如有些网页上面有阅读数。独立访客数需要对用户做去重,同一个用户多次访问只计算一次。

概括来说,这类需求就是去统计一个集合中不重复元素的个数。在数学上把一个集合不重复元素的个数称为集合的基数(cardinality,也有叫做势),这类问题可以称为基数统计问题。问题其实很简单,有很多常规的方法或数据结构去解决,例如:

[*]集合Set
将所有元素存储在一个Set(可以用哈希表或树实现)中,利用Set对元素进行去重。该方法可以精确的计算出不重复元素的数量,由于需要存储实际的数据,在数据量较少时可行,但是数据量达到百万、千万甚至上亿时,会占用大量的内存。假设有1亿个不重复的元素,每个数据大小是4字节,那么使用Set结构至少需要内存 100000000∗4B=400MB。

[*]位图 Bitmap
位图是一个大的bit数组,我们不需要去保存实际的元素,只需要用1bit来标识某个元素是否出现过,这样能够极大地节省内存。位图占用的内存和元素的值域有关,因为我们需要把值域映射到这个大的比特数组上。假设元素的值域是 ,那么采用位图需要的内存就是 100000000∗1bit / 8 ≈12MB 。该方法也能够精确的计算出不重复元素的数量,比起Set来说,内存占用确实减少很多,但是如果需要统计上千个模块或者业务的数据,那么内存消耗依然很大。

[*]布隆过滤器Bloom filter
布隆过滤器类似于位图,也是一个大的bit数组,存储的数据通过多个哈希函数计算,映射到比特数组的多个bit中,进而判断一个元素是否在集合中。布隆过滤器具有一定的误判率,占用的内存比位图略小,和误判率有关,误判率越低,需要的内存越大。

上述这些方法在数据量较小的时候,都是可以有效的解决基数统计问题,但是当数据量较大的时候,这些方法占用的内存可能就无法接受了。
那么在海量数据的场景下,有什么方法去解决基数统计的问题呢?

二、大数据场景下的基数统计算法
基数统计是大数据场景中经常需要处理的问题,也有很多统计的算法,例如:
[*]Linear Counting(LC)
[*]LogLog Counting(LLC)
[*]HyperLogLog Counting(HLLC)
[*]······

Linear算法和位图类似,但实际使用的不多,这里不多做介绍了,本文主要介绍HyperLogLog算法。"Hyper"是“超”的意思,和HTTP中的H是一个意思,从名字中也可以看出,HyperLogLog是LogLog算法的一个改进和优化,后面我们会一起讲解这两个算法,并学习HyperLogLog是如何在LogLog的基础上改进的。
需要明确的是,上述这些算法有个共同的特点,都不能精确计算集合的基数,而是概率计算,有一定的误差。因此在不追求绝对准确的情况下,可以考虑使用这些算法。下面我们就介绍一下LLC和HLLC算法的基本原理,以及如何一步一步的设计并优化的。
2.1 伯努利试验
提到概率就不得不提伯努利,伯努利在概率论中有着重要的地位,有个著名的实验叫做伯努利试验。LLC和HLLC算法底层的数学原理就是伯努利试验。所以我们需要去了解一下。
伯努利试验是指在同样的条件下重复地、相互独立地进行的一种随机试验,其特点是该随机试验只有两种可能结果:发生或者不发生。概念看起来比较复杂,其实很简单,就是抛硬币。我们假设抛硬币只会出现正面和反面两种情况,并且正反面的概率相等,都为 p=1/2,然后我们一直抛硬币,直到第一次出现正面为止,我们称为一次完整的伯努利试验。可能第一次抛就出现了正面,也可能连续抛了4次才出现正面,这都算作完整的伯努利试验。我们把一次完整的伯努利试验经历的抛硬币次数记为 k 。
我们用0表示抛到反面,用1表示抛到正面,假设我们做了 N 次伯努利试验,对于第≌次伯努利试验,所经历的抛硬币次数为 <span tabindex="0" data-mathml="ki" role="presentation" style="padding-top: 1px; padding-bottom: 1px; outline: 0px; max-width: none; overflow-wrap: normal; display: inline-block; line-height: 0; text-align: left; float: none; direction: ltr; max-height: none; min-width: 0px; min-height: 0px; border-width: 0px; border-style: initial; border-color: initial; box-sizing: border-box !important;">kiki。在这 N 次伯努利试验中,最大的次数为 kmax。kmax如下图所示:

那么如果我不告诉你 N是多少,只告诉你kmax,你能否根据 N去推算出<span tabindex="0" data-mathml="N" role="presentation" style="padding-top: 1px; padding-bottom: 1px; outline: 0px; max-width: none; overflow-wrap: normal; display: inline-block; line-height: 0; text-align: left; float: none; direction: ltr; max-height: none; min-width: 0px; min-height: 0px; border-width: 0px; border-style: initial; border-color: initial; box-sizing: border-box !important;">呢?也就是估算我们做了多少次伯努利试验呢?要想解决这个问题,我们可以先考虑以下两个问题:

[*]假设进行N 次伯努利试验,所有投掷次数都不大于<span tabindex="0" data-mathml="kmax" role="presentation" style="padding-top: 1px; padding-bottom: 1px; outline: 0px; max-width: none; overflow-wrap: normal; display: inline-block; line-height: 0; text-align: left; float: none; direction: ltr; max-height: none; min-width: 0px; min-height: 0px; border-width: 0px; border-style: initial; border-color: initial; box-sizing: border-box !important;">kmax 的概率是多少?
[*]假设进行N 次伯努利试验,至少有一次投掷次数大于或等于<span tabindex="0" data-mathml="kmax" role="presentation" style="padding-top: 1px; padding-bottom: 1px; outline: 0px; max-width: none; overflow-wrap: normal; display: inline-block; line-height: 0; text-align: left; float: none; direction: ltr; max-height: none; min-width: 0px; min-height: 0px; border-width: 0px; border-style: initial; border-color: initial; box-sizing: border-box !important;">kmax 的概率是多少?

对于第一个问题,一次完整的伯努利试验投掷次数大于kmax 的概率等于 ,即连续kmax 次反面的概率,因此一次投掷不大于 kmax的概率为,所以 次伯努利试验都不大于 kmax的概率是。
第二个问题,答案显然是 当时,,而当时,。
什么意思呢?就是说如果N 远大于,那么所有投掷次数都不大于kmax的概率是0,这和kmax 是所有 k的最大值矛盾;同样如果N 远小于,至少有一次投掷次数大于或等于kmax 的概率是0,也是矛盾的。因此可以考虑 为 N 的一个估计值。
我们举个例子:(1)第一次伯努利试验,抛了5次才出现正面,(2)第二次伯努利试验,抛了2次才出现正面, (3)第三次伯努利试验,抛了8次才出现正面, (4)第四次伯努利试验,抛了4次才出现正面,
kmax =8 ,因此估算次,也就是说,按照概率来讲, 我们做256次试验,才会出现一次“抛了8次才出现正面”的情况。然而实际 N =4 。可以看出,只是一个估算,当试验次数N 很小的时候,误差会很大。
2.2 用伯努利试验类比
前面用了很大的篇幅去介绍伯努利试验,它和我们的基数统计到底有什么关系呢?其实我们可以通过把伯努利试验类比到基数统计上。
我们一次抛硬币的伯努利试验,可以看作是一串二进制串000···1。假设有一个哈希函数能够将集合的元素哈希成固定长度的二进制串,并且该哈希函数分布均匀,冲突较小,那么理论上来说,二进制串的每个比特是0还是1的概率应该是相同的,我们从该二进制串的低位向高位去寻找第一个1出现的位置k,那么这个过程其实就是一个伯努利试验。既然这样,我们同样能够通过所有k的最大值 kmax去估算集合的元素个数。如下图所示:

在这个例子中我们集合中的每个元素经过一个哈希函数计算,得到32位的二进制串,然后每个二进制串从低位向高位去寻找第一个1出现的位置 k,最大的为 kmax =3 ,则估算集合元素的个数是 。也就是说,理论上来说,有8个元素,才会出现一次二进制串末尾是"100"的情况。同样可以看出,这种估算的结果误差会很大。
2.3 分桶思想减小误差
我们做过物理化学实验都知道,减小误差的方法之一是多次实验取平均。假设我们做多轮伯努利试验,每轮都是 N 次,然后对每轮试验的 kmax求平均进行估算,是不是就能够有效的减小误差呢?答案是肯定的。但是和抛硬币不同,抛硬币我们每一轮的结果都是随机的,所以可以取平均减小误差,但是集合中的数据通过哈希计算得到的值是固定的,无论你计算多少轮,结果也都不会改变。那么对于集合基数的估计,如何去减小误差呢?
我们只能够从数据本身入手。一种思路就是我们把数据平均分成多份,对每一份数据进行估算,然后多份数据来进行求平均减小误差。这其实就是分桶的思想,假设数据经过哈希函数计算,得到了一个32bit的二进制串,我们用低10位来表示分桶的位置(10位最多有1024个桶),然后高22位用于模拟伯努利过程,每个桶只需要保存当前出现过的最大的 kmax。整个过程如下图所示:

最后我们得到1024个桶的 kmax 值,我们可以进行取平均并估算集合的基数值。
2.4 LogLog Counting 算法
LogLog Counting算法在分桶后,对这 <span tabindex="0" data-mathml="m" role="presentation" style="padding-top: 1px; padding-bottom: 1px; outline: 0px; max-width: none; overflow-wrap: normal; display: inline-block; line-height: 0; text-align: left; float: none; direction: ltr; max-height: none; min-width: 0px; min-height: 0px; border-width: 0px; border-style: initial; border-color: initial; box-sizing: border-box !important;">mm 个桶的kmax 值做一个算数平均数:然后可以估算每个桶的基数值为 ,最后乘以桶数 m 和偏差修正因子 <span tabindex="0" data-mathml="c" role="presentation" style="padding-top: 1px; padding-bottom: 1px; outline: 0px; max-width: none; overflow-wrap: normal; display: inline-block; line-height: 0; text-align: left; float: none; direction: ltr; max-height: none; min-width: 0px; min-height: 0px; border-width: 0px; border-style: initial; border-color: initial; box-sizing: border-box !important;">cc ,就得到了整个集合的基数值:可以看出算法的计算并不复杂。那么LLC算法占用多少空间呢?回到上面分桶的例子,我们有1024个桶,高22位去模拟伯努利试验, kmax的最大值也只能是22,因此每个桶只需要5bit就能保存,总共占用空间只需要 1024 ∗ 5bit= 640B1024∗5bit=640B 。不难理解,这个空间复杂度是的,所以这也是该算法叫做LogLog Counting的原因。LLC算法的标准误差约为:。
2.5 HyperLogLog Counting 算法
前面说HLLC算法是对LLC算法的一个改进和优化,具体是在哪里呢?主要是在平均数的计算上。
LLC用的是算数平均数,求所有桶 kmax的平均值。算数平均数有个缺点,它容易受到极值的影响。举个例子,我们可能都经历过“平均工资”,假设公司里小王的月薪是10000,老板的月薪是100000,那么采用算数平均值计算平均工资为 1/2(10000+100000)=55000,这个结果在小王看来,可能会很诧异,自己的工资什么时候这么高了?公司里大部分的人的薪资可能都和小王差不多,只有少数的人工资非常高,那么通过算数平均数得到的平均工资可能会因为这几个极大值而变得没有意义。同样,我们知道伯努利试验中 <span tabindex="0" data-mathml="k" role="presentation" style="padding-top: 1px; padding-bottom: 1px; outline: 0px; max-width: none; overflow-wrap: normal; display: inline-block; line-height: 0; text-align: left; float: none; direction: ltr; max-height: none; min-width: 0px; min-height: 0px; border-width: 0px; border-style: initial; border-color: initial; box-sizing: border-box !important;">kk越大的概率越低,也就是说可能大部分桶的 kmax值都比较低,如果有几个桶的 kmax值非常大,那么算数平均数就会被拉高很多,起不到减小误差的效果了。
更好的方法是采用调和平均数(倒数的平均数的倒数)。还是上面平均工资的例子,如果采用调和平均值计算,则平均工资为2/(1/10000+1/10000)<span tabindex="0" data-mathml="2110000+1100000&#x2248;18181" role="presentation" style="padding-top: 1px; padding-bottom: 1px; outline: 0px; max-width: none; overflow-wrap: normal; display: inline-table; line-height: 0; text-align: left; float: none; direction: ltr; max-height: none; min-width: 0px; min-height: 0px; border-width: 0px; border-style: initial; border-color: initial; box-sizing: border-box !important;">≈181812110000+1100000≈18181,这个结果相比于算数平均数会好很多。调和平均数的好处是,它比较偏袒较小值,受极大值的影响较小。所以调和平均数应该更适应于这个场景。这就是HLLC算法的主要改进点。首先它对每个桶的基数估计值来计算一个调和平均数,然后同样乘以桶数 mm 和修正因子 cc ,最后得到整个集合的基数估计值:该算法的空间复杂度同样也是,误差为。可以看出HLLC算法的误差比LLC要小一些。

三、Redis 中的 HyperLogLog
Redis在2.8.9版本中添加了HyperLogLog数据结构,常用的命令包括:

PFADD命令将元素添加到HLL结构中,PFCOUNT命令会返回指定HLL的基数估算值,PFMERGE命令可以将多个HLL结构进行合并。我们会发现,HLL结构的命令都是以“PF”为前缀,这是因为该算法的发明者叫做 Philippe Flajolet。
在Redis中,添加到HLL中的value值会被Hash函数计算得到一个64bit的值,低14位用于分桶,所以桶数为   个,高50位用于伯努利试验,需要6bit来存储。所以在Redis中,HLL仅用空间 16384∗6bit=12KB ,就可以统计多达个数,标准误差约为 。
算法的实现本身并不复杂,但是Redis还是对其内存占用作了一些优化。我们可以看出,无论基数多大,桶数都是16384个,所占用的内存都是12KB,当基数比较小时,还是会造成一些空间浪费。所以Redis采用稀疏存储结构和密集存储结构两种方式。
3.1 密集存储结构
密集存储结构类似于位图,是一个大小固定为12KB的数组,每6bit表示一个桶,存储该桶的 kmax值,共计16384个桶。如下图所示:

对每个桶的读写操作时,都需要一定的位运算,定位到桶的那6个比特并进行读写。
3.2 稀疏存储结构
稀疏存储结构并不真的使用12KB的数组来表示16384个桶,而是使用特殊的字节结构来表达,如下图所示:


[*]ZERO:占用1个字节,表示连续多少个桶的计数都是0。前2位固定是00,后6位表示有多少个桶,最大为64。
[*]XZERO:占用2个字节,表示连续多少个桶的计数都是0。前2位固定是01,后14位表示有多少个桶,最大为16384。
[*]VAL:占用1个字节,表示连续多少个桶的计数为多少。前1位固定是1,接下来5位表示计数的值是多少,所以最大是32。最后2位表示连续多少个桶。


根据上面的定义,我们可以知道,一个初始状态的HLL结构,只需要2个字节,也就是一个XZERO来存储,而不需要12KB的空间;而在HLL插入了少量数据之后,可以用很少个ZERO,XZERO和VAL来进行表示,如下图所示:

Redis稀疏存储结构切换到密集存储结构的条件是:
[*]当某个桶的计数值使用VAL无法表示时,也就是出现某个桶的计数值大于32,则会切换到密集存储结构。
[*]当稀疏存储结构占用的空间大于3000字节时(可配置)。


四、总结
在海量数据的场景下,HyperLogLog Counting算法以损失一定准确性为代价,能够使用极少的内存统计集合的基数,常常被用于统计产品的日活、月活,独立访客数等等场景。在Redis中也支持HyperLogLog数据结构,并且对内存的使用做了一些优化。
另外推荐一个工具,可视化的演示了HLLC算法的详细过程,对学习HLLC算法很有帮助。


页: [1]
查看完整版本: HyperLogLog 算法原理及其在 Redis 中的实现