场景
做服务端的同学,应该都遇到过计数场景,比如我想知道浏览某一个web页面的总人数,总次数;查看某条热门动态的总人数总次数;购买某件商品的总人数总次数;对于总次数我们直接基于计数器累加就能很方便的解决,时间和空间复杂度都不高。而对于总人数,常规思路我们都是基于去重数据结构Set来存储,将所有访问了的用户id就存到到set中,最终获取set集合中的元素个数即为总人数。当平台量不大时可能还好,一旦访问人数过大,这将是一笔不小的存储开销,并且随着人数的不断增多,所需要的存储空间也会不断加大,这就有点头疼了。
redis的HyperLogLog
对于这种场景redis提供了一种数据结构HyperLogLog,最大只需要12KB的存储空间,就能进行去重统计,最大统计数能达到2^64。当然他也有缺点,就是这个统计结果并不绝对精确,存在误差。但是当数量级达到一定程度后,一定的精度损失很多场景其实是可以接受的,比如2021年北京GDP达到4万亿,至于是四万亿零一百万还是四万亿零两百万,对于数据整体的观测来说其实是没有影响的。
HyperLogLog的使用方法
HyperLogLog使用方法很简单,官方提供了两个命令:
## 增加统计值
## eg: pfadd browser_user_ids 140011
pfadd key value
## 获取统计结果
## eg: pfcount browser_user_ids
pfcount key
当我们重复多次pfadd同一个值时对统计结果是不会有影响的,从而达到去重统计的效果。
效果测试
我们通过如下代码尝试进行一万项不重复数据统计,重复三十次:
class RedisTest(): TestBase() {
init {
initRedis()
}
@Test
fun hyperLogLogTest(){
val key = "test"
(1..30).forEach {
coreRedis.del(key)
val list = (1..10000).map {
UnionIdUtil.unionId()
}
coreRedis.pfadd(key, *list.toTypedArray())
println("count="+ coreRedis.pfcount(key))
}
}
}
执行结果:
count=9978
count=9978
count=10068
count=10053
count=9976
count=10055
count=10002
count=9983
count=9945
count=10048
count=10025
count=9931
count=9966
count=10132
count=9948
count=9982
count=9959
count=10011
count=10015
count=10084
基本一万的统计差个几十的样子。
HyperLogLog原理分析
如果大家只是使用,到这里就结束了。但本着知其然,知其所以然的精神,下面我们来聊一下这东西的底层原理。这东西得从抛硬币说起。
伯努利实验
我们都知道抛硬币,每次出现正面或者反面的概率都是50%。假设我们一只抛硬币,知道抛出正面才停下来。我们可能一次就抛到了正面,也可能10次或者更多次才抛到正面,所以我们定义为抛了k次才抛到了正面。而从一开始到抛到一次正面我们称之为一次伯努利试验。假设我们进行了n次伯努利试验,就会出现n个k,如下图:
其中这n个k中有一个值最大的k,我们记为。而上面的和之间存在一定的数学关系,根据概率论的极大似然估算法可以得到:,极大似然估算法本身是基于样本数据进行模型参数推导的过程,具体如何推导需要微积分和概率论这些大学高数知识,都还给老师了所以就不再推导了。不过我们可以通过程序来验证一下,我们尝试进行一百万次伯努利实验:
/**
* 进行伯努利实验
* @param n 进行次数
* @return 最大一次抛到正面的投掷次数 kmax
*/
fun bernoulliTest(n: Long): Long{
//第几次伯努利实验
var nowN = 0L
//最大的k
var maxK = 0L
//本轮伯努利实验抛硬币次数
var flipCoinCnt = 0L
while (nowN < n) {
val random = Random().nextInt(2)
flipCoinCnt++
if (random == 1) {
//表示一次伯努利实验结束
if (maxK < flipCoinCnt) {
maxK = flipCoinCnt
}
flipCoinCnt = 0
nowN++
}
}
return maxK
}
@Test
fun testBernoulli(){
println(bernoulliTest(1000000L))
}
得到了。我们可以去校验一下其实会发现
19,22,25,20,22,21,20,20,21,20,21,25,25,19,22,20,20,21,21,22,21,20,19,20,23,18,22,22,19,22,19,20,22,22,20,20,26,22,19,22,24,20,20,25,25,21,20,22,24,22
会发现每次得到的也都不一致。这个一方面是因为概率论下得到的结论本身需要趋于无穷大的样本下才能得到正确的结果,比如我们说跑硬币每次正反面的概率都是50%,但是你们连续抛两次正面是很正常的,因为在小样本面前概率论的公式是不正确的;另一方面是基于极大似然估算得到的公式本身也存在精度问题。所以我们一是需要增加样本数量,二是本身需要做精度优化。
公式优化一
一种常见的优化方式如下,对于单次实验精度不足的问题,采用多次实验求平均。另外增加一个常系数对等式关系进行补偿,我们根据这个思路可以得到新的公式:
A为增加的常数,m表示进行了m轮,表示m轮每一轮的的平均值。带入上面的数据,我们将50组数据求平均,可以得到m = 50, = 21.34, n = 1000000,可以求出A = 0.00753442108
我们基于这常数A可以对其他数据进行验算:
val a = 0.00753442108
(1..20).forEach {
var cnt = 1000000L * (it % 5 + 1)
val list = (1..50).map {
bernoulliTest(cnt)
}
val value = a * 50 * Math.pow(2.0, list.sum().toDouble() / list.size)
println("伯努利试验次数=${cnt},估计值:${value}")
}
得到以下结果:
伯努利试验次数=2000000,估计值:1433955.2479701438
伯努利试验次数=3000000,估计值:3680750.602382236
伯努利试验次数=4000000,估计值:4723970.64556762
伯努利试验次数=5000000,估计值:3630076.6211529947
伯努利试验次数=1000000,估计值:999999.9999681418
伯努利试验次数=2000000,估计值:2143546.9250042993
伯努利试验次数=3000000,估计值:3073750.362478103
伯努利试验次数=4000000,估计值:4658934.345725395
伯努利试验次数=5000000,估计值:4346939.44996575
伯努利试验次数=1000000,估计值:858565.4364104022
伯努利试验次数=2000000,估计值:1866065.9830141638
伯努利试验次数=3000000,估计值:3434261.7456416087
伯努利试验次数=4000000,估计值:4346939.44996575
伯努利试验次数=5000000,估计值:4469148.552146502
伯努利试验次数=1000000,估计值:812252.3963303582
伯努利试验次数=2000000,估计值:1999999.9999362836
伯努利试验次数=3000000,估计值:2496661.097723685
伯努利试验次数=4000000,估计值:3784230.5867818296
伯努利试验次数=5000000,估计值:5351710.218973959
伯努利试验次数=1000000,估计值:882702.9962625337
其实可以看到实际值和估计值也已经有几分像样了,不过精度上差距还是比较大。
公式优化二
第一个优化是基于平均数来的,而HyperLogLog实际采用的是调和平均,调和平均可以解决一些异常大值对整体的影响,比如我赚1000一个月,我朋友赚2000一个月,马月赚100000000一个月,直接平均的话我们人均月薪33334333,而调和平均是: = 1999.98 。这样能有效解决马云的工资对我们平均工资的巨大影响。根据上面的距离我们可以总结出调和平均的计算公式:
总体的优化公式如下:
可以看到他是对整体做的调和平均,并不是对做调和平均,也很好理解,单单一个其实差距不大,但是指数之后来去就大了,所以对指数计算后的结果做调和平均比有效的多。
我们同样可以根据这个公式,结合上面的50组数据计算出A = 0.014179944992065428,然后我们可以根据这个A再对其他数据进行验算.
伯努利试验次数=2000000,估计值:1644659.522986519
伯努利试验次数=3000000,估计值:2830458.0606781673
伯努利试验次数=4000000,估计值:4492387.58409064
伯努利试验次数=5000000,估计值:5102199.137100489
伯努利试验次数=1000000,估计值:881764.2698295031
伯努利试验次数=2000000,估计值:1813759.0088748583
伯努利试验次数=3000000,估计值:3532293.98663697
伯努利试验次数=4000000,估计值:3645278.68224478
伯努利试验次数=5000000,估计值:5244419.950399558
伯努利试验次数=1000000,估计值:1109608.2089552237
伯努利试验次数=2000000,估计值:1912379.4212218646
伯努利试验次数=3000000,估计值:2622029.344905972
伯努利试验次数=4000000,估计值:5313232.830820769
伯努利试验次数=5000000,估计值:6993523.494556978
伯努利试验次数=1000000,估计值:961212.121212121
伯努利试验次数=2000000,估计值:2212508.7189025804
伯努利试验次数=3000000,估计值:3309913.0434782603
伯努利试验次数=4000000,估计值:3544794.1888619848
伯努利试验次数=5000000,估计值:3903199.3437243635
伯努利试验次数=1000000,估计值:844440.5004880645
看下数据会发现和上面直接平均的效果差不太多,精度也不是很够。所以我们这里把样本数从50增加到一千试试,一千轮伯努利试验组,每组进行一百万次伯努利试验,得到的一千个
25,24,23,24,22,22,19,25,19,20,21,22,24,21,20,23,20,23,25,20,26,20,22,21,20,19,26,23,22,20,23,19,19,23,22,20,23,20,21,21,22,20,21,20,20,23,22,20,22,23,27,20,22,19,19,23,23,22,20,20,24,20,22,21,23,19,21,25,23,20,20,21,23,21,21,20,24,21,22,20,25,20,22,22,22,21,20,23,20,19,25,22,21,21,26,22,21,20,22,20,20,24,21,21,21,22,22,25,22,20,19,23,18,19,21,20,23,22,20,23,27,21,21,24,23,19,21,24,18,21,20,20,20,23,21,23,20,20,22,21,21,23,20,23,23,19,22,22,20,27,24,22,20,20,19,21,22,23,21,20,19,22,20,20,19,23,24,19,21,19,23,20,22,19,22,20,19,20,21,21,24,19,22,21,20,23,21,20,22,21,21,23,21,22,22,20,27,20,23,21,23,22,19,22,24,23,21,21,19,20,19,27,21,21,22,20,22,20,18,22,21,21,19,21,22,19,22,19,23,24,23,21,19,21,20,20,21,21,21,21,20,19,21,20,22,21,20,24,21,21,24,19,22,26,22,18,20,19,23,23,21,28,23,22,24,21,22,21,25,22,22,21,19,22,23,19,25,19,25,21,20,21,20,23,25,22,19,22,21,22,24,20,19,20,19,21,23,20,21,20,19,22,19,25,21,21,19,20,19,20,20,20,22,21,20,20,20,20,21,24,27,20,21,19,20,18,19,21,21,22,22,21,21,19,23,21,20,21,22,23,23,20,21,20,20,19,18,21,26,19,19,20,18,22,22,20,23,23,19,20,22,21,19,23,20,20,20,19,19,24,23,20,23,21,20,25,21,23,24,21,21,22,19,20,19,21,19,26,20,20,21,22,19,23,19,21,20,19,19,20,20,20,18,22,20,22,21,22,24,27,20,20,20,20,21,21,21,21,21,22,20,20,21,21,24,20,19,23,18,21,21,20,29,19,18,19,21,24,21,20,24,19,21,23,21,22,22,21,20,19,19,22,24,24,20,21,21,21,20,22,19,21,20,20,19,21,21,23,18,19,18,25,23,19,20,20,20,23,22,19,20,19,25,24,22,24,22,20,23,21,21,22,20,21,20,20,19,23,22,20,20,21,24,23,21,21,20,20,20,21,24,21,20,22,21,21,21,20,20,21,19,23,19,26,22,22,23,21,23,20,21,21,20,20,20,23,21,21,20,19,21,21,20,22,21,19,21,23,21,19,27,20,25,21,19,21,20,21,22,24,24,20,22,21,22,21,22,21,21,20,21,19,25,19,22,21,21,22,20,21,23,20,20,21,22,22,27,22,20,21,25,20,22,27,20,22,22,25,20,21,23,23,19,23,26,21,20,20,20,20,21,20,22,22,22,19,19,22,21,21,20,19,21,19,22,20,18,23,25,23,23,21,20,22,23,21,19,21,23,25,22,20,22,20,20,21,24,21,21,29,22,19,24,24,23,21,22,20,21,21,23,21,19,19,23,20,23,20,26,20,22,22,21,19,21,19,21,23,19,20,21,28,21,20,27,21,21,21,26,21,18,19,21,20,21,22,20,22,21,22,22,25,21,20,20,19,20,21,19,21,21,21,20,24,21,23,20,21,21,22,21,22,21,19,21,19,24,20,22,20,20,21,20,22,19,20,21,20,19,21,21,20,24,20,19,20,21,22,19,21,25,22,21,19,23,21,22,19,23,19,21,21,20,23,20,19,24,19,20,20,26,20,22,22,19,22,22,21,27,21,22,21,20,20,21,22,21,23,20,21,20,21,22,19,23,21,23,20,20,21,21,22,21,20,23,18,22,22,24,21,25,25,22,19,22,21,20,19,20,27,24,23,25,23,18,22,21,20,21,21,17,20,21,21,23,19,21,21,20,21,26,22,22,21,22,20,23,22,25,22,20,21,20,21,22,21,23,21,20,23,23,21,21,21,20,21,21,19,21,20,20,23,22,21,19,19,21,24,23,22,23,22,19,23,21,21,23,22,21,20,21,19,20,19,20,23,23,21,20,24,18,19,18,21,20,25,23,21,20,20,20,27,20,21,22,25,22,23,20,20,21,20,20,23,20,21,20,22,18,23,21,22,20,19,19,19,21,22,22,24,22,20,25,21,21,21,23,20,21,22,20,21,21,21,23,20,24,21,23,22,20,21,22,20,21,20,20,23,23,23,19,18,21,19,21,24,22,21,20,20,22,20,24,25,23,18,22,20,19,23,20,20,19,19,27,22,19,20,21,19
我基于这一千个可以重新计算得到 A = 0.014421418309211731。然后我们在把A带进去验算以下其他数据,由于分了一千轮,导致计算速度会很慢,这边改造成了线程池方式,总验算数也从20降到了10,代码如下:
val threadPool = ThreadPoolExecutor(20,100, 1,TimeUnit.MINUTES, LinkedBlockingQueue(20),
ThreadPoolExecutor.CallerRunsPolicy())
@Test
fun testBernoulli2(){
val a = 0.014421418309211731
val countDownLatch = CountDownLatch(10)
(1..10).forEach {
threadPool.execute {
var cnt = 1000000L * (it % 5 + 1)
val list = (1..1000).map {
bernoulliTest(cnt)
}
val value = a * 50 * list.size.toDouble() / list.sumOf { 1.0 / Math.pow(2.0, it.toDouble()) }
println("伯努利试验次数=${cnt},估计值:${value}")
countDownLatch.countDown()
}
}
countDownLatch.await()
}
执行结果如下:
伯努利试验次数=1000000,估计值:993474.5188010976
伯努利试验次数=2000000,估计值:2126125.4581640214
伯努利试验次数=3000000,估计值:2954221.3761083484
伯努利试验次数=2000000,估计值:2014385.0451727884
伯努利试验次数=1000000,估计值:972014.7350666528
伯努利试验次数=3000000,估计值:2948001.6829536217
伯努利试验次数=2000000,估计值:1960978.6289981091
伯努利试验次数=1000000,估计值:972128.3891155305
伯努利试验次数=3000000,估计值:3071573.320744116
伯努利试验次数=2000000,估计值:1946117.8199038433
可以看到当我们把一千轮作为一个整体时,这个估计值和实际机制就像样了。如果大家有更多的时间或者有更强大的算力,可以试试轮次作为一个样本试试。
伯努利试验和redis去重统计扯上关系
上面我们主要以试验数据去验证和优化这个根据概率论得到的数学公式。而redis的HyperLogLog数据结构就是基于这个公式而来:
根据前面的分析我们知道,A是常数,m是轮次数,都是固定的。假设我们现在要去统计一个网页的总浏览人数,我们每次增加一个用户id,我们类比做了一次伯努利试验,原理是redis将每次加入的用户id会hash成一个64为的比特串:[000000…101…00…1],redis会把比特串的从右往左的14位用来分论次,14位一共就能分 = 16384轮。我们上面从50轮提到1000轮之后,精度上我们发现已经有很大的提升了,而redis这里用了16384轮,那最终得到的结果肯定比我们精度高得多。64位去除右侧的14位后还有50位,因为都是比特,只有0和1就像我们上面抛硬币只有正反面,我们在50位中找到第一次出现1的位置其实就是本次伯努利试验的,我们将这个k记录下来,因为最大也就只能到50,所以我们使用一个6位的比特串就能进行记录(已经大于50了,所以足够记录了),当我们不断的往这个数据结构中增加用户id,每个用户id都会被分到16384轮中的其中一轮,然后得到自己的k,如果自己的比原本保存的k大,则替换成自己的k,否则就不做操作。如此循环往复,我们16384轮次中,每个轮次都会留下自己的。然后我们就可以根据上面那个公式根据反推出总共进行的伯努利试验次数,对应具体场景其实就是总共的用户id数量。
那如何避免的对重复id的累计呢,因为重复的id,会被hash成同一个64位比特串,那最终所在的轮次以及得到的k和原本都是一样的,所以当重复添加的时候是没有任何影响的,从而实现去重统计。
最大内存12KB的由来
上面我们解析出了整个去重统计的过程,其实可以看到具体的内存消耗,我们一共分了16384轮,每一轮需要个6比特来存储,所以我们一共需要的内存是:16384 * 6比特 = 98304比特,那根据计算机内存单位转换关系,8比特 = 1 byte, 1024byte = 1KB。所以 98304 / 8 / 1024 = 12KB。所以就实现了12KB 对 的数据进行基数统计的功能。