HyperLogLog是 Redis 的高级数据结构,是统计基数的利器。前文我们已经介绍过HyperLogLog的基本用法,如果只求会用,只需要掌握HyperLogLog的三个命令即可,如果想要更进一步了解HyperLogLog的原理以及源码实现,相信这篇文章会给你带来一些启发。
基数
在数学上,基数或势,即集合中包含的元素的“个数”(参见势的比较),是日常交流中基数的概念在数学上的精确化(并使之不再受限于有限情形)。有限集合的基数,其意义与日常用语中的“基数”相同,例如{\displaystyle {a,b,c}}的基数是3。无限集合的基数,其意义在于比较两个集的大小,例如整数集和有理数集的基数相同;整数集的基数比实数集的小。
在介绍HyperLogLog的原理之前,请你先来思考一下,如果让你来统计基数,你会用什么方法。
Set
熟悉Redis数据结构的同学一定首先会想到Set这个结构,我们只需要把数据都存入Set,然后用scard命令就可以得到结果,这是一种思路,但是存在一定的问题。如果数据量非常大,那么将会耗费很大的内存空间,如果这些数据仅仅是用来统计基数,那么无疑是造成了巨大的浪费,因此,我们需要找到一种占用内存较小的方法。
bitmap
bitmap同样是一种可以统计基数的方法,可以理解为用bit数组存储元素,例如01101001,表示的是[1,2,4,8],bitmap中1的个数就是基数。bitmap也可以轻松合并多个集合,只需要将多个数组进行异或操作就可以了。bitmap相比于Set也大大节省了内存,我们来粗略计算一下,统计1亿个数据的基数,需要的内存是:100000000/8/1024/1024 ≈ 12M。
虽然bitmap在节省空间方面已经有了不错的表现,但是如果需要统计1000个对象,就需要大约12G的内存,显然这个结果仍然不能令我们满意。在这种情况下,HyperLogLog将会出来拯救我们。
HyperLogLog原理
HyperLogLog实际上不会存储每个元素的值,它使用的是概率算法,通过存储元素的hash值的第一个1的位置,来计算元素数量。这么说不太容易理解,容我先搬出来一个栗子。
有一天Jack和丫丫玩抛硬币的游戏,规则是丫丫负责抛硬币,每次抛到正面为一回合,丫丫可以自己决定进行几个回合。最后需要告诉Jack最长的那个回合抛了多少次,再由Jack来猜丫丫一共进行了几个回合。Jack心想:这可不好猜啊,我得算算概率了。于是在脑海中绘制这样一张图。
k是每回合抛到1所用的次数,我们已知的是最大的k值,可以用k max 表示,由于每次抛硬币的结果只有0和1两种情况,因此,k max 在任意回合出现的概率即为(1/2) k max ,因此可以推测n=2 k max 。概率学把这种问题叫做伯努利实验。此时丫丫已经完成了n个回合,并且告诉Jack最长的一次抛了3次,Jack此时也胸有成竹,马上说出他的答案8,最后的结果是:丫丫只抛了一回合,Jack输了,要负责刷碗一个月。
终于,我们的Philippe Flajolet教授遇到了Jack一样的问题,他决心吸取Jack的教训,要让这个算法更加准确,于是引入了桶的概念,计算m个桶的加权平均值,这样就能得到比较准确的答案了(实际上还要进行其他修正)。最终的公式如图
其中m是桶的数量,const是修正常数,它的取值会根据m而变化。p=log 2 m
1switch (p) { 2 case 4: 3 constant = 0.673 * m * m; 4 case 5: 5 constant = 0.697 * m * m; 6 case 6: 7 constant = 0.709 * m * m; 8 default: 9 constant = (0.7213 / (1 + 1.079 / m)) * m * m; 10}
我们回到Redis,对于一个输入的字符串,首先得到64位的hash值,用前14位来定位桶的位置(共有2 14 ,即16384个桶)。后面50位即为伯努利过程,每个桶有6bit,记录第一次出现1的位置count,如果count>oldcount,就用count替换oldcount。
了解原理之后,我们再来聊一下HyperLogLog的存储。HyperLogLog的存储结构分为密集存储结构和稀疏存储结构两种,默认为稀疏存储结构,而我们常说的占用12K内存的则是密集存储结构。
密集存储结构
密集存储比较简单,就是连续16384个6bit的串成的位图。由于每个桶是6bit,因此对桶的定位要麻烦一些。
1#define HLL_BITS 6 /* Enough to count up to 63 leading zeroes. */ 2#define HLL_REGISTER_MAX ((1<<HLL_BITS)-1) 3/* Store the value of the register at position 'regnum' into variable 'target'. 4 * 'p' is an array of unsigned bytes. */ 5#define HLL_DENSE_GET_REGISTER(target,p,regnum) do { \ 6 uint8_t *_p = (uint8_t*) p; \ 7 unsigned long _byte = regnum*HLL_BITS/8; \ 8 unsigned long _fb = regnum*HLL_BITS&7; \ 9 unsigned long _fb8 = 8 - _fb; \ 10 unsigned long b0 = _p[_byte]; \ 11 unsigned long b1 = _p[_byte+1]; \ 12 target = ((b0 >> _fb) | (b1 << _fb8)) & HLL_REGISTER_MAX; \ 13} while(0) 14 15/* Set the value of the register at position 'regnum' to 'val'. 16 * 'p' is an array of unsigned bytes. */ 17#define HLL_DENSE_SET_REGISTER(p,regnum,val) do { \ 18 uint8_t *_p = (uint8_t*) p; \ 19 unsigned long _byte = regnum*HLL_BITS/8; \ 20 unsigned long _fb = regnum*HLL_BITS&7; \ 21 unsigned long _fb8 = 8 - _fb; \ 22 unsigned long _v = val; \ 23 _p[_byte] &= ~(HLL_REGISTER_MAX << _fb); \ 24 _p[_byte] |= _v << _fb; \ 25 _p[_byte+1] &= ~(HLL_REGISTER_MAX >> _fb8); \ 26 _p[_byte+1] |= _v >> _fb8; \ 27} while(0)
如果我们要定位的桶编号为regnum,它的偏移字节量为(regnum * 6) / 8,起始bit偏移为(regnum * 6) % 8,例如,我们要定位编号为5的桶,字节偏移是3,位偏移也是6,也就是说,从第4个字节的第7位开始是编号为3的桶。这里需要注意,字节序和我们平时的字节序相反,因此需要进行倒置。我们用一张图来说明Redis是如何定位桶并且得到存储的值(即HLL_DENSE_GET_REGISTER函数的解释)。
对于编号为5的桶,我们已经得到了字节偏移_byte和为偏移_fb,b0 >> _fb和b1 << _fb8操作是将字节倒置,然后进行拼接,并且保留最后6位。
稀疏存储结构
你以为Redis真的会用16384个6bit存储每一个HLL对象吗,那就too naive了,虽然它只占用了12K内存,但是Redis对于内存的节约已经到了丧心病狂的地步了。因此,如果比较多的计数值都是0,那么就会采用稀疏存储的结构。
对于连续多个计数值为0的桶,Redis使用的存储方式是:00xxxxxx,前缀两个0,后面6位的值加1表示有连续多少个桶的计数值为0,由于6bit最大能表示64个桶,所以Redis又设计了另一种表示方法:01xxxxxx yyyyyyyy,这样后面14bit就可以表示16384个桶了,而一个初始状态的HyperLogLog对象只需要用2个字节来存储。
如果连续的桶数都不是0,那么Redis的表示方式为1vvvvvxx,即为连续(xx+1)个桶的计数值都是(vvvvv+1)。例如,10011110表示连续3个8。这里使用5bit,最大只能表示32。因此,当某个计数值大于32时,Redis会将这个HyperLogLog对象调整为密集存储。
Redis用三条指令来表达稀疏存储的方式:
-
ZERO:len 单个字节表示 00[len-1],连续最多64个零计数值
-
VAL:value,len 单个字节表示 1[value-1][len-1],连续 len 个值为 value 的计数值
-
XZERO:len 双字节表示 01[len-1],连续最多16384个零计数值
Redis从稀疏存储转换到密集存储的条件是:
-
任意一个计数值从 32 变成 33,因为VAL指令已经无法容纳,它能表示的计数值最大为 32
-
稀疏存储占用的总字节数超过 3000 字节,这个阈值可以通过 hll_sparse_max_bytes 参数进行调整。
源码解析
接下来通过源码来看一下pfadd和pfcount两个命令的具体流程。在这之前我们首先要了解的是HyperLogLog的头结构体和创建一个HyperLogLog对象的步骤。
HyperLogLog头结构体
1struct hllhdr { 2 char magic[4]; /* "HYLL" */ 3 uint8_t encoding; /* HLL_DENSE or HLL_SPARSE. */ 4 uint8_t notused[3]; /* Reserved for future use, must be zero. */ 5 uint8_t card[8]; /* Cached cardinality, little endian. */ 6 uint8_t registers[]; /* Data bytes. */ 7};
创建HyperLogLog对象
1#define HLL_P 14 /* The greater is P, the smaller the error. */ 2#define HLL_REGISTERS (1<<HLL_P) /* With P=14, 16384 registers. */ 3#define HLL_SPARSE_XZERO_MAX_LEN 16384 4 5 6#define HLL_SPARSE_XZERO_SET(p,len) do { \ 7 int _l = (len)-1; \ 8 *(p) = (_l>>8) | HLL_SPARSE_XZERO_BIT; \ 9 *((p)+1) = (_l&0xff); \ 10} while(0) 11 12/* Create an HLL object. We always create the HLL using sparse encoding. 13 * This will be upgraded to the dense representation as needed. */ 14robj *createHLLObject(void) { 15 robj *o; 16 struct hllhdr *hdr; 17 sds s; 18 uint8_t *p; 19 int sparselen = HLL_HDR_SIZE + 20 (((HLL_REGISTERS+(HLL_SPARSE_XZERO_MAX_LEN-1)) / 21 HLL_SPARSE_XZERO_MAX_LEN)*2); 22 int aux; 23 24 /* Populate the sparse representation with as many XZERO opcodes as 25 * needed to represent all the registers. */ 26 aux = HLL_REGISTERS; 27 s = sdsnewlen(NULL,sparselen); 28 p = (uint8_t*)s + HLL_HDR_SIZE; 29 while(aux) { 30 int xzero = HLL_SPARSE_XZERO_MAX_LEN; 31 if (xzero > aux) xzero = aux; 32 HLL_SPARSE_XZERO_SET(p,xzero); 33 p += 2; 34 aux -= xzero; 35 } 36 serverAssert((p-(uint8_t*)s) == sparselen); 37 38 /* Create the actual object. */ 39 o = createObject(OBJ_STRING,s); 40 hdr = o->ptr; 41 memcpy(hdr->magic,"HYLL",4); 42 hdr->encoding = HLL_SPARSE; 43 return o; 44}
这里sparselen=HLL_HDR_SIZE+2,因为初始化时默认所有桶的计数值都是0。其他过程不难理解,用的存储方式是我们前面提到过的稀疏存储,创建的对象实质上是一个字符串对象,这也是字符串命令可以操作HyperLogLog对象的原因。
PFADD命令
1/* PFADD var ele ele ele ... ele => :0 or :1 */ 2void pfaddCommand(client *c) { 3 robj *o = lookupKeyWrite(c->db,c->argv[1]); 4 struct hllhdr *hdr; 5 int updated = 0, j; 6 7 if (o == NULL) { 8 /* Create the key with a string value of the exact length to 9 * hold our HLL data structure. sdsnewlen() when NULL is passed 10 * is guaranteed to return bytes initialized to zero. */ 11 o = createHLLObject(); 12 dbAdd(c->db,c->argv[1],o); 13 updated++; 14 } else { 15 if (isHLLObjectOrReply(c,o) != C_OK) return; 16 o = dbUnshareStringValue(c->db,c->argv[1],o); 17 } 18 /* Perform the low level ADD operation for every element. */ 19 for (j = 2; j < c->argc; j++) { 20 int retval = hllAdd(o, (unsigned char*)c->argv[j]->ptr, 21 sdslen(c->argv[j]->ptr)); 22 switch(retval) { 23 case 1: 24 updated++; 25 break; 26 case -1: 27 addReplySds(c,sdsnew(invalid_hll_err)); 28 return; 29 } 30 } 31 hdr = o->ptr; 32 if (updated) { 33 signalModifiedKey(c->db,c->argv[1]); 34 notifyKeyspaceEvent(NOTIFY_STRING,"pfadd",c->argv[1],c->db->id); 35 server.dirty++; 36 HLL_INVALIDATE_CACHE(hdr); 37 } 38 addReply(c, updated ? shared.cone : shared.czero); 39}
PFADD命令会先判断key是否存在,如果不存在,则创建一个新的HyperLogLog对象;如果存在,会调用isHLLObjectOrReply()函数检查这个对象是不是HyperLogLog对象,检查方法主要是检查魔数是否正确,存储结构是否正确以及头结构体的长度是否正确等。
一切就绪后,才可以调用hllAdd()函数添加元素。hllAdd函数很简单,只是根据存储结构判断需要调用hllDenseAdd()函数还是hllSparseAdd()函数。
密集存储结构只是比较新旧计数值,如果新计数值大于就计数值,就将其替代。
而稀疏存储结构要复杂一些:
-
判断是否需要调整为密集存储结构,如果不需要则继续进行,否则就先调整为密集存储结构,然后执行添加操作
-
我们需要先定位要修改的字节段,通过循环计算每一段表示的桶的范围是否包括要修改的桶
-
定位到桶后,如果这个桶已经是VAL,并且计数值大于当前要添加的计数值,则返回0,如果小于当前计数值,就进行更新
-
如果是ZERO,并且长度为1,那么可以直接把它替换为VAL,并且设置计数值
-
如果不是上述两种情况,则需要对现有的存储进行拆分
PFCOUNT命令
1/* PFCOUNT var -> approximated cardinality of set. */ 2void pfcountCommand(client *c) { 3 robj *o; 4 struct hllhdr *hdr; 5 uint64_t card; 6 7 /* Case 1: multi-key keys, cardinality of the union. 8 * 9 * When multiple keys are specified, PFCOUNT actually computes 10 * the cardinality of the merge of the N HLLs specified. */ 11 if (c->argc > 2) { 12 uint8_t max[HLL_HDR_SIZE+HLL_REGISTERS], *registers; 13 int j; 14 15 /* Compute an HLL with M[i] = MAX(M[i]_j). */ 16 memset(max,0,sizeof(max)); 17 hdr = (struct hllhdr*) max; 18 hdr->encoding = HLL_RAW; /* Special internal-only encoding. */ 19 registers = max + HLL_HDR_SIZE; 20 for (j = 1; j < c->argc; j++) { 21 /* Check type and size. */ 22 robj *o = lookupKeyRead(c->db,c->argv[j]); 23 if (o == NULL) continue; /* Assume empty HLL for non existing var.*/ 24 if (isHLLObjectOrReply(c,o) != C_OK) return; 25 26 /* Merge with this HLL with our 'max' HHL by setting max[i] 27 * to MAX(max[i],hll[i]). */ 28 if (hllMerge(registers,o) == C_ERR) { 29 addReplySds(c,sdsnew(invalid_hll_err)); 30 return; 31 } 32 } 33 34 /* Compute cardinality of the resulting set. */ 35 addReplyLongLong(c,hllCount(hdr,NULL)); 36 return; 37 } 38 39 /* Case 2: cardinality of the single HLL. 40 * 41 * The user specified a single key. Either return the cached value 42 * or compute one and update the cache. */ 43 o = lookupKeyWrite(c->db,c->argv[1]); 44 if (o == NULL) { 45 /* No key? Cardinality is zero since no element was added, otherwise 46 * we would have a key as HLLADD creates it as a side effect. */ 47 addReply(c,shared.czero); 48 } else { 49 if (isHLLObjectOrReply(c,o) != C_OK) return; 50 o = dbUnshareStringValue(c->db,c->argv[1],o); 51 52 /* Check if the cached cardinality is valid. */ 53 hdr = o->ptr; 54 if (HLL_VALID_CACHE(hdr)) { 55 /* Just return the cached value. */ 56 card = (uint64_t)hdr->card[0]; 57 card |= (uint64_t)hdr->card[1] << 8; 58 card |= (uint64_t)hdr->card[2] << 16; 59 card |= (uint64_t)hdr->card[3] << 24; 60 card |= (uint64_t)hdr->card[4] << 32; 61 card |= (uint64_t)hdr->card[5] << 40; 62 card |= (uint64_t)hdr->card[6] << 48; 63 card |= (uint64_t)hdr->card[7] << 56; 64 } else { 65 int invalid = 0; 66 /* Recompute it and update the cached value. */ 67 card = hllCount(hdr,&invalid); 68 if (invalid) { 69 addReplySds(c,sdsnew(invalid_hll_err)); 70 return; 71 } 72 hdr->card[0] = card & 0xff; 73 hdr->card[1] = (card >> 8) & 0xff; 74 hdr->card[2] = (card >> 16) & 0xff; 75 hdr->card[3] = (card >> 24) & 0xff; 76 hdr->card[4] = (card >> 32) & 0xff; 77 hdr->card[5] = (card >> 40) & 0xff; 78 hdr->card[6] = (card >> 48) & 0xff; 79 hdr->card[7] = (card >> 56) & 0xff; 80 /* This is not considered a read-only command even if the 81 * data structure is not modified, since the cached value 82 * may be modified and given that the HLL is a Redis string 83 * we need to propagate the change. */ 84 signalModifiedKey(c->db,c->argv[1]); 85 server.dirty++; 86 } 87 addReplyLongLong(c,card); 88 } 89}
如果要计算多个HyperLogLog的基数,则需要将多个HyperLogLog对象合并,这里合并方法是将所有的HyperLogLog对象合并到一个名为max的对象中,max采用的是密集存储结构,如果被合并的对象也是密集存储结构,则循环比较每一个计数值,将大的那个存入max。如果被合并的是稀疏存储,则只需要比较VAL即可。
如果计算单个HyperLogLog对象的基数,则先判断对象头结构体中的基数缓存是否有效,如果有效,可直接返回。如果已经失效,则需要重新计算基数,并修改原有缓存,这也是PFCOUNT命令不被当做只读命令的原因。
结语
最后,给大家推荐一个帮助理解HyperLogLog原理的工具:http://content.research.neustar.biz/blog/hll.html,有兴趣的话可以去学习一下。
英文比较好的同学可以直接点击 阅读原文 阅读antirez的关于HyperLogLogd 博客。
以上所述就是小编给大家介绍的《走近源码:神奇的 HyperLogLog》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- 走近源码:Redis 的启动过程
- 走近源码:Redis 如何执行命令
- 走近源码:Redis跳跃列表究竟怎么跳
- 走近源码:Redis命令执行过程(客户端)
- 走近源码:Redis 命令执行过程(客户端)
- 走近监控系统的神经中枢
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Web Data Mining
Bing Liu / Springer / 2011-6-26 / CAD 61.50
Web mining aims to discover useful information and knowledge from Web hyperlinks, page contents, and usage data. Although Web mining uses many conventional data mining techniques, it is not purely an ......一起来看看 《Web Data Mining》 这本书的介绍吧!