Redis - HyperLogLog

179 阅读10分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第14天,点击查看活动详情

问题:如果负责开发和维护一个大型的网站,有一个产品经理需要每个网页的UV(用户访问量),应该如何实现?

  • 我们可以借助Redis提供的Set数据结构来处理,但是随着用户量的增长,内存大占用也随着变大。而我们对这种UV的数据是可以有一定容忍度,让其可以有一定误差。
  • Redis提供的HyperLogLog(简称:HLL)是一种概率数据结构,用于对独特事物进行计数。HLL提供不精确的去重计数方案,误差在小于1%。该算法的神奇之初在于不需要使用与计数量成正比的内存量,而是使用恒定的内存。最坏的情况下为12K

概念

Redis 中的 HLL,虽然在技术上是一种不同的数据结构,但被编码为 Redis 字符串,因此您可以调用 GET 来序列化 HLL,并调用SET将其反序列化回服务器。

从概念上讲,HLL API 就像使用 Sets 来完成相同的任务。您会将 每个观察到的元素 SADD到一个集合中,并使用SCARD检查集合内的元素数量,这是唯一的,因为SADD不会重新添加现有元素。

使用SETHLL数据比较

数据结构1天1月1年
SET80M2.4G28G
HLL15K450K5M

命令

HLL提供了3个命令:pfaddpfcountpfmerge

# PFADD key element [element ...]
# 添加指定元素到 HyperLogLog 中。
> pfadd hll a b c d
(integer) 1
# PFCOUNT key [key ...]
# 返回给定 HyperLogLog 的基数估算值。
> pfcount hll
(integer) 4
# PFMERGE destkey sourcekey [sourcekey ...]
# 将多个 HyperLogLog 合并为一个 HyperLogLog
# 不常用

使用算法

HLL基于概率论中伯努利试验并结合了极大似然估算方法,并做了分桶优化。

伯努利试验

伯努利试验是数学概率论中的一部分内容,它的典故来源于抛硬币

硬币拥有正反两面,一次的上抛至落下,最终出现正反面的概率都是 50%。假设一直抛硬币,直到它出现正面为止,我们记录为一次完整的试验,可能抛了一次就出现了正面,也可能抛了四次才出现正面。无论抛了多少次,只要出现了正面,就记录为一次试验。这个试验就是伯努利试验

假设我们进行了n次伯努利试验,就意味着出现了n次正面。假设每次抛掷次数为k,第一次为k1,第n次为kn。对于这n次试验,必然会有一个最大的抛掷次数k,例如抛了12次才出现正面,那么称这个为k_max,代表抛了最多的次数。则可以得出以下结论:

  • n 次伯努利过程,投掷次数都小于或等于 k_max
  • n 次伯努利过程,至少有一次投掷次数等于 k_max

极大似然原理的直观想法是:一个随机试验如有若干个可能的结果A,B,C,…。若在一次试验中,结果A出现,则一般认为试验条件对A出现有利,也即A出现的概率很大。

最终结合极大似然估算的方法,发现在nk_max中存在估算关联:n = 2^(k_max) 。这种通过局部信息预估整体数据流特性的方法似乎有些超出我们的基本认知,需要用概率和统计的方法才能推导和验证这种关联关系。

  • 第一次试验: 抛了3次才出现正面,此时 k=3,n=1
  • 第二次试验: 抛了2次才出现正面,此时 k=2,n=2
  • 第三次试验: 抛了6次才出现正面,此时 k=6,n=3

假设上面例子中实验组数共 3组,那么 k_max = 6,最终 n=3,我们放进估算公式中去,明显: 3 ≠ 2^6 。也即是说,当试验次数很小的时候,这种估算方法的误差是很大的。

估算的优化

在上面的3组例子中,我们称为一轮的估算。如果只是进行一轮的话,当 n 足够大的时候,估算的误差率会相对减少,但仍然不够小。

那么是否可以进行多轮呢?例如进行 100 轮或者更多轮次的试验,然后再取每轮的 k_max,再取平均数,即: k_mx/100。最终再估算出 n。下面是LogLog的估算公式:

image.png

上面公式的DVLL对应的就是nconstant是修正因子,它的具体值是不定的,可以根据实际情况而分支设置。m代表的是试验的轮数。头上有一横的R就是平均数:(k_max_1 + ... + k_max_m)/m

这种通过增加试验轮次,再取k_max平均数的算法优化就是LogLog的做法。而 HyperLogLogLogLog的区别就是,它采用的不是平均数,而是调和平均数调和平均数平均数的好处就是不容易受到大的数值的影响。下面举个例子:

A的是1000/月,B30000/月。
采用平均数的方式就是: (1000 + 30000) / 2 = 15500
采用调和平均数的方式就是: 2/(1/1000 + 1/30000) ≈ 1935.484

调和平均数计算方式:

image.png

