redis源码阅读—hyperloglog(基数统计)_redis hyperloglog 源码(2)

img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

如果你需要这些资料,可以戳这里获取

背景

假如现在老板给了你一个需求,统计网站一天有多少个ip地址访问或者统计某个商品链接每天被多少个不同客户访问,你会选择那种解决方案呢?

第一反应我用hashMap啊,这样很方便的实现了去重。但是仔细一想,发现不对劲啊,我们的产品日活用户达到百万以上级别了,如果采用 HashMap 的做法,就会导致程序中占用大量的内存。

这个时候,可能公司有经验的工程师就会建议你使用hyperloglog。在一定条件允许下,如果允许统计在巨量数据面前的误差率在可接受的范围内,1000万浏览量允许最终统计出少了一两万这样子,那么就可以采用HyperLogLog算法来解决上面的计数类似问题。

基数统计

什么是基数呢?基数是指一个集合中不同元素的个数。假设有一组数据{1,2,3,3,4,5,4,6},除去重复的数字之后,该组数据中不同的数有6个,则该组数据的基数为6。

那什么是基数统计呢?基数统计是指在误差允许的情况下估算出一组数据的误差。

从上述的概念中,我们可以很容易想到基数统计的用途,HLL算法用来进行基数统计。

伯努利试验

伯努利试验是数学概率论中的一部分内容,它的典故来源于抛硬币。

硬币拥有正反两面,一次的上抛至落下,最终出现正反面的概率都是50%。假设一直抛硬币,直到它出现正面为止,我们记录为一次完整的试验,间中可能抛了一次就出现了正面,也可能抛了4次才出现正面。无论抛了多少次,只要出现了正面,就记录为一次试验。

那么对于多次的伯努利试验,假设这个多次为n次。就意味着出现了n次的正面。假设每次伯努利试验所经历了的抛掷次数为k。第一次伯努利试验,次数设为k1,以此类推,第n次对应的是kn。

其中,对于这n次伯努利试验中,必然会有一个最大的抛掷次数k,例如抛了12次才出现正面,那么称这个为k_max,代表抛了最多的次数。

伯努利试验容易得出有以下结论:

  • n 次伯努利过程的投掷次数都不大于 k_max。
  • n 次伯努利过程,至少有一次投掷次数等于 k_max

最终结合极大似然估算的方法,发现在n和k_max中存在估算关联:n = 2^(k_max) 。这样一来,我们就可以将2^(k_max) 作为n的一个粗糙估计。

这种通过局部信息预估整体数据流特性的方法似乎有些超出我们的基本认知,需要用概率和统计的方法才能推导和验证这种关联关系。

当然,在实际应用中,由于数据存在偶然性,会导致估计量误差较大,这时候需要采用分组估计来消除误差,并且进行偏差修正。

所谓分组估计就是,每一个数据进行hash之后存放在不同的桶中,然后计算每一个桶的k_max,最后对这些值求一个平k_avg,即可得到基数的粗糙估计2^(k_avg)。

估算优化

针对上面的估算只是进行了一轮的试验,那么是否可以进行多轮呢?例如进行 100 轮或者更多轮次的试验,然后再取每轮的 k_max,再取平均数,即: k_max/100。最终再估算出 n。下面是LogLog的估算公式
在这里插入图片描述
上面公式的DVLL对应的就是nconstant是修正因子,它的具体值是不定的,可以根据实际情况而分支设置。m代表的是试验的轮数。头上有一横的R就是平均数:(k_max_1 + ... + k_max_m)/m


这种通过增加试验轮次,再取k_max平均数的算法优化就是LogLog的做法。而 HyperLogLog和LogLog的区别就是,它采用的不是平均数,而是调和平均数。下面举个栗子:

求平均工资:

A的是1000/月,B的30000/月。采用平均数的方式就是: (1000 + 30000) / 2 = 15500

采用调和平均数的方式就是: 2/(1/1000 + 1/30000) ≈ 1935.484

调和平均数估算公式
在这里插入图片描述
n代表试验的次数,是累加符号,其实就是这样一种形式:n * 1 / (1/x1 + 1/x2 + 1/x3 +...+1/xn)调和平均数比平均数的好处就是不容易受到大的数值的影响


