Redis系列-HyberLogLog 算法

更新记录

更新时间 更新内容
20241104 自实现hyberloglog基于cgo直接使用redis原生部分代码,重新实验

HyberLogLog 算法介绍

相信很多同学在面试的时候都遇到过一些大数据题目, 我的很大,你忍一下。假设现在面试官就现场出了一道题,有一个日活量很高的网站,现在需要你大致统计一下这个网站的日活量,但是呢,这个电脑可用的内存,只有几十KB,如何较为精确地统计网站的日活量?如何解决这个问题,就需要用到HyberLogLog算法。
HyberLogLog算法是一种概率性统计算法,其可以仅用12kb的内存大小统计 2 64 2^{64} 264个数的基数(不同个数),而误差不会超过1%。

数学原理

这样一种强大的算法是建立在概率学的伯努利试验上,或者说是抛硬币。在抛硬币试验中,抛出正面或者反面的概率都是 1 2 \frac{1}{2} 21。在一次伯努利试验中,一直抛硬币直到抛出正面为止,记录在这一次试验中抛硬币的次数为k。在n次这样的伯努利试验中,计k_max为最大的次数,那么根据概率学的极大似然估算方法,可以得到 n = 2 k _ m a x ( 式 1 ) n=2^{k\_max}(式1) n=2k_max(1)。其实这个也很好理解,因为在伯努利试验中,抛k次才出现正面的概率本身是 1 2 k \frac{1}{2^{k}} 2k1,那么对于k_max而言,概率就是 1 2 k _ m a x \frac{1}{2^{k\_max}} 2k_max1,那要出现这样抛k_max才出现正面的伯努利试验,是不是大概需要 2 k _ m a x 2^{k\_max} 2k_max次伯努利试验,所以式一这样的估算公式从感觉上是成立的。
既然是估算,那一定有误差,并且这个误差随着n的增大是会减少的。对于k_max,也可以进行多轮试验m,然后每一轮试验都有一个k_max,然后最后总的估算结果用k_max的平均值代入式二,这也就是LogLog算法。
n = c o n s t a n t ∗ m ∗ 2 ∑ i = 1 m k _ m a x i m (式二) n = constant * m * 2^{\frac{\sum_{i=1}^{m} k\_max_i}{m}}(式二) n=constantm2mi=1mk_maxi(式二)
而HyberLogLog算法在这基础上增加了一层优化,求 k _ m a x ‾ \overline{k\_max} k_max不用普通的平均数,而是用调和平均数,计算方式如式三
k _ m a x ‾ = m ∑ i = 1 m 1 k _ m a x i (式三) \overline{k\_max}=\frac{m}{\sum_{i=1}^m \frac{1}{k\_max_i}}(式三) k_max=i=1mk_maxi1m(式三)
使用调和平均数的好处是可以减少较大数值对于平均值的影响,可以进一步减少统计的误差。
说了那么多的公式,可是为什么HyberLogLog算法能够进行基数统计呢?下面,以redis代码来讲讲如何在基数统计中使用HyberLogLog算法。redis版本为6.0.19。

redis代码解读

如何对应

在基数统计中,任务就是要统计不同的数字的数量。那么用HyberLogLog算法做基数统计的话,就是对于每一个数字,先用哈希函数将数字哈希成H位的比特串,然后将低m位用做分组号,高H-m位看做是做伯努利试验,即在高H-m位中,从低到高,为0表示抛出了反面,为1表示抛出了正面。那么伯努利试验中的n就表示有多少个不同的数,因为多少个不同的数就可以理解为做了多少次伯努利试验。
在redis中,H=64,m=14,也就是说有16384个分组。由于k_max不会超过50,所以每一个分组可以用6个字节保存k_max的情况,所以消耗的内存就是16384 * 6bit=12KB。当其实redis在这内存消耗上还做了一些优化,也是其一贯的套路,就是在编码方式上用了两种编码,分别是密集编码和稀疏避免,应对不同的数据量。

代码解读

首先来看HyberLogLog的头部定义

struct hllhdr {
   
