HyperLogLog:12KB 数清一亿个 key
「这个月有多少个不同的访客」是一个精确统计代价极高的问题:唯一的精确答法是把见过的 key 全存下来去重。HyperLogLog 用 12288 字节回答任意规模的这个问题,标准误 0.81%。
它依据的是一个观察:一个足够随机的 hash,其二进制表示以 个 0 开头的概率是 。见过 个不同的 hash,最长的那串前导零大约是 位。反过来,看见了一串 位的前导零,就有理由猜基数在 附近。
1 · 前导零个数与基数
把 32 位 hash 从高位往低位读,取前 位当桶下标,余下 位数前导零。定义 为 的前导零个数加一,那么 的取值分布是几何分布:。
定义 1.1(rank) 对 32 位 hash 与桶下标位数 ,桶号为 的高 位,rank 为 的低 位左对齐后的前导零个数加一。低位全为 0 时 rank 取 。
2 · 单个极值的方差
只留一个寄存器、记录全部 key 里最长的那串前导零,估计式就是 。实测真实基数 10000、八组独立数据时,八个估计分别是 16384、4096、16384、32768、8192、8192、65536、8192,最大与最小相差 16 倍,均方相对误差 216%。
问题出在估计量的粒度上。 是一个极值统计量,只能取整数, 于是只能落在 2 的整数次幂上,相邻两档之间差一倍。任何单一极值估计都逃不掉这个粒度。
出路是取多个独立的极值再平均。把 hash 的高 位拿去选桶,每个桶各自维护一个 ,就得到了 个近似独立的估计器,每个估计器看到约 个 key。
3 · 分桶与调和平均
个桶各给出一个 ,怎么合成一个数是关键。算术平均不行:某个桶偶然拿到一个大 rank 时, 会大得离谱,一个离群桶就能把平均值拉飞。
调和平均 是这一步的答案:先取倒数求和,再取倒。倒数把大值压成接近 0 的贡献,离群桶几乎不影响结果。HyperLogLog 的估计式是
分母那一项正是 除以调和平均。相比 LogLog 用几何平均,调和平均把标准误从 压到 ,这一步是 HyperLogLog 相对 LogLog 的全部改进。
Redis 取 ,即 。每个桶存 6 bit(够表示 到 的 rank,而 32 位 hash 下 rank 最大只到 19),合计 字节。标准误 。
4 · 偏差修正常数
上式里的 补的是一处系统性偏差:不加它的话,调和平均给出的估计恒偏高。Flajolet 等人算出的积分式是
工程上不解这个积分,而是用它的渐近式 ,另给 、、 三档写死的数值解 、、。 时渐近式给出 ,与极限值 只差 。
5 · 两端修正的接缝
这个式子在两端都失效。基数远小于 时大量桶还是 0,调和平均被这些 0 主导,估计严重偏高;基数接近 时 hash 开始大量碰撞,估计偏低。标准做法各打一个补丁:
- 且还有空桶时改用 linear counting,,其中 是空桶数。
- 时用 反解碰撞。
第一个补丁的接缝处有一段真实的偏差。、每个基数 24 组独立数据的实测:基数 36000 时平均相对误差 ,40000 时跳到 ,42000 时到峰值 ,均方相对误差 2.286%,接近标准误的三倍。之后随基数增大缓慢回落,60000 时降到 。
成因是两段公式在接缝处不连续:原始估计式在 略大于 的区间本身带正偏差,而在此之前这一段一直被 linear counting 挡着。HLL++ 修的正是它,办法是给每个 预先测出 200 个基数点上的偏差存成表,运行时按最近邻插值减掉。
警示 · 这一段偏差差点被写成另一个结论。用 12 组独立数据跑时,基数 80 万处的平均相对误差是 ,看着像 32 位 hash 的碰撞开始咬人。把组数加到 32 组后,同一个基数的偏差落回 ,前一个数字是抽样噪声。真正稳定的正偏差只有接缝处那一处,全站正文里的 24 组曲线是重跑后的版本。
6 · 稀疏与稠密
12288 字节是定长的,基数只有几十时也照占不误。Redis 的对策是给小基数另备一套 稀疏编码:桶值大多为 0,把它们游程压掉。三个 opcode 各管一段:
| opcode | 字节 | 覆盖 |
|---|---|---|
ZERO |
1 | 连续 1 到 64 个零桶 |
XZERO |
2 | 连续 1 到 16384 个零桶 |
VAL |
1 | 连续 1 到 4 个相同的非零桶值,值域 1 到 32 |
一个空的 HLL 是一条 XZERO,共 2 字节。基数增长时零桶被逐个填掉,游程碎成一堆 VAL,编码长度反超定长。实测
的切换点在基数 1676:此时稀疏编码要 3001 字节,刚越过 hll-sparse-max-bytes 的默认值 3000。
另有一条与字节数无关的硬条件:VAL 的值域只到 32,任何一个桶的值超过 32 就必须转稠密。
时 rank 最大 19,这条在实践中不会触发;
更小时(余下的位更多)才可能撞上。
7 · PFMERGE 的成立依据
PFMERGE 只做一件事:逐桶取两边的最大值。它成立的理由不在概率论,而在桶里存的那个量的语义——桶存的是「落进本桶的全部 hash 里,最长的那串前导零」,这个量对并集的答案,恰好等于两边各自答案的最大值。
合并结果与对并集直接建一份逐桶相等,不是近似相等。这一条可以逐桶断言,core/hll.test.ts 里就是这么验的:、两批各 3 万个 key,合并后 16384 个桶与直接建的那一份全部对上。
把两个估计相加则完全不行:重叠部分被计了两次。实测两批各 2 万个 key、重叠 30% 时,相加得 40203,真值 34000,高估 18.2%;重叠 100% 时高估 103%。这个误差随重叠比例上升,与 HLL 本身的精度无关。多机汇总要的是逐桶取 max 那一种,可合并性在分布式场景里的分量不低于省内存。
8 · 参考文献
- Flajolet, P., Fusy, É., Gandouet, O., & Meunier, F. (2007). HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm. Discrete Mathematics and Theoretical Computer Science Proceedings, AH, 137–156.
- Durand, M., & Flajolet, P. (2003). Loglog counting of large cardinalities. ESA 2003, 605–617.
- Heule, S., Nunkesser, M., & Hall, A. (2013). HyperLogLog in practice: algorithmic engineering of a state of the art cardinality estimation algorithm. EDBT 2013, 683–692.
- Whang, K.-Y., Vander-Zanden, B. T., & Taylor, H. M. (1990). A linear-time probabilistic counting algorithm for database applications. ACM Transactions on Database Systems, 15(2), 208–229.
- Sanfilippo, S. (2014). Redis new data structure: the HyperLogLog. antirez.com.