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

其实在看hyperloglog源码之前,redis里这个高级数据类型都没有用过,只是了解一点它的命令。主要是用来基数统计,如日活用户统计(针对千万级别的访问量,如果实际没有那么大hashmap和bitmap都可以解决)这种需求会使用这种数据类型,性能会高很多。看了源码之后才发现,真是不太能看懂,里边涉及很多的算法和数学知识,尽管如此,还是简单的把我理解的东西的总结一下。

推荐文章
知乎:Redis源码中hyperloglog结构的实现原理是什么?
掘金:HyperLogLog 算法的原理讲解以及 Redis 是如何应用它的

背景

假如现在老板给了你一个需求,统计网站一天有多少个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存储。

后面的50位bit流,如下样子:
00001000…11000
其中第一次出现1的位置我们记为count, 所以count最大值是50, 用6个bit位就够表示了。
2 ^ 6 = 64

故一个HLL对象实际用来存储的空间是16384(个桶) * (
每个桶6个bit) / 8 = 12288 byte。 也就是使用了约12k的内存。这个其实redis比较牛逼的地方,其实用一个字节来存的话,其实也就是16k的内存,但是为了能省4k的内存,搞出一堆。这个只是dense方式存储,相对是浪费空间的,下面讲的sparse方式存储更加节约空间。

计算出index(桶的下标), count(后面50个bit中第一次出现1的位置)后,下一步就是更新桶的操作。
根据index找到桶,然后看当前的count 是否大于oldcount,大于则更新下oldcount = count。此时为了性能考虑,是不会去统计当前的基数的,而是将HLL的头里面的一个标志位置为1,表示下次进行pfcount操作的时候,当前的缓存值已经失效了,需要重新统计缓存值。在后面pfcount流程的时候,发现这个标记为失效,就会去重新统计新的基数,放入基数缓存。

// 密集模式添加元素
int hllDenseAdd(uint8_t *registers, unsigned char *ele, size_t elesize) {
    
    
    long index;
    uint8_t count = hllPatLen(ele,elesize,&index); //index就是桶的下标, count则是后面50个bit位中1第一次出现的位置
    /* Update the register if this element produced a longer run of zeroes. */
    return hllDenseSet(registers,index,count);
}

int hllDenseSet(uint8_t *registers, long index, uint8_t count) {
    
    
    uint8_t oldcount;
    //找到对应的index获取其中的值
    HLL_DENSE_GET_REGISTER(oldcount,registers,index);
    if (count > oldcount) {
    
     // 如果比现有的最大值还大,则添加该值到数据部分
        HLL_DENSE_SET_REGISTER(registers,index,count);
        return 1;
    } else {
    
    // 如果小于现有的最大值,则不做处理,因为不影响基数
        return 0;
    }
}

// 用于计算hash后的值中,第一个出现1的位置
int hllPatLen(unsigned char *ele, size_t elesize, long *regp) {
    
    
    uint64_t hash, bit, index;
    int count;
    // 利用MurmurHash64A哈希函数来计算该元素的hash值  
    hash = MurmurHash64A(ele,elesize,0xadc83b19ULL);
    // 计算应该放在哪个桶
    index = hash & HLL_P_MASK;
    // 为了保证循环能够终止
    hash |= ((uint64_t)1<<63); 
    bit = HLL_REGISTERS;
    // 存储第一个1出现的位置
    count = 1;
    // 计算count
    while((hash & bit) == 0) {
    
    
        count++;
        bit <<= 1;
    }
    *regp = (int) index;
    return count;
}
/* Our hash function is MurmurHash2, 64 bit version.
 * It was modified for Redis in order to provide the same result in
 * big and little endian archs (endian neutral). */
uint64_t MurmurHash64A (const void * key, int len, unsigned int seed) {
    
    }

计算基数

统计基数流程,就如果cache标志位是有效的,直接返回缓存值,否则重新计算HLL的所有16384个桶,然后进行统计修正,具体的修正的原理,涉及很多的数学知识和论文,这里就不提及了。具体参考:解读Cardinality Estimation算法(第四部分:HyperLogLog Counting及Adaptive Counting) 太难了 看不懂

