应用场景
当需要对大量数据做去重计数, 例如统计一个页面的UV(Unique Visitor, 独立访客), 或者用户搜索的关键词数量, 比较容易想到的方案有
- 存储到数据库表, 使用distinct count计算
- 使用Redis的set, bitmap等数据结构
但都存在一些问题, 随着数据量增加, 存储空间占用越来越大; 统计速度慢, 性能并不理想
数据分析, 网络监控及数据库优化等领域都会涉及到基数计数的需求.
基数, 一个集合中不重复元素的个数.
目前还没有在大数据场景中准确计算基数的高效算法, 因此在允许一定误差的情况下, 使用概率算法是一个不错的选择
概率算法
概率算法不直接存储数据本身, 而通过一定的概率统计方法预估基数值, 这种方法可以大大节省内存, 同时保证误差控制在一定范围内
目前用于基数计数的概率算法有
- LC(Linear Counting): 早期的基数估计算法, 在空间复杂度方面不算优秀, O ( N m a x ) O(N_{max}) O(Nmax)
- LLC(LogLog Counting): 相比于LC更加节省内存, 空间复杂度只有 O ( l o g 2 ( l o g 2 ( N m a x ) ) ) O(log_2(log_2(N_{max}))) O(log2(log2(Nmax)))
- HLL(HyperLogLog Counting): HLL基于LLC的优化和改进, 在同样空间复杂度情况下, 能够比LLC的基数估计误差更小, 例如Redia中HLL只占12KB, 可以计算接近 2 64 2^{64} 264个不同基数, 标准误差为0.81%
极大似然估计
极大似然估计是建立在极大似然原理的基础上的一个统计方法, 是概率论在统计学中的应用
极大似然估计提供了一种给定观察数据来评估模型参数的方法, 即: 模型已定, 参数未知
通过若干次试验, 观察其结果, 利用试验结果得到某个参数值能够使样本出现的概率为最大, 则称为极大似然估计
目的就是: 利用已知的样本结果, 反推最有可能/最大概率导致这样结果的参数值
例如有两个箱子, 甲箱有99白球1个黑球, 乙箱有99黑球1白球, 现在取出了一个黑球
问黑球是从哪个箱子取出的
你的第一印象就是黑球最像是从乙箱取出的, 这个推断符合人们的经验事实, 最像就是最大似然之意, 这种想法常称为极大似然原理
伯努利试验
伯努利试验(Bernoulli experiment), 是在同样的条件下重复地, 相互独立地进行的一种随机试验
其特点是该随机试验只有两种可能结果: 发生或者不发生
我们假设该项试验独立重复地进行了n次, 那么就称这一系列重复独立的随机试验为n重伯努利试验
单个伯努利试验是没有多大意义的, 然而当我们反复进行伯努利试验, 去观察这些试验有多少是成功的多少是失败的, 事情就变得有意义了, 这些累计记录包含了很多潜在的非常有用的信息
以抛硬币为例, 每次抛硬币出现正面的概率都是50%, 假设一直抛硬币, 直到出现正面, 则一个伯努利试验结束
假设第1次伯努利试验抛硬币的次数为K1, 第n次伯努利试验抛硬币的次数为Kn
我们记录这n次伯努利试验的最大抛硬币次数为Kmax
那么重点来了
在某次伯努利试验中, kmax出现的概率, 就是投掷了kmax-1次反面, 和1次正面, 概率就是
1
/
2
k
m
a
x
1/2^{k_{max}}
1/2kmax
也就是说需要进行
2
k
m
a
x
2^{k_{max}}
2kmax次试验可能会出现一次kmax, 那么根据极大似然估算法, 我们就粗略估计本次进行的n次伯努利试验的
n
=
2
k
m
a
x
n=2^{k_{max}}
n=2kmax
同理, 应用到基数统计中就是, 把集合中每个元素都经过hash后表示为二进制数串, 一个数串类比成一次抛硬币试验, 1是抛到正面, 0是反面
二进制串中从低位开始第一个1出现的位置, 理解为抛硬币中第一次出现正面的抛掷次数k, 依旧通过
2
k
m
a
x
2^{k_{max}}
2kmax来估算集合中一共有多少不同的数字
因为重复的值hash后二进制数串一致, 得到的k值不会变化, 所以是天然去重的, 重复数据不会对估算值产生任何影响
后续优化
很显然这个估算关系是不准确的
关于估值偏差较大的问题, 可以采用如下方式结合来缩小误差
均匀随机化
选取的哈希函数必须满足以下条件, 优秀的哈希函数是后续概率分析的基础
- 哈希结果有很好的均匀性, 无论原始集合元素的值分布如何, 哈希结果都几乎服从均匀分布
- 哈希的碰撞几乎可以忽略不计
- 哈希结果的长度是固定的
分桶平均
以上直接采用单一估计量会由于偶然性存在较大误差, 因此采用分桶平均的思想来消除误差
以Redis为例, 哈希结果64位, 前14位来分桶, 共16384个桶, 后50位作为真正用于基数估计的比特串
桶编号相同的元素分配到一个桶, 先分别统计每个桶各自的kmax, 然后进行平均
相当于物理试验中多次试验取平均的做法, 可以有效消减因偶然性带来的误差
调和平均
分桶取平均是LLC的算法实现, HLL区别在于采用了调和平均数,
调和平均数指的是倒数的平均数, 相比平均数能有效抵抗离群值对平均值的扰动
比如我和马云的平均资产, 我有两万, 他有两千亿, 平均一下我有一千亿
但是调和平均数, 平均一下我有2/(1/20000 + 1/200000000000)=40000
就和我的资产偏差不是很大
偏差修正
经过上述系列优化后的估计量看似已经不错了, 但通过数学分析可以知道这并不是基数n的无偏估计
因此需要修正成无偏估计, 此处涉及一系列数学公式
简单来说就是, 增加修正因子, 是一个不固定的值, 会根据实际情况来进行值的调整
详细可参考论文
Redis中HyperLogLog结构
具体数据结构, 密集存储结构, 稀疏存储结构及其转换, 可参考一下文章
玩转Redis-HyperLogLog原理探索
ps: 另附一个演示工具, 在明白以上原理以后, 可以直观的看到HLL的工作过程及误差变化情况