如果要我们设计一个基于Redis
统计页面UV
的实现方案,可能的实现方案有什么? 大家可能很容易想到的一个方案就是使用Set
对象保存每一个访问页面的用户id
,因为Set
结构天然就支持去重功能,因此使用scard
取出的Set
集合大小即为页面UV
。但是,如果页面UV
非常巨大时,使用Set
结构存储就会非常浪费空间。 Redis
提供了HyperLogLog
数据结构来解决这类统计问题,既节省内存占用,又能保证统计结果在可接受的误差之内。
Redis中的HyperLogLog使用
Redis
中的HyperLogLog
提供了两个指令pfadd
和pfcount
。pfadd
用于增加计数,pfcount
用于获取计数。针对上面的统计页面UV
的场景,当用户访问页面时,就使用pfadd
指令将用户id
添加到HyperLogLog
中,最后执行pfcount
就能获取到页面UV
。
HyperLogLog算法介绍
实际上,HyperLogLog
是一种够提供不精确的去重计的算法。存在以下的特点:
- 能够使用极少的内存来统计巨量的数据,在
Redis
中实现的HyperLogLog
,只需要12K内存就能统计2^64
个数据。 - 计数存在一定的误差,误差率整体较低。标准误差为
0.81%
。 - 误差可以被设置辅助计算因子进行降低。
伯努利试验
伯努利试验是数学概率论中的一部分内容,它的典故来源于抛硬币。
硬币拥有正反两面,每次的上抛至落下,出现正反面的概率都是1/2
。第一次出现正面的概率是1/2
,连续2次出现正面的概率就是1/4
, 连续出现K
次正面的概率就是2^(-k)
。假设一直抛硬币,直到它出现正面为止,我们记录为一次完整的试验,中间可能抛了一次就出现了正面,也可能抛了4次才出现正面。无论抛了多少次,只要出现了正面,就记录为一次试验。这个试验就是伯努利试验。
如果你觉得自己学习效率低,缺乏正确的指导,可以加入资源丰富,学习氛围浓厚的技术圈一起学习交流吧!
[Java架构群]
群内有许多来自一线的技术大牛,也有在小厂或外包公司奋斗的码农,我们致力打造一个平等,高质量的JAVA交流圈子,不一定能短期就让每个人的技术突飞猛进,但从长远来说,眼光,格局,长远发展的方向才是最重要的。
那么对于n
次的伯努利试验,假设每次伯努利试验所经历了的抛掷次数为k
。那么第一次伯努利试验,次数设为k1
,以此类推,第n
次对应的是kn
。其中,对于这n
次伯努利试验中,必然会有一个最大的抛掷次数k_max
。
最终结合极大似然估算的方法,发现在n
和k_max
中存在估算关联:n * 2^(-k_max)=1
,即n = 2^(k_max)
。
估算优化
我们只进行n
次伯努利试验称为一轮实验。当n
足够大的时候,估算的误差率会相对减少,但仍然不够小。 但是如果我们进行更多轮次实验,然后再取每轮的 k_max
的平均数。最终再估算出 n
,这样子的误差就会相对减少很多。下面是LogLog的估算公式:
上面公式的DVLL
对应的就是n
,constant
是修正因子,它的具体值是不定的,可以根据实际情况而分支设置。m
代表的是试验的轮数。头上有一横的R
就是平均数:(k_max_1 + ... + k_max_m)/m
。
这种通过增加试验轮次,再取k_max
平均数的算法优化就是LogLog的做法。而 HyperLogLog
和LogLog
的区别就是,它采用的不是平均数,而是调和平均数。调和平均数比平均数的好处就是不容易受到大的数值的影响。下面举个例子:
程序实现
通过上面的内容我们已经知道,在抛硬币的例子中,可以通过一次伯努利试验中出现的k_max
来估算n
。
将数据转换为比特串
通过hash
函数,将数据转换为比特串,例如输入5
,可以转换为为:101
。这么做的目的是和抛硬币对上,在比特串中,0
代表反面,1
代表正面。如果一个数据最终被转化了10010000
,那么从右往左看,我们可以认为,首次出现1
的时候,就是正面。基于上面的估算结论,我们可以根据存入数据中,转化后的出现了1
的最大的位置k_max
来估算存入了多少数据,即n = 2^(k_max)
。
分桶
分桶就是分多少轮。具体到程序实现上,就是将输入数据hash
转换为比特串的时候,先确定桶号,然后再将第一个出现1
的个位数值设置到该桶中。
Redis中的HyperLogLog实现
在Redis
中的HyperLogLog
中,共分为2^14
个桶,每个桶有6
个bit
,占用内存为=2^14*6/8/1024=12K
。
当我们执行命令pfadd key value
时,首先会通过hash
函数将value
转换为64
位比特串。其中,前14位用来确定分桶(刚好有2^14
个桶),后50
位用来获取第一个出现1
的位数(从右向左),因为最大为50
,因此使用6
个bit
完全可以表示。
不同的value
,会被设置到不同桶中去,如果出现了在同一个桶的,但是后面第一个出现1
的位数不同。那么比较这个桶新的值是否比原来的值大,如果大于,则替换。否则,保持不变。
最终,2^14
个桶都保存了出现1
的最大值k_max
,此时调用pfcount
时,按照前面介绍的估算方式,便可以计算出key
的设置了多少次valu
,也就是统计值,即2^14*2^(k_max平均)
。
最后
给大家分享一篇一线开发大牛整理的java高并发核心编程神仙文档,里面主要包含的知识点有:多线程、线程池、内置锁、JMM、CAS、JUC、高并发设计模式、Java异步回调、CompletableFuture类等。
码字不易,如果觉得本篇文章对你有用的话,请给我一键三连!关注作者,后续会有更多的干货分享,请持续关注!