实际计算运行方式

  • 比特串

    通过hash函数,将数据转为比特串,例如输入5,便转为:101。为什么要这样转化呢?

    是因为要和抛硬币对应上,比特串中,0 代表了反面,1 代表了正面,如果一个数据最终被转化了 10010000,那么从右往左,从低位往高位看,我们可以认为,首次出现 1 的时候,就是正面。

    那么基于上面的估算结论,我们可以通过多次抛硬币实验的最大抛到正面的次数来预估总共进行了多少次实验,同样也就可以根据存入数据中,转化后的出现了 1 的最大的位置 k_max 来估算存入了多少数据。

  • 分桶

    分桶就是分多少轮。抽象到计算机存储中去,就是存储的是一个以单位是比特(bit),长度为 L 的大数组 S ,将 S 平均分为 m 组,注意这个 m 组,就是对应多少轮,然后每组所占有的比特个数是平均的,设为 P。容易得出下面的关系:

L = S.length
L = m * p
以 K 为单位,S 占用的内存 = L / 8 / 1024
在 Redis 中,HyperLogLog设置为:
m=16834,p=6,L=16834 * 6。占用内存为=16834 * 6 / 8 / 1024 = 12K
形象化为:

   第0组     第1组                       .... 第16833组

[000 000] [000 000] [000 000] [000 000] .... [000 000]
  • 对应

    现在回到我们的原始页面统计用户的问题中去。

设主页的 key 为: main
用户 id 为:idn , n->0,1,2,3....

在这个统计问题中,不同的用户 id 标识了一个用户,那么我们可以把用户的 id 作为被hash的输入。即:
hash(id) = 比特串
不同的用户 id,必然拥有不同的`比特串`。每一个`比特串`,也必然会至少出现一次 1 的位置。我们类比每一个`比特串`为一次`伯努利试验`。

现在要`分轮`,也就是`分桶`。所以我们可以设定,每个`比特串`的前多少位转为10进制后,其值就对应于所在桶的标号。假设`比特串`的低两位用来计算桶下标志,此时有一个用户的id的`比特串`是:1001011000011。它的所在桶下标为:`11(2) = 1*2^1 + 1*2^0 = 3`,处于第3个桶,即第3轮中。

上面例子中,计算出桶号后,剩下的`比特串`是:10010110000,从低位到高位看,第一次出现 1 的位置是 5 。也就是说,此时第3个桶,第3轮的试验中,`k_max = 5`5 对应的二进制是:101,又因为每个桶有 p 个比特位。当 p>=3 时,便可以将 101 存进去。

模仿上面的流程,多个不同的用户 id,就被分散到不同的桶中去了,且每个桶有其 k_max。然后当要统计出 `mian` 页面有多少用户点击量的时候,就是一次估算。最终结合所有桶中的 k_max,代入估算公式,便能得出估算值。

下面是 `HyperLogLog` 的结合了调和平均数的估算公式,变量释意和`LogLog`的一样:


Redis 中的 HyperLogLog 原理

前面我们已经认识到,它的实现中,设有 16384 个桶,即:2^14 = 16384,每个桶有 6 位,每个桶可以表达的最大数字是:63 ,二进制为: 111 111

对于命令:pfadd key value

在存入时,value 会被 hash 成 64 位,即 64 bit 的比特字符串,前 14 位用来选择这个 value 的比特串中从右往左第一个 1 出现的下标位置数值要存到那个桶中去,即前 14 位用来分桶。设第一个1出现位置的数值为 index 。当 index=5 时,就是: ....10000 [01 0000 0000 0000]

之所以选 14位 来表达桶编号是因为,分了 16384 个桶,而 2^14 = 16384,刚好地,最大的时候可以把桶利用完,不造成浪费。假设一个字符串的前 14 位是:00 0000 0000 0010 (从右往左看) ,其十进制值为 2。那么 index 将会被转化后放到编号为 2 的桶。

index 的转化规则:

首先因为完整的 value 比特字符串是 64 位形式,减去 14 后,剩下 50 位,那么极端情况,出现 1 的位置,是在第 50 位,即位置是 50。此时 index = 50。此时先将 index 转为 2 进制,它是:110010 。

因为16384 个桶中,每个桶是 6 bit 组成的。刚好 110010 就被设置到了第 2 号桶中去了。请注意,50 已经是最坏的情况,且它都被容纳进去了。那么其他的不用想也肯定能被容纳进去。

因为 fpadd 的 key 可以设置多个 value。例如下面的例子:

pfadd lgh golang

pfadd lgh python

pfadd lgh java

根据上面的做法,不同的 value,会被设置到不同桶中去,如果出现了在同一个桶的,即前 14 位值是一样的,但是后面出现 1 的位置不一样。那么比较原来的 index 是否比新 index 大。是,则替换。否,则不变。

最终地,一个 key 所对应的 16384 个桶都设置了很多的 value 了,每个桶有一个k_max。此时调用 pfcount 时,按照前面介绍的估算方式,便可以计算出 key 的设置了多少次 value,也就是统计值。

value 被转为 64 位的比特串,最终被按照上面的做法记录到每个桶中去。64 位转为十进制就是:2^64,HyperLogLog 仅用了:16384 * 6 /8 / 1024 K 存储空间就能统计多达 2^64 个数。

偏差修正

在估算的计算公式中,constant 变量不是一个定值,它会根据实际情况而被分支设置,例如下面的样子。

假设:m为分桶数,p是m的以2为底的对数。

// m 为桶数

switch (p) {

  case 4:

    constant = 0.673 * m * m;

  case 5:

    constant = 0.697 * m * m;

  case 6:

    constant = 0.709 * m * m;

  default:

    constant = (0.7213 / (1 + 1.079 / m)) * m * m;

}