redis源码阅读-HyperLogLog

HyperLogLog是redis中用来做基数统计的。

什么是基数?

即一组元素中不重复的所有元素,比如一组元素{1,2,2,3,4,5,5,5,6,7,7},不重复的数为{1,2,3,4,5,6,7},所以基数为7。

hyperLog用法

  • PFADD key element [element …] 添加指定元素到 HyperLogLog 中。
  • PFCOUNT key [key …] 返回给定 HyperLogLog 的基数估算值。
  • PFMERGE destkey sourcekey [sourcekey …] 将多个 HyperLogLog 合并为一个 HyperLogLog

hyperLog原理

hyperLog其实是基于伯努利实验提出的,怎么来理解伯努利实验,这里举个简单的例子:

假如你扔了很多次硬币,然后告诉我连续出现反面的最大次数为2,让我猜你一共扔了多少次实验?
用1代表正面,0代表反面
序列如下: 1110100110,那么出现我连续出现反面的最大次数为2的概率为1/21/21/2=1/8,所以我猜测总共抛了8次。

那如何把这种思想应用到基数统计呢?

通过把值进行hash,得到固定长度的结果,然后将这个结果表示为二进制形式,每次新添加一个值,相当于抛了一次硬币。记录这个抛硬币序列的某种信息,我理解这里可以有多种信息表达,然后根据上面的伯努利实验来估计总共有多少个不同的数加入(由于相同的值不会改变原来的信息,所以这样就可以估计出加入的不同的值)。

但是上面这样的估计是不准确的?
假设我们用第一个出现1的位置来表达信息,那么两个不同的值完全可能出现出现1的位置是相同的,所以这样就会导致出现冲突。
怎么解决这样的冲突呢?
分桶。说白了,就是把数据分成m份,每份分别计算结果,然后将求的值求调和平均即可。

redis中HyperLog实现原理

将值通过hash后得到64位的值,用前14位来计算桶位,2的14次方是16384,所以需要16384个桶位。剩下的50位的第一个1出现的位置这个信息,需要装入桶中,最大的值就是50,需要用6个bit来表示。理论上需要的内存大小16384*6=12k。

redis中的编码方式

这里编码方式其实就是指redis中如何去存储上面讲到的16384*6。
redis中针对hyperLogLog有两种编码方式:稠密编码和稀疏编码

  1. 稠密编码
    稠密编码很容易理解:每6bit表达一个桶位即可。
  2. 稀疏编码
    虽然12k大小已经很小了,但redis对内存的优化是非常严格的。稀疏编码有3种表达:
  • ZERO: 00xxxxxx, 占用一个byte,前2位的0表示ZERO表达,后面6位表示连续的0的桶位的个数
  • XZERO: 01xxxxxx xxxxxxxx,占用两个byte,这个可以理解位ZERO的增强版,因为上面一共只有6位来表达,最大能表达64。XZERO用14位来表达,可最大表达16384
  • VAL: 1vvvvvxx,占用一个byte,中间的5位来表示具体的值,后面的2位来表示前面的值出现的次数,所以可以表达的最大的数为32,重复次数1-4.

从上面可以看出,稀疏编码最大能表达的数是32,所以当有值超过32时,就不得不转为稠密编码。

这里需要注意一下。计数是从0开始的,即0表示1,用1vvvvvxx来比喻,10000300表示的是4重复1次。
稀疏编码的例子:假如在桶位为1001,1002,1010的值分别为2,2,3,其余桶位均为0,那么稀疏编码表达如下:
1001,1002,1010的二进制分别为,11 1110 1001,11 1111 0010

XZERO: 0100 0011 1110 1000 表示0-1000都为0
VAL: 1 00010 02 表示1001,1002连续出现两个2
ZERO: 0000 0111 表示1003-1009连续7个0
VAL: 1 00011 01 表示1010是3
XZERO: 0111 1100 0000 1110 表示从1011到16384都为0

