硬币拥有正反两面,一次的上抛至落下,最终出现正反面的概率都是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
对应的就是n
,constant
是修正因子,它的具体值是不定的,可以根据实际情况而分支设置。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);
}
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数Go语言工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Go语言全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Golang知识点,真正体系化!
由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新
如果你觉得这些内容对你有帮助,可以添加V获取:vip1024b (备注Go)
一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Golang知识点,真正体系化!**
由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新
如果你觉得这些内容对你有帮助,可以添加V获取:vip1024b (备注Go)
[外链图片转存中…(img-ahfiEZfi-1712985790776)]
一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!