/* Return the approximated cardinality of the set based on the harmonic
 * mean of the registers values. 'hdr' points to the start of the SDS
 * representing the String object holding the HLL representation.
 *
 * If the sparse representation of the HLL object is not valid, the integer
 * pointed by 'invalid' is set to non-zero, otherwise it is left untouched.
 *
 * hllCount() supports a special internal-only encoding of HLL_RAW, that
 * is, hdr->registers will point to an uint8_t array of HLL_REGISTERS element.
 * This is useful in order to speedup PFCOUNT when called against multiple
 * keys (no need to work with 6-bit integers encoding). */
uint64_t hllCount(struct hllhdr *hdr, int *invalid) {
    
    
    double m = HLL_REGISTERS;
    double E;
    int j;
    int reghisto[HLL_Q+2] = {
    
    0};

    /* Compute register histogram */
    if (hdr->encoding == HLL_DENSE) {
    
    
        hllDenseRegHisto(hdr->registers,reghisto);
    } else if (hdr->encoding == HLL_SPARSE) {
    
    
        hllSparseRegHisto(hdr->registers,
                         sdslen((sds)hdr)-HLL_HDR_SIZE,invalid,reghisto);
    } else if (hdr->encoding == HLL_RAW) {
    
    
        hllRawRegHisto(hdr->registers,reghisto);
    } else {
    
    
        serverPanic("Unknown HyperLogLog encoding in hllCount()");
    }

    /* Estimate cardinality form register histogram. See:
     * "New cardinality estimation algorithms for HyperLogLog sketches"
     * Otmar Ertl, arXiv:1702.01284 */
    //这里具体的修正流程,要去看论文,就照着抄过来实现就可以了。 
    double z = m * hllTau((m-reghisto[HLL_Q+1])/(double)m);
    for (j = HLL_Q; j >= 1; --j) {
    
    
        z += reghisto[j];
        z *= 0.5;
    }
    z += m * hllSigma(reghisto[0]/(double)m);
    E = llroundl(HLL_ALPHA_INF*m*m/z);

    return (uint64_t) E;
}

合并hyperloglog键

这部分真没太看懂…

回头再想想

前面我们已经认识到,它的实现中,设有 16384 个桶,即:2^14 = 16384,每个桶有 6 位,每个桶可以表达的最大数字是: 2^5+2^4+...+1 = 63 ,二进制为: 111 111 。

对于命令:pfadd key value
在存入时,value 会被 hash 成 64 位,即 64 bit 的比特字符串,前 14 位用来分桶,前 14 位的二进制转为 10 进制就是桶标号。

之所以选 14位 来表达桶编号是因为,分了 16384 个桶,而 2^14 = 16384,刚好地,最大的时候可以把桶利用完,不造成浪费。假设一个字符串的前 14 位是:00 0000 0000 0010,其十进制值为 2。那么 index 将会被转化后放到编号为 2 的桶。

index 的转化规则:
首先因为完整的 value 比特字符串是 64 位形式,减去 14 后,剩下 50 位,那么极端情况,出现 1 的位置,是在第 50 位,即位置是 50。此时 index = 50。此时先将 index 转为 2 进制,它是:110010 。

因为16384 个桶中,每个桶是 6 bit 组成的。刚好 110010 就被设置到了第 2 号桶中去了。请注意,50 已经是最坏的情况,且它都被容纳进去了。那么其他的不用想也肯定能被容纳进去。

根据上面的做法,不同的 value,会被设置到不同桶中去,如果出现了在同一个桶的,即前 14 位值是一样的,但是后面出现 1 的位置不一样。那么比较原来的 index 是否比新 index 大。是,则替换。否,则不变。

最终地,一个 key 所对应的 16384 个桶都设置了很多的 value 了,每个桶有一个k_max。此时调用 pfcount 时,按照前面介绍的估算方式,便可以计算出 key 的设置了多少次 value,也就是统计值。

value 被转为 64 位的比特串,最终被按照上面的做法记录到每个桶中去。64 位转为十进制就是:2^64,HyperLogLog 仅用了:16384 * 6 /8 / 1024 K 存储空间就能统计多达 2^64 个数。

猜你喜欢

转载自blog.csdn.net/qq_36581961/article/details/112260362