这里需要改为从0开始的计数

这里一共用了2+1+1+1+2=7个字节,相较于稠密编码用到了12k节约了很大的内存。

问题:
在执行padd时,数据可能会发生变化,稀疏编码岂不是是改动很大?

代码实现

数据结构

struct hllhdr {
    char magic[4];      /* "HYLL" */
    uint8_t encoding;   /* HLL_DENSE or HLL_SPARSE. 表达稠密编码或稀疏编码 */
    uint8_t notused[3]; /* Reserved for future use, must be zero. 未使用的位 */
    uint8_t card[8];    /* Cached cardinality, little endian. 缓存的基数*/
    uint8_t registers[]; /* Data bytes. 上面讲到的桶位*/
};

创建redis的HLLObject。刚创建是稀疏编码,由于刚创建值都为0,所以需要足够的XZERO来表达。

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);
    int aux;
    
    aux = HLL_REGISTERS;  //最开始需要表达16384个连续的0
    s = sdsnewlen(NULL,sparselen);
    p = (uint8_t*)s + HLL_HDR_SIZE;
    while(aux) {
        int xzero = HLL_SPARSE_XZERO_MAX_LEN; //这个长度就是16384
        if (xzero > aux) xzero = aux;
        HLL_SPARSE_XZERO_SET(p,xzero); //设置连续0的个数 
        p += 2; //因为XZERO是2个字节
        aux -= xzero; 
    }
    serverAssert((p-(uint8_t*)s) == sparselen);

    o = createObject(OBJ_STRING,s);
    hdr = o->ptr;
    memcpy(hdr->magic,"HYLL",4); 
    hdr->encoding = HLL_SPARSE; //默认为稀疏编码
    return o;
}

Add操作

宏定义

先了解一些重要的宏定义,后面的代码会多次使用到.

  1. 计算重复的次数

为什么要加上1呢?因为前面讲过了计数是从0开始的,0表示1,所以这里算长度需要加1.

#define HLL_SPARSE_ZERO_LEN(p) (((*(p)) & 0x3f)+1) //0x3f 111111
#define HLL_SPARSE_VAL_LEN(p) (((*(p)) & 0x3)+1) // p指向的值&2进值的11,因为VAL的最后两位来表达重复
#define HLL_SPARSE_XZERO_LEN(p) (((((*(p)) & 0x3f) << 8) | (*((p)+1)))+1) // p这个位置的后7位加上p后面一个位置的8位
  1. 设置具体的值
    为什么这里需要定义位do…while(0)呢?
#define HLL_SPARSE_VAL_SET(p,val,len) do { \
    *(p) = (((val)-1)<<2|((len)-1))|HLL_SPARSE_VAL_BIT; \
} while(0) //while(0)的写法是什么意思呢??
#define HLL_SPARSE_ZERO_SET(p,len) do { \
    *(p) = (len)-1; \
} while(0)
#define HLL_SPARSE_XZERO_SET(p,len) do { \
    int _l = (len)-1; \
    *(p) = (_l>>8) | HLL_SPARSE_XZERO_BIT; \
    *((p)+1) = (_l&0xff); \
} while(0)

根据编码的不同,选择不同的函数处理。

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. */
    }
}

两个函数都会调用到下面的函数:
regp最后是桶位的索引位置,返回的第一次出现1的位置。

int hllPatLen(unsigned char *ele, size_t elesize, long *regp) {
    uint64_t hash, bit, index;
    int count;
    hash = MurmurHash64A(ele,elesize,0xadc83b19ULL);
    index = hash & HLL_P_MASK; /* Register index. HLL_P_MASK 其实就是:111 1111 11111  */
    hash >>= HLL_P; /* hash左移14位,剩余的50位用来求职 */
    hash |= ((uint64_t)1<<HLL_Q); // 避免hash都为0的情况,下面的循环无法退出
    bit = 1;
    count = 1; /* Initialized to 1 since we count the "00000...1" pattern. */
    while((hash & bit) == 0) {
        count++;
        bit <<= 1; //计算第一次出现1的位置
    }
    *regp = (int) index; //桶位的位置
    return count;
}

