Redis HyperLogLog
HyperLogLog
应用场景
现开发维护一个大型的网站,需要统计每个网页每天的UV和PV数据,现在需要你来开发这个统计模块,你会如何实现?
如果统计PV非常好办,给每个网页设置一个独立的Redis计数器,这个计数器的key加上当天的日期,这样请求一次,incrby一次,这样最终可以统计出所有的PV数据。
但是UV不一样,它要去重,同一用户一天之内的多次访问,只能计数一次,这就要求每个网页请求都需要带上用户的ID,无论是登陆用户还是未登录用户都需要各一个唯一的ID标识。
或许你想到一个简单的方案,那就是每个页面一个独立的set集合存储所有的当天访问此页面的用户ID。当一个请求过来时,我们使用sadd将用户的ID塞进去,通过scard可以取出这个集合的大小,这个数字就是这个页面的UV数据。没错,这是一个非常简单的方案。
但是,如果这个页面访问量非常大,如果一个爆款页面几千万的UV,这时需要一个很大的set集合来统计,这就非常浪费空间了。如果这样的页面很多,那需要的的存储空间是惊人的。为这样的一个去重功能浪费这么多的存储空间值得么?其实老板需要的数据又不需要太精确,105w和106w这两个数字对老板来说没有太大的区别,因此有没有更好的解决方案呢?
Redis提供了HyperfLogLog数据结构就是用来专门解决这类统计问题的。HeperLogLog提供不精确的去重计数方案,标准误差在0.81%,这样精度的已经可以满足上面的UV统计需求了。
使用方法
HeperfLogLog提供了两个指令pfadd和pfcount,从字面意义很好理解,一个是增加计数,一个是获取计数。pfadd用法和集合set的sadd的用法一样,来一个用户ID,直接将用户ID塞进去就是。 pfcount和scard的用法一样,直接获取计数。
127.0.0.1:6379> pfadd codehole user1
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 1
127.0.0.1:6379> pfadd codehole user2
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 2
127.0.0.1:6379> pfadd codehole user3
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 3
127.0.0.1:6379> pfadd codehole user4
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 4
127.0.0.1:6379> pfadd codehole user5
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 5
127.0.0.1:6379> pfadd codehole user6
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 6
127.0.0.1:6379> pfadd codehole user7 user8 user9 user10
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 10
pfadd 这个 pf是什么意思呢?
它是HyperLogLog这个数据结构的发明人 Philippe Flajolet的首字母缩写。
pfmerge 适合什么场合用?
HyperLogLog提供了第三个指令pfmerge,用于将多个pf计数值累加在一起形成一个新的pf值。
比如在某个网站中我们有两个内容差不多的页面,运营说需要这两个页面的数据进行合并。其中页面的UV访问量也需要合并, 那这个时候pfmerge就可以派上用场了。
注意事项
HyperLogLog这个数据结构不是免费的,它是需要占据一定12k的存储空间, 所以它不适合统计单个用户的相关数据。如果你的用户上亿,可以算一算,这个空间成本是非常惊人的,但是相比set存储方案,HyperLogLog所用的空间真的是可以用千斤对比四两来形容。
因为Redis对HyperLogLog的存储进行了优化,在计数比较小时, 它的存储空间采用稀疏矩阵存储,空间占用很小, 仅仅在计数慢慢变大,稀疏矩阵占用空间超过了阀值时才会一次性转变为稠密矩阵,才会占用12k的空间。
HyperLogLog实现原理
给定一系列的随机整数, 我们记录下低位连续零位的最大长度k, 通过这个k值可以估算出随机数的数量。首先不问为什么, 编写代码做一个实验, 观察下随机整数的数量和k值的关系。
import math
import random
# 算低位零的个数
def low_zeros(value):
for i in range(1, 32):
if value >> i << i != value:
break
return i - 1
# 通过随机数记录最大的低位零的个数
class BitKeeper(object):
def __init__(self):
self.maxbits = 0
def random(self):
value = random.randint(0, 2**32 -1)
bits = low_zeros(value)
if bits > self.maxbits:
self.maxbits = bits
class Experiment(object):
def __init__(self, n):
self.n = n
self.keeper = BitKeeper()
def do(self):
for i in range(self.n):
self.keeper.random()
def debug(self):
print(self.n, %.2f % math.log(self.n, 2), self.keeper.maxbits)
for i in range(1000, 100000, 100):
exp = Experiment(i)
exp.do()
exp.debug()
通过这个实验可以发现K和N的对数之间存在显著的线性相关性:
如果N介于2^k 和2^(k+1) 之间, 用这种方式估值的值都等于2^k,这明显是不合理的。这里采用BitKeeper, 然后进行加权估计, 就可以得到一个比较准确的值。
代码中分了1024个桶, 计算平均数使用了调和平均(倒数的平均)。普通的平均法可能因为个别离群值对平均结果产生较大的影响,调和平均可以有效平滑离群值的影响。
pf的内存占用为什么是12k?
我们在上面的算法中使用了1024个桶进行独立计数, 不过在Redis的HyperLogLog实现中用到的是16384个桶, 也就是2^14, 每个桶的maxbits需要6个bits来存储, 最大可以表示maxbits=63, 于是总共占用的内存就是2^14*6/8 = 12k字节。