Hyperloglog实现过程

比特串

通过hash函数,将数据转为比特串,例如输入5,便转为:101。为什么要这样转化呢?

是因为要和抛硬币对应上,比特串中,0 代表了反面,1 代表了正面,如果一个数据最终被转化了 10010000,那么从右往左,从低位往高位看,我们可以认为,首次出现 1 的时候,就是正面。

那么基于上面的估算结论,我们可以通过多次抛硬币实验的最大抛到正面的次数来预估总共进行了多少次实验,同样也就可以根据存入数据中,转化后的出现了 1 的最大的位置 k_max 来估算存入了多少数据。

分桶

分桶就是分多少轮。抽象到计算机存储中去,就是存储的是一个以单位是比特(bit),长度为 L 的大数组 S ,将 S 平均分为 m 组,注意这个 m 组,就是对应多少轮,然后每组所占有的比特个数是平均的,设为 P。容易得出下面的关系:

  • L = S.length
  • L = m * p
  • 以 K 为单位,S 占用的内存 = L / 8 / 1024

在 Redis 中,HyperLogLog设置为:m=16834,p=6,L=16834 * 6。占用内存为=16834 * 6 / 8 / 1024 = 12K

  第0组     第1组                       .... 第16833组
[000 000] [000 000] [000 000] [000 000] .... [000 000]

对应

现在回到我们的原始APP页面统计用户的问题中去。

  • 设 APP 主页的 key 为: main
  • 用户 id 为:idn , n->0,1,2,3…

在这个统计问题中,不同的用户 id 标识了一个用户,那么我们可以把用户的 id 作为被hash的输入。即:

hash(id) = 比特串

不同的用户 id,必然拥有不同的比特串。每一个比特串,也必然会至少出现一次 1 的位置。我们类比每一个比特串为一次伯努利试验。

现在要分轮,也就是分桶。所以我们可以设定,每个比特串的前多少位转为10进制后,其值就对应于所在桶的标号。假设比特串的低两位用来计算桶下标志,此时有一个用户的id的比特串是:1001011000011。它的所在桶下标为:11(2) = 1*2^1 + 1*2^0 = 3,处于第3个桶,即第3轮中。

上面例子中,计算出桶号后,剩下的比特串是:10010110000,从低位到高位看,第一次出现 1 的位置是 5 。也就是说,此时第3个桶,第3轮的试验中,k_max = 5。5 对应的二进制是:101,又因为每个桶有 p 个比特位。当 p>=3 时,便可以将 101 存进去。

模仿上面的流程,多个不同的用户 id,就被分散到不同的桶中去了,且每个桶有其 k_max。然后当要统计出 mian 页面有多少用户点击量的时候,就是一次估算。最终结合所有桶中的 k_max,代入估算公式,便能得出估算值。

下面是 HyperLogLog 的结合了调和平均数的估算公式,变量释意和LogLog的一样:
在这里插入图片描述

看看源码

创建hll对象

struct hllhdr {
    char magic[4];      /\* "HYLL" 魔数,前面4个字节表示这是一个hll对象\*/
    uint8_t encoding;   /\* HLL\_DENSE or HLL\_SPARSE. 存储方式 密集和稀疏\*/
    uint8_t notused[3]; /\* Reserved for future use, must be zero. 保留字段,因为redis是自然字节对齐的,所以空着也是空着,不如定义一下\*/
    uint8_t card[8];    /\* Cached cardinality, little endian. 缓存的当前hll对象的基数值 \*/
    uint8_t registers[]; /\* Data bytes. 桶个数 对于dense存储方式,这里就是一个12k的连续数组,对于sparse存储方式,这里长度是不定的\*/
};

/\* Create an HLL object. We always create the HLL using sparse encoding.
 \* This will be upgraded to the dense representation as needed.
 这里英文注释其实已经写的很清楚了,默认hll对象使用sparse的编码方式,这样比较节约内存,但是sparse方式存储其实比较难以理解,代码实现也比较复杂,但是对于理解来说,其实就是对于里面hll桶的存储方式的不同,HLL算法本身逻辑上没有区别
 \*/