先看稠密编码的情况:

首先获取到对应桶位中对应的oldcount,只有这个oldcount值小于当前的count值,则更新为当前值

int hllDenseSet(uint8_t *registers, long index, uint8_t count) {
    uint8_t oldcount;

    HLL_DENSE_GET_REGISTER(oldcount,registers,index);
    if (count > oldcount) {
        HLL_DENSE_SET_REGISTER(registers,index,count);
        return 1;
    } else {
        return 0;
    }
}

稀疏编码的情况会被稠密编码的情况复杂得多。
因为某个值改变,可能会改变原来的编码,就需要重新编码,甚至所占用的内存大小也会改变。如果count值大于了32,还需要转化为dense编码。
下面的代码可以简单理解为以下几步(具体见代码注释):

  1. 判断count值是否大于32,如果是直接转为dense编码;
  2. 找到桶位index所处的opcode的内存位置;
  3. 如果该opcode的中表达重复次数的值为1,意味着当前opcode只表示了当前一个桶位,分两种情况:
    • 如果是ZERO和VAL的情况,可以直接设置值
    • 如果是XZERO,因为占用两个字节,不能直接表达VAL(VAL只需要一个字节),所以按照后面的步骤处理
  4. 不是3种的第一种情况,就需要重新分配来把之前的opcode进行分裂。分3段来设置值:[first,index)之间的值,index的值,(index,last]的值。
