引入
之前的文章Java架构直通车——点赞功能用Mysql还是Redis?一文中,我们介绍了分别从mysql和redis实现点赞功能统计的可行性。
这里要介绍一个HyperLogLog算法,虽然应用场景不同,但是两者还是具有一定的相似之处的。
HyperLogLog 是最早由 Flajolet 及其同事在 2007 年提出的一种 估算基数的近似最优算法。但跟原版论文不同的是,好像很多书包括 Redis 作者都把它称为一种 新的数据结构(new datastruct) (算法实现确实需要一种特定的数据结构来实现)。
什么是基数统计
思考这样的一个场景: 如果你负责开发维护一个大型的网站,有一天老板找产品经理要网站上每个网页的 UV(独立访客,每个用户每天只记录一次) ,然后让你来开发这个统计模块,你会如何实现?
如果统计 PV(浏览量,用户每点一次记录一次) ,那非常好办,给每个页面配置一个独立的 Redis 计数器就可以了,把这个计数器的 key 后缀加上当天的日期。这样每来一个请求,就执行 INCRBY 指令一次,最终就可以统计出所有的 PV 数据了。
但是 UV 不同,它要去重,同一个用户一天之内的多次访问请求只能计数一次。这就要求了每一个网页请求都需要带上用户的 ID,无论是登录用户还是未登录的用户,都需要一个唯一 ID 来标识。
你也许马上就想到了一个 简单的解决方案:那就是 为每一个页面设置一个独立的 set 集合 来存储所有当天访问过此页面的用户 ID(之前redis的方案不就是使用set或者hash来保存的嘛)。
如果采用set:
- 存储空间巨大: 如果网站访问量一大,你需要用来存储的 set 集合就会非常大,如果页面再一多… 为了一个去重功能耗费的资源就可以直接让你 老板打死你;
- 统计复杂: 如果多个 set 集合如果要聚合统计一下,又是一个复杂的事情;
实现一个简单的统计UV的功能就要耗费这么大的空间和时间,得不偿失。所以可以采用统计学的方法,并不是像点赞功能那样要精确到某个人是否真正点了赞,可以 允许有误差 !
基数统计的常用方法
- 第一种:B 树
B 树最大的优势就是插入和查找效率很高,如果用 B 树存储要统计的数据,可以快速判断新来的数据是否存在,并快速将元素插入 B 树。要计算基础值,只需要计算 B 树的节点个数就行了。
不过将 B 树结构维护到内存中,能够解决统计和计算的问题,但是 并没有节省内存。
- 第二种:bitmap
bitmap 可以理解为通过一个 bit 数组来存储特定数据的一种数据结构,每一个 bit 位都能独立包含信息,bit 是数据的最小存储单位,因此能大量节省空间,也可以将整个 bit 数据一次性 load 到内存计算。
如果定义一个很大的 bit 数组,基础统计中 每一个元素对应到 bit 数组中的一位,例如:bit数组 001101001001101001代表实际数组[2,3,5,8]。新加入一个元素,只需要将已有的bit数组和新加入的数字做按位或 (or)(or)计算。bitmap中1的数量就是集合的基数值。没错就和我们之前的布隆过滤器差不多的思想,是一种不准确的统计。
bitmap对多个结果求异或可以大大减少存储内存。可以简单做一个计算,如果要统计 1 亿 个数据的基数值,大约需要的内存:100_000_000/ 8/ 1024/ 1024 ≈ 12 Mb
HyperLogLog原理
HyperLogLog 的表现是惊人的,用 bitmap 存储 1 个亿 统计数据大概需要 12 M 内存,而在 HyperLoglog 中,只需要不到 1 K 内存就能够做到!在 Redis 中实现的 HyperLoglog 也只需要 12 K 内存,在 标准误差 0.81% 的前提下,能够统计 264 个数据!
首先,我们来思考一个抛硬币的游戏:你连续掷 n 次硬币,然后说出其中 连续 掷为正面的最大次数,我来猜你一共抛了多少次。
比如说,你说你这一次 最多连续出现了 2 次 正面,那么我就可以知道你这一次投掷的次数并不多,所以 我可能会猜是 5 或者是其他小一些的数字,但如果你说你这一次 最多连续出现了 20 次 正面,虽然我觉得不可能,但我仍然知道你花了特别多的时间,所以 我说 GUN…。
我们知道连续出现20次正面的可能性是 ( 1 2 ) 20 (\frac{1}{2})^{20} (21)20。
这期间我可能会要求你 重复实验 ,然后我得到了更多的数据之后就会估计得更准。我们来把刚才的游戏换一种说法:
这张图的意思是,我们给定一系列的随机整数,记录下低位连续零位的最大长度maxbit,当maxbit=16的时候,n是多少?
换句话说,可以通过maxbit获知n的值大概是多少。
具体方式,可以写代码来估算下。
import java.util.Random;
/**
* Author: leesanghyuk
* Date: 2020-06-29 09:16
* Description:
*/
public class Solution {
class PFBitMap{
private int maxbit;//最低位连续长度
private int n;//统计n次
public PFBitMap(int n) {
this.n = n;
}
/**
* 获取在n次实验统计下,maxbit与n的关系
*/
public void getPFResult(){
for (int i = 0; i < n; i++) {
random();//产生一个随机数
}
System.out.printf("%d %.2f %d\n",
n, Math.log(n) / Math.log(2), maxbit);
}
/**
* 产生一个随机值
*/
private void random() {
long value = new Random().nextLong();
int bit = lowZeros(value);
if (bit > this.maxbit) {
this.maxbit = bit;
}
}
/**
* 统计低位连续的零的个数
*/
private int lowZeros(long value) {
int i = 0;
for (; i < 32; i++) {
if (value >> i << i != value) {
break;
}
}
return i - 1;
}
}
public static void main(String[] args) {
for (int i=1000;i<100000;i+=5000){
PFBitMap pfBitMap=new Solution().new PFBitMap(i);
pfBitMap.getPFResult();
}
}
}
输出为:
1000 9.97 11
6000 12.55 13
11000 13.43 13
16000 13.97 13
21000 14.36 15
26000 14.67 16
31000 14.92 17
36000 15.14 15
41000 15.32 17
46000 15.49 16
51000 15.64 15
56000 15.77 18
61000 15.90 15
66000 16.01 15
71000 16.12 19
76000 16.21 14
81000 16.31 15
86000 16.39 17
91000 16.47 16
96000 16.55 17
经过统计,maxbit 和 n 的对数之间存在显著的线性相关性:n 约等于 2 m a x b i t 2^{maxbit} 2maxbit
再近一步:分桶平均
如果 N 介于 2k 和 2k+1 之间,用这种方式估计的值都等于 2k,这明显是不合理的,所以我们可以使用多个 PFBitMap 进行加权估计。其中的Random函数改成多线程的ThreadLocalRandom.current().nextLong(1L << 32);
即可,然后求加权平均。
这个过程有点 类似于选秀节目里面的打分,一堆专业评委打分,但是有一些评委因为自己特别喜欢所以给高了,一些评委又打低了,所以一般都要 屏蔽最高分和最低分,然后 再计算平均值,这样的出来的分数就差不多是公平公正的了。
观察脚本的输出,误差率百分比控制在个位数:
100000 94274.94 0.06
200000 194092.62 0.03
300000 277329.92 0.08
400000 373281.66 0.07
500000 501551.60 0.00
600000 596078.40 0.01
700000 687265.72 0.02
800000 828778.96 0.04
900000 944683.53 0.05
更近一步:真实的HyperLogLog
真实的 HyperLogLog 要比上面的算法更加复杂一些,也更加精确一些。但是原理已经再之前说清楚了。
有一个神奇的网站,可以动态地让你观察到 HyperLogLog 的算法到底是怎么执行的。
Redis 中的 HyperLogLog 实现提供了两个指令 PFADD
和 PFCOUNT
,字面意思就是一个是增加,另一个是获取计数。PFADD 和 set 集合的 SADD 的用法是一样的,来一个用户 ID,就将用户 ID 塞进去就是,PFCOUNT 和 SCARD 的用法是一致的,直接获取计数值:
> PFADD codehole user1
(interger) 1
> PFCOUNT codehole
(integer) 1
我们可以用 Java 编写一个脚本来试试 HyperLogLog 的准确性到底有多少:
public class JedisTest {
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
jedis.pfadd("codehole", "user" + i);
}
long total = jedis.pfcount("codehole");
System.out.printf("%d %d\n", 100000, total);
jedis.close();
}
}
结果输出如下:
100000 99723