背景
数据库在收集统计信息的时候,需要收集 NDV,以前谈到过数据库是如何根据采样数据的 NDV 估算全部数据的 NDV。除此之外,数据库还有手段可以优化计算 NDV 的效率,甚至可以从分区 NDV 来推导全局 NDV。
不考虑任何优化的情况下,如果有 n 条记录,计算 NDV 需要排序去重,空间复杂度是 O(n)。其次计算全局 NDV 的时候要重新计算一遍,没法利用分区 NDV。比如说,分区 A 的某个字段值是 {1,2,3},分区 B 的这个字段值是 {1,2,4},那么分区 NDV=3,全局 NDV=4,全局 NDV 没法根据分区 NDV 推导出来。
算法
为了解决这个问题,在 1990 年论文"A Linear-Time Probabilistic Counting Algorithm for Database Applications" 提出了 LLC 算法。在 2007 年论文"HyperLogLog the analysis of a near-optimal cardinality estimation algorithm"提出了 HLL 算法。
简单来说,HLL 和 LLC 都是基于概率的估算,通过对数据计算哈希值,投入多个桶,根据桶内统计信息来估计 NDV,当 NDV 较小时(小于桶数),LLC 比较可靠,当 NDV 较大时(大于桶数),HLL 比较可靠。以下假设 NDV=n,并且有 m 个桶,来解释这两种算法。
LinearCounting(LLC)
假设 n 个数据计算哈希值后被扔进了 m 个桶,考察有多少个桶是空的这个统计信息来估算 n 的值。记空桶个数的数学期望为 v。
根据概率,一个桶是空的意味着所有的数据都扔进了其他的桶,其概率为:
(1−1m)n(1-\frac{1}{m})^n(1−m1)n
因为总共有 m 个桶,所以空桶个数的数学期望就是:
v=m⋅(1−1m)nv=m\cdot (1-\frac{1}{m})^nv=m⋅(1−m1)n
两边取对数也就是:
ln(vm)=n⋅ln(1−1m)\ln(\frac{v}{m})=n\cdot\ln(1-\frac{1}{m})ln(mv)=n⋅ln(1−m1)
如果 m 很大,可以用ln(1−x)\ln(1-x)ln(1−x)在 x=0 点出泰勒展开来近似,可以得到:
ln(vm)=n⋅(−1m−12m2−13m2−...)\ln(\frac{v}{m})=n\cdot(-\frac{1}{m}-\frac{1}{2m^2}-\frac{1}{3m^2}-...)ln(mv)=n⋅(−m1−2m21−3m21−...)
舍去高阶无穷小:
ln(vm)≈n⋅(−1m)≈−nm\ln(\frac{v}{m})\approx n\cdot(-\frac{1}{m})\approx-\frac{n}{m}ln(mv)≈n⋅(−m1)≈−mn
得到:
n^=−m⋅ln(vm)=m⋅ln(mv)\hat{n}=-m\cdot\ln(\frac{v}{m})=m\cdot\ln(\frac{m}{v})n^=−m⋅ln(mv)=m⋅ln(vm)
HyperLogLog(HLL)
HLL 稍微复杂点,其做法也是将 n 个数据计算哈希值后,将其扔进 m 个桶。这次考察的是:这些二进制哈希值中,最左侧有多少个连续的 0,也被称为"前导零个数"。比如某个数据计算 64 位哈希值后为:
0001 1000 ...<后面省略>...
则这个数据的前导零个数为 3,记为:
ρ=3\rho=3ρ=3
一个桶里所有数据最大的前导零个数记为:
M[i]=max{ρ1,ρ2,...}+1M[i]=\max\{\rho_{1},\rho_{2},...\}+1M[i]=max{ρ1,ρ2,...}+1
我们断言,这个桶里的 NDV 近似等于 2M[i]2^{M[i]}2M[i]
为什么呢?可以做个比喻,如果翻一枚硬币,连续 k 次正面朝上后才反面朝上的概率是 12k+1\frac{1}{2^{k+1}}2k+11,意味着翻了 2k+12^{k+1}2k+1 次硬币才能见到这样一次"罕见"的情况。
那是不是直接用 2M[i]2^{M[i]}2M[i] 来估计 NDV 就行了呢,还不行,因为这个方差太大,有可能偏离实际值,因此要做调整。
第一个调整是:既然有那么多桶,那么在这些桶上取平均值会更好些,因此这 64 位哈希值分成两部分,前 10 位是桶的编号,也就是一共有 1024 个桶,后 54 位才用来观察前导零个数。当然分 m 个组取平均值会是期望是原来的 1m\frac{1}{m}m1,所以最后要补充乘以一个 m。
第二个调整是:用调和平均数代替算术平均数,也就是计算:
z=11mΣi=1m2−M[i]=mΣi=1m2−M[i]z=\frac{1}{\frac{1}{m}\Sigma_{i=1}^{m}2^{-M[i]}}=\frac{m}{\Sigma_{i=1}^{m}2^{-M[i]}}z=m1Σi=1m2−M[i]1=Σi=1m2−M[i]m
原因是调和平均数比算数平均数更小一点,更大程度远离那些异常高的值。
第三个调整是:这样计算后,估算出来的 NDV 在数学期望上存在系统性偏差,要做修正。原理比较复杂,可以见参考资料中的论文。简单说是还要加一个和 m 有关的 αm\alpha_mαm 修正因子。
所以最终的估算就是:
n^=αm⋅m⋅z=αm⋅m2Σi=1m2−M[i]\hat{n}=\alpha_m\cdot m\cdot z=\alpha_m\cdot\frac{m^2}{\Sigma_{i=1}^{m}2^{-M[i]}}n^=αm⋅m⋅z=αm⋅Σi=1m2−M[i]m2
分区推导全局
在上述两种 NDV 估算方法中,可以发现如果是有重复值,那么一方面重复值不会改变 n 的值,另一方面重复值计算出来的哈希值是相同的,也不会改变 LLC 和 HLL 算法中统计值的变化,所以这两个算法可以很好的估计 NDV。
不管是 HLL 还是 LLC,做法都是对每一条数据计算哈希值,扔进一个桶,每个桶会维护一个数据结构称为 HLL 草图。当用分区 NDV 推导全局 NDV 的时候,只需要合并 HLL 草图即可,非常方便。
举个例子:
分区 A 有 4 个桶,HLL 草图是:SA=M[3,5,1,2]S_A=M[3,5,1,2]SA=M[3,5,1,2]
分区 B 也有 4 个桶,HLL 草图是:SB=M[1,3,4,7]S_B=M[1,3,4,7]SB=M[1,3,4,7]
则全局表的 HLL 草图是:S=M[3,5,4,7]S=M[3,5,4,7]S=M[3,5,4,7]
也就是每个桶的最大前导零个数是所有分区桶中最大前导零个数的最大值,这也是符合直觉和逻辑的。
这里也能看到,如果是精确计算 NDV,n 个不同值需要存储所有的值,假设一个值需要 c 个字节,那么全部就需要 c∗nc*nc∗n 个字节,也就是空间复杂度为 O(n)O(n)O(n)。但如果是 HLL 估算,首先 n 个不同值只需要 log2n\log_{2}{n}log2n 这个数字就能表示(最大前导零个数),以二进制的方式存储 log2n\log_{2}{n}log2n 这个数字只需要 log2(log2n)\log_2(\log_{2}{n})log2(log2n) 个字节,所以空间复杂度是 O(log(logn))O(\log(\log n))O(log(logn)),大大减少。