int hllSparseSet(robj *o, long index, uint8_t count) {
    struct hllhdr *hdr;
    uint8_t oldcount, *sparse, *end, *p, *prev, *next;
    long first, span;
    long is_zero = 0, is_xzero = 0, is_val = 0, runlen = 0;

    //当count大于32时,需要转为dense编码
    if (count > HLL_SPARSE_VAL_MAX_VALUE) goto promote;

    //在更新中,所需的内存可能会变大,最坏的情况就是XZERO变为:XZERO-VAL-XZERO,所以可能需要新增3个byte的内存
    //先调用sdsMakeRoomFor来分配足够的空间
    o->ptr = sdsMakeRoomFor(o->ptr,3);

    //step1: 定位桶位index所在的内存位置
    sparse = p = ((uint8_t*)o->ptr) + HLL_HDR_SIZE; //p表示的桶位所在opcode位置的起始地址
    end = p + sdslen(o->ptr) - HLL_HDR_SIZE;

    first = 0; //表示循环里已经遍历到的桶位的个数
    prev = NULL; //指向前一个opcode
    next = NULL; //指向下一个opcode
    span = 0; //表示当前这个opcode下,0或者val重复的次数
    while(p < end) {
        long oplen;
        oplen = 1;
        if (HLL_SPARSE_IS_ZERO(p)) {
            span = HLL_SPARSE_ZERO_LEN(p);
        } else if (HLL_SPARSE_IS_VAL(p)) {
            span = HLL_SPARSE_VAL_LEN(p);
        } else { /* XZERO. */
            span = HLL_SPARSE_XZERO_LEN(p);
            oplen = 2;
        }

        if (index <= first+span-1) break; //当前就是opcode就是桶位index的位置
        prev = p;
        p += oplen; //注意这里p变化了,后面会用到
        first += span;
    }
    if (span == 0 || p >= end) return -1; /* Invalid format. */

    next = HLL_SPARSE_IS_XZERO(p) ? p+2 : p+1; //当前opcode的下一个opcode
    if (next >= end) next = NULL;

    //记录下当前opcode的类型和长度,避免之后重复计算
    if (HLL_SPARSE_IS_ZERO(p)) {
        is_zero = 1;
        runlen = HLL_SPARSE_ZERO_LEN(p);
    } else if (HLL_SPARSE_IS_XZERO(p)) {
        is_xzero = 1;
        runlen = HLL_SPARSE_XZERO_LEN(p);
    } else {
        is_val = 1;
        runlen = HLL_SPARSE_VAL_LEN(p);
    }

    //情况1: runlen==1并且原来为VAL或者ZERO
    //为什么这种情况可直接设置值:runlen==1,表示重复次数为1,即该opcode只表达了当前这个桶位
    if (is_val) {
        oldcount = HLL_SPARSE_VAL_VALUE(p);
        if (oldcount >= count) return 0;
        if (runlen == 1) {
            HLL_SPARSE_VAL_SET(p,count,1);
            goto updated;
        }
    }
    //原来是ZERO,但现在要设置为VAL,所以和上面的情况一样
    if (is_zero && runlen == 1) {
        HLL_SPARSE_VAL_SET(p,count,1);
        goto updated;
    }

    //到这里说明 runlen!=1,或者为xzero的情况
    //原来的opcode就需要被分裂为多个opcodes
    //最坏的情况是XZERO会被分裂为:XZERO - VAL - XZERO,共需要5bytes
    //基本思路就是先创建一个5byte的新空间,把分裂后的值放到这个内存空间中
    uint8_t seq[5], *n = seq; //这个seq为什么需要5个字节?因为最坏的情况XZERO-VAL-XZERO 一共需要2+1+2=5种情况
    int last = first+span-1; /* Last register covered by the sequence. */
    int len;

    //设置分为3步
    // [first,index)之间的值,index的值,(index,last]的值
    if (is_zero || is_xzero) { //如果原来的操作是ZERO或者XZERO
        if (index != first) {
            len = index-first;  //index是桶位的位置,first是当前这个opcode起始那个位置表示的桶位
            if (len > HLL_SPARSE_ZERO_MAX_LEN) { //len>64,即ZERO无法表达,所以设置为XZERO来表达
                HLL_SPARSE_XZERO_SET(n,len);
                n += 2;
            } else {
                HLL_SPARSE_ZERO_SET(n,len); //ZERO可以表达
                n++;
            }
        }
        HLL_SPARSE_VAL_SET(n,count,1);
        n++;
        if (index != last) {
            len = last-index;
            if (len > HLL_SPARSE_ZERO_MAX_LEN) {
                HLL_SPARSE_XZERO_SET(n,len);
                n += 2;
            } else {
                HLL_SPARSE_ZERO_SET(n,len);
                n++;
            }
        }
    } else { //原来的opcode是VAL
        int curval = HLL_SPARSE_VAL_VALUE(p); //取得原来的值

        if (index != first) {
            len = index-first;
            HLL_SPARSE_VAL_SET(n,curval,len); //设置[first.index)的值
            n++;
        }
        HLL_SPARSE_VAL_SET(n,count,1); //设置当前的值
        n++;
        if (index != last) { //设置(index,last]的值
            len = last-index;
            HLL_SPARSE_VAL_SET(n,curval,len);
            n++;
        }
    }

     //前面已经调用了sdsMakeRoomFor()来分配足够多的空间
     int seqlen = n-seq; //两个char指针相减,得到的实际分裂后新的opcode的总长度
     int oldlen = is_xzero ? 2 : 1;
     int deltalen = seqlen-oldlen; //增加的长度

     if (deltalen > 0 &&
         sdslen(o->ptr)+deltalen > server.hll_sparse_max_bytes) goto promote;
     if (deltalen && next) memmove(next+deltalen,next,end-next); //把后面的内存位置往后面移动一下,为分裂出的opcode腾出空间
     sdsIncrLen(o->ptr,deltalen); //增加长度
     memcpy(p,seq,seqlen); //把新的地址拷贝到原来的位置
     end += deltalen;

updated:
    // Step 4: 合并可以合并的相邻项
    //代码省略
    return 1;

promote: //需要转化为紧密编码
     //代码省略......
    return dense_retval;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值