在开始这一节之前,我们先思考一个常见的业务问题:如果你负责开发维护一个大型的网站,有一天老板找产品经理要网站每个网页每天的UV数据,然后让你来开发这个统计模块,你会如何实现?
如果统计PV那非常好办,给每个网页一个独立的Redis计数器就可以了,这个计数器的key后缀加上当天的日期。这样来一个请求,incrby一次,最终就可以统计出所有的PV数据。
但是UV不一样,它要去重,同一个用户一天之内的多次访问请求只能计数一次。这就要求每一个网页请求都需要带上用户的ID,无论是登陆用户还是未登陆用户都需要一个唯一ID来标识。
你也许已经想到了一个简单的方案,那就是为每一个页面一个独立的set集合来存储所有当天访问过此页面的用户ID。当一个请求过来时,我们使用sadd将用户ID塞进去就可以了。通过scard可以取出这个集合的大小,这个数字就是这个页面的UV数据。没错,这是一个非常简单的方案。
但是,如果你的页面访问量非常大,比如一个爆款页面几千万的UV,你需要一个很大的set集合来统计,这就非常浪费空间。如果这样的页面很多,那所需要的存储空间是惊人的。为这样一个去重功能就耗费这样多的存储空间,值得么?其实老板需要的数据又不需要太精确,w和w这两个数字对于老板们来说并没有多大区别,So,有没有更好的解决方案呢?
这就是本节要引入的一个解决方案,Redis提供了HyperLogLog数据结构就是用来解决这种统计问题的。HyperLogLog提供不精确的去重计数方案,虽然不精确但是也不是非常不精确,标准误差是0.81%,这样的精确度已经可以满足上面的UV统计需求了。
HyperLogLog数据结构是Redis的高级数据结构,它非常有用,但是令人感到意外的是,使用过它的人非常少。
使用方法
HyperLogLog提供了两个指令pfadd和pfcount,根据字面意义很好理解,一个是增加计数,一个是获取计数。pfadd用法和set集合的sadd是一样的,来一个用户ID,就将用户ID塞进去就是。pfcount和scard用法是一样的,直接获取计数值。
简单试了一下,发现还蛮精确的,一个没多也一个没少。接下来我们使用脚本,往里面灌更多的数据,看看它是否还可以继续精确下去,如果不能精确,差距有多大。人生苦短,我用Python!Python脚本走起来!
当然Java也不错,大同小异,下面是Java版本:
我们来看下输出:
当我们加入第个元素时,结果开始出现了不一致。接下来我们将数据增加到10w个,看看总量差距有多大。
Java版:
跑了约半分钟,我们看输出:
差了个,按百分比是0.%,对于上面的UV统计需求来说,误差率也不算高。然后我们把上面的脚本再跑一边,也就相当于将数据重复加入一边,查看输出,可以发现,pfcount的结果没有任何改变,还是,说明它确实具备去重功能。
pfadd这个pf是什么意思?
它是HyperLogLog这个数据结构的发明人PhilippeFlajolet的首字母缩写,老师觉得他发型很酷,看起来是个佛系教授。
pfmerge适合什么场合用?
HyperLogLog除了上面的pfadd和pfcount之外,还提供了第三个指令pfmerge,用于将多个pf计数值累加在一起形成一个新的pf值。
比如在网站中我们有两个内容差不多的页面,运营说需要这两个页面的数据进行合并。其中页面的UV访问量也需要合并,那这个时候pfmerge就可以派上用场了。
注意事项
HyperLogLog这个数据结构不是免费的,不是说使用这个数据结构要花钱,它需要占据一定12k的存储空间,所以它不适合统计单个用户相关的数据。如果你的用户上亿,可以算算,这个空间成本是非常惊人的。但是相比set存储方案,HyperLogLog所使用的空间那真是可以使用千斤对比四两来形容了。
不过你也不必过于担心,因为Redis对HyperLogLog的存储进行了优化,在计数比较小时,它的存储空间采用稀疏矩阵存储,空间占用很小,仅仅在计数慢慢变大,稀疏矩阵占用空间渐渐超过了阈值时才会一次性转变成稠密矩阵,才会占用12k的空间。
HyperLogLog实现原理
HyperLogLog的使用非常简单,但是实现原理比较复杂,如果读者没有特别的兴趣,下面的内容暂时可以跳过不看。
为了方便理解HyperLogLog的内部实现原理,我画了下面这张图
这张图的意思是,给定一系列的随机整数,我们记录下低位连续零位的最大长度k,通过这个k值可以估算出随机数的数量。首先不问为什么,我们编写代码做一个实验,观察一下随机整数的数量和k值的关系。
Java版:
运行观察输出:
通过这实验可以发现K和N的对数之间存在显著的线性相关性:
如果N介于2^K和2^(K+1)之间,用这种方式估计的值都等于2^K,这明显是不合理的。这里可以采用多个BitKeeper,然后进行加权估计,就可以得到一个比较准确的值。
下面是Java版:
代码中分了个桶,计算平均数使用了调和平均(倒数的平均)。普通的平均法可能因为个别离群值对平均结果产生较大的影响,调和平均可以有效平滑离群值的影响。
观察脚本的输出,误差率控制在百分比个位数:
真实的HyperLogLog要比上面的示例代码更加复杂一些,也更加精确一些。上面的这个算法在随机次数很少的情况下会出现除零错误,因为maxbits=0是不可以求倒数的。
pf的内存占用为什么是12k?
我们在上面的算法中使用了个桶进行独立计数,不过在Redis的HyperLogLog实现中用到的是个桶,也就是2^14,每个桶的maxbits需要6个bits来存储,最大可以表示maxbits=63,于是总共占用内存就是2^14*6/8=12k字节。
思考作业
尝试将一堆数据进行分组,分别进行计数,再使用pfmerge合并到一起,观察pfcount计数值,与不分组的情况下的统计结果进行比较,观察有没有差异。
扩展阅读
HyperLogLog复杂的公式推导请阅读Count-DistinctProblem,如果你的概率论基础不好,那就建议不要看了(另,这个PPT需要翻墙观看)。