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有两种编码方式:稠密编码和稀疏编码
- 稠密编码
稠密编码很容易理解:每6bit表达一个桶位即可。 - 稀疏编码
虽然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呢?因为前面讲过了计数是从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位
- 设置具体的值
为什么这里需要定义位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编码。
下面的代码可以简单理解为以下几步(具体见代码注释):
- 判断count值是否大于32,如果是直接转为dense编码;
- 找到桶位index所处的opcode的内存位置;
- 如果该opcode的中表达重复次数的值为1,意味着当前opcode只表示了当前一个桶位,分两种情况:
- 如果是ZERO和VAL的情况,可以直接设置值
- 如果是XZERO,因为占用两个字节,不能直接表达VAL(VAL只需要一个字节),所以按照后面的步骤处理
- 不是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;
}