    char magic[4];       // 魔法数字,固定填充为HYLL
    uint8_t encoding;   // 用来表示编码方式,0是密集编码,1是稀疏编码
    uint8_t notused[3]; // 保留未来使用,必须填充零
    uint8_t card[8];    // 缓存的基数,最高位为1则无效,否则可以使用以提高效率
    uint8_t registers[]; // 计数器
};

registers 是一个计数器,对于不同的编码方式来说,是不一样的,下面先以简单的密集编码来讲述整个过程。

密集编码

在密集编码下,registers就是一个固定的16384 * 6bit 大小的数组,数组的每一项为八个bit,这也就意味着每一个分组的k_max计数可能落在数组不同的位置。因此,redis先定义了两个宏来查询或者设置某一个分组的k_max值。之所以使用宏,作者说是为了内联inline以提高运行效率。下面来看看这两个预先定义的宏。

// 该宏的作用是将数组p 对应的第regnum个分组的计数取出,赋值给target
#define HLL_DENSE_GET_REGISTER(target,p,regnum) do {
      \
    uint8_t *_p = (uint8_t*) p; \
    unsigned long _byte = regnum*HLL_BITS/8; \
    unsigned long _fb = regnum*HLL_BITS&7; \
    unsigned long _fb8 = 8 - _fb; \
    unsigned long b0 = _p[_byte]; \
    unsigned long b1 = _p[_byte+1]; \
    target = ((b0 >> _fb) | (b1 << _fb8)) & HLL_REGISTER_MAX; \
} while(0)

其中,HLL_BITS也是预先定义的宏,表示每一个分组的bit位数,这里为6。用一个例子描述一下这个过程,假设p表示的数组为 00101101 11010011 11000101 … , 现在要求第二个分组的计数,这个计数应该是001100, 因为每一项从低位开始,那么第一个分组是101101,第二个分组是001100 。那么regnum=1(从零开始计数),则_byte算出的是0, 表示第二个分组的计数从p的第一项开始,算出_fb=6,表示第二个分组的计数从p的第一项的第6位开始(从最低位0开始),则_fb8=2,b0=00101101,b1=11010011,b0 >> _fb = 00000000, b1 << _fb8=01001100, 两者按位或运算得到结果为01001100, 再和HLL_REGISTER_MAX=63=111111, 按位与运算(去掉高两位)得到001100。
再来看如何设置某一个分组的计数,宏定义如下

// 该宏将p的第regnum个分组的计数设置为val
#define HLL_DENSE_SET_REGISTER(p,regnum,val) do {
      \
    uint8_t *_p = (uint8_t*) p; \
    unsigned long _byte = regnum*HLL_BITS/8; \
    unsigned long _fb = regnum*HLL_BITS&7; \
    unsigned long _fb8 = 8 - _fb; \
    unsigned long _v = val; \
    _p[_byte] &= ~(HLL_REGISTER_MAX << _fb); \
    _p[_byte] |= _v << _fb; \
    _p[_byte+1] &= ~(HLL_REGISTER_MAX >> _fb8); \
    _p[_byte+1] |= _v >> _fb8; \
} while(0)

这个稍微复杂了一点,同样以一个例子说明,假设p表示的数组为 00101101 11010011 11000101 … , 现在要将第三个分组(regnum=2)的计数设置为37=10 0101,首先还是找到这个分组开始于p的第几项的第几个位,可以计算得到,_byte=1, _fb=4, _fb8=4, 接下来就是将两部分的对应位先清零,然后再赋值。
接下来看密集编码下的两个重要操作,分别是增加一个元素和基数统计。增加一个元素的入口函数如下

// hllDenseAdd 往hll中‘增加’一个元素
int hllDenseAdd(uint8_t *registers, unsigned char *ele, size_t elesize) {
   
    long index;
    // index 表示分组序号,count表示这个分组的计数
    uint8_t count = hllPatLen(ele,elesize,&index);
    /* Update the register if this element produced a longer run of zeroes. */
    return hllDenseSet(registers,index,count)
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值