robj \*createHLLObject(void) {
    robj \*o;
    struct hllhdr \*hdr;
    sds s;
    uint8_t \*p;
    int sparselen = HLL_HDR_SIZE +
                    (((HLL_REGISTERS+(HLL_SPARSE_XZERO_MAX_LEN-1)) /
                     HLL_SPARSE_XZERO_MAX_LEN)\*2);  
    //头长度+(16384 + (16384-1) / 16384 \* 2),也就是2个字节,默认因为基数统计里面所有的桶都是0,用spase方式存储,只需要2个字节
    int aux;

    /\* Populate the sparse representation with as many XZERO opcodes as
 \* needed to represent all the registers. \*/
    aux = HLL_REGISTERS;
    s = sdsnewlen(NULL,sparselen);
    p = (uint8_t\*)s + HLL_HDR_SIZE;
    while(aux) {
        int xzero = HLL_SPARSE_XZERO_MAX_LEN;
        if (xzero > aux) xzero = aux;
        HLL\_SPARSE\_XZERO\_SET(p,xzero);
        p += 2;
        aux -= xzero;
    }
    serverAssert((p-(uint8_t\*)s) == sparselen);

    /\* Create the actual object. \*/
    o = createObject(OBJ_STRING,s);
    hdr = o->ptr;
    memcpy(hdr->magic,"HYLL",4);
    hdr->encoding = HLL_SPARSE;
    return o;
}

添加元素

PFADD key element [element …]

void pfaddCommand(client \*c) {
    robj \*o = lookupKeyWrite(c->db,c->argv[1]);
    struct hllhdr \*hdr;
    int updated = 0, j;
    // 客户端交互部分,此处可以放着以后理解
    if (o == NULL) { 
        // 创建一个hyperloglog键
        o = createHLLObject();
        dbAdd(c->db,c->argv[1],o);
        updated++;
    } else {
        // 判断是否是一个hyperloglog键,判断前四个字节是否为'HYLL'
        if (isHLLObjectOrReply(c,o) != C_OK) return;
        o = dbUnshareStringValue(c->db,c->argv[1],o);
    }
    // 调用hllAdd函数来添加元素
    for (j = 2; j < c->argc; j++) {
        int retval = hllAdd(o, (unsigned char\*)c->argv[j]->ptr,
                               sdslen(c->argv[j]->ptr));
        switch(retval) {
        case 1:
            updated++;
            break;
        case -1:
            addReplySds(c,sdsnew(invalid_hll_err));
            return;
        }
    }
    hdr = o->ptr;
    if (updated) {
        signalModifiedKey(c->db,c->argv[1]);
        notifyKeyspaceEvent(NOTIFY_STRING,"pfadd",c->argv[1],c->db->id);
        server.dirty++;
        HLL\_INVALIDATE\_CACHE(hdr);
    }
    // 客户端交互部分,此处可以放着以后理解
    addReply(c, updated ? shared.cone : shared.czero);
}

上述代码包含了很多与客户端交互的部分,此处可以先不看,添加元素主要由hllAdd函数实现。

/\* Call hllDenseAdd() or hllSparseAdd() according to the HLL encoding. \*/
int hllAdd(robj \*o, unsigned char \*ele, size_t elesize) {
    struct hllhdr \*hdr = o->ptr;
    switch(hdr->encoding) {
    case HLL_DENSE: return hllDenseAdd(hdr->registers,ele,elesize);
    case HLL_SPARSE: return hllSparseAdd(o,ele,elesize);
    default: return -1; /\* Invalid representation. \*/
    }
}

使用dense方式存储

来一个byte流,传入 是一个void * 指针和一个长度len,
通过MurmurHash64A 函数 计算一个64位的hash值。64位的前14位(这个值是可以修改的)作为index,后面作为50位作为bit流。
2 ^ 14 == 16384 也就是一共有16384个桶。每个桶使用6个bit存储。

img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

如果你需要这些资料,可以戳这里获取

[外链图片转存中…(img-fcJyF7AE-1715909704464)]
[外链图片转存中…(img-useWYWMN-1715909704464)]
[外链图片转存中…(img-7KwVgNtW-1715909704464)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

如果你需要这些资料,可以戳这里获取

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值