是因为要和抛硬币对应上,比特串中,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,也就是统计值。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!
由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新