土法炼钢兴趣小组的算法知识备份

HyperLogLog:用 12KB 统计十亿基数

目录

假设你在运营一个日活过亿的平台。产品经理问你:昨天有多少独立访客?你的第一反应是开一个 HashSet,把每个 user_id 丢进去,最后看 size。

一亿个 64-bit ID,HashSet 的开销大约是 \(100 \times 10^6 \times (8 + 16 + 8) \approx 3.2\text{GB}\)——这还只是一个计数器。如果你要按省份、按渠道、按页面分别统计 UV,内存账单会让你的 SRE 同事当场辞职。

HyperLogLog 告诉你:12KB 够了。不是 12MB,不是 12GB,就是 12KB。

用 12KB 内存,你可以估计高达 \(10^9\) 量级的基数,标准误差仅约 0.81%。这意味着如果真实 UV 是一亿,HyperLogLog 的回答大概率落在 9919 万到 10081 万之间。对于绝大多数业务场景,这个精度绰绰有余。

这不是魔法,是数学。具体地说,是 Philippe Flajolet 和他的合作者们在二十多年间逐步打磨出来的一系列算法:从 1985 年的 Probabilistic Counting,到 2003 年的 LogLog,再到 2007 年的 HyperLogLog,最后是 Google 在 2013 年提出的 HyperLogLog++。每一步改进都建立在严格的概率论和解析组合学之上,同时也包含着精巧的工程权衡。

本文将从基数统计问题的定义出发,完整地推导这条演化路径。我会给出核心公式的直觉解释和严格推导,分析偏差修正的三段式策略,展示 HyperLogLog++ 的稀疏表示优化,讨论 Redis 中的实现细节,最后提供一个完整的 C 语言实现。如果你只想了解”怎么用”,可以直接跳到第九节的 Redis 部分;但如果你想真正理解”为什么有效”,建议从头读起。

一、基数统计问题

什么是基数

给定一个多重集合(multiset)\(S\),其中元素可以重复出现,基数(cardinality)\(n\) 定义为 \(S\) 中不同元素的数量:

\[ n = |\\{x : x \in S\\}| \]

这个定义看似平凡,但在流式计算场景中它变得极其困难。流式意味着:

  1. 数据量巨大,无法全部存入内存。
  2. 每个元素只能被观察一次(single-pass)。
  3. 我们可能需要在任意时刻给出当前基数的估计。

实际应用场景

基数统计无处不在:

场景 元素 基数含义
网站分析 访客 cookie/user_id 独立访客数(UV)
网络监控 源 IP 地址 发起连接的不同主机数
数据库优化 列值 列的 distinct count(用于查询优化器)
搜索引擎 查询词 独立查询词数量
广告系统 (广告 ID, 用户 ID) 对 广告触达的独立用户数
DDoS 检测 目标端口 被扫描的不同端口数

精确方案的代价

精确计算基数有几种经典方案:

方案一:排序去重。 对数据排序后扫描一遍统计不同元素。时间 \(O(n \log n)\),空间 \(O(n)\)。流式场景下不可行——你不能对流排序。

方案二:HashSet。 将每个元素插入哈希表,最终返回表大小。时间 \(O(n)\),空间 \(O(n)\)。这是最直接的方案,但内存开销与基数成正比。一亿个 64-bit 元素,Java 的 HashSet 大约需要 3-4GB。

方案三:Bitmap。 如果元素值域有限(比如 32-bit 整数),可以用一个 \(2^{32}\) 位的位图标记每个出现过的值。空间固定为 512MB,不依赖基数大小。但值域必须预先已知且不能太大。

方案四:Bloom Filter。 空间比 HashSet 小很多(大约 \(1.44 \times n \times \log_2(1/\epsilon)\) 位),但 Bloom Filter 本身不能直接告诉你基数是多少——它只回答”某元素是否可能在集合中”。虽然可以通过 Bloom Filter 的填充率反推基数,但精度很差。

核心矛盾是:精确计数需要的空间与基数成线性关系,而我们希望用与基数无关的常数空间来完成估计。

HyperLogLog 正是为此而生。

二、Flajolet-Martin 的直觉

抛硬币与前导零

让我们从一个极其简单的观察开始。

假设你均匀随机地生成 \(n\) 个二进制字符串。对于每个字符串,你观察它的前导零的数量。直觉告诉你:

反过来说:如果你在 \(n\) 个字符串中观察到的最大前导零数为 \(R\),那么 \(n\) 大约是 \(2^R\)

这就是 Flajolet 和 Martin 在 1985 年论文 Probabilistic Counting Algorithms for Data Base Applications 中提出的核心思想。

形式化

\(h: \mathcal{U} \to \\{0, 1\\}^L\) 是一个将元素映射到 \(L\)-bit 二进制串的哈希函数(假设行为近似均匀随机)。定义函数 \(\rho(x)\)\(x\) 的二进制表示中第一个 1 的位置(即前导零数加一):

\[ \rho(0001\ldots) = 4, \quad \rho(1\ldots) = 1, \quad \rho(01\ldots) = 2 \]

对于多重集合 \(S\) 中的每个元素 \(v\),计算 \(h(v)\) 并记录 \(\rho(h(v))\)。令:

\[ R = \max_{v \in S} \rho(h(v)) \]

\(2^R\)\(|S|\) 的一个(非常粗糙的)估计。

为什么这是对的

考虑事件”至少有一个哈希值的 \(\rho\)\(\geq k\)“。每个哈希值的 \(\rho \geq k\) 的概率是 \(1/2^k\)。如果有 \(n\) 个不同元素:

\[ \Pr[R \geq k] = 1 - (1 - 2^{-k})^n \approx 1 - e^{-n/2^k} \]

\(n \gg 2^k\) 时,\(\Pr[R \geq k] \approx 1\)(几乎肯定会观察到)。当 \(n \ll 2^k\) 时,\(\Pr[R \geq k] \approx n/2^k \approx 0\)(几乎不可能观察到)。转折点就在 \(n \approx 2^k\) 附近。所以 \(R \approx \log_2 n\),即 \(2^R \approx n\)

致命缺陷

这个估计的方差极大。\(2^R\) 只能取 \(\\{1, 2, 4, 8, 16, \ldots\\}\) 这些 2 的幂次值——这意味着你的估计要么翻倍,要么减半,没有中间状态。对于任何实际应用,这个精度都不可接受。

但 Flajolet-Martin 的贡献不在于给出一个好的估计器,而在于建立了这个令人惊叹的直觉:哈希值的位模式携带了基数信息。

三、LogLog:分桶取平均

随机平均化

降低方差的经典方法是取多次独立实验的平均值。但我们只有一个数据流,怎么得到”多次独立实验”?

答案是随机分桶(stochastic averaging)。将哈希值的前 \(p\) 位用作桶索引,把元素分到 \(m = 2^p\) 个桶中。对每个桶,用剩余位计算 \(\rho\) 值,维护每个桶的最大 \(\rho\) 值。

\(M[0], M[1], \ldots, M[m-1]\)\(m\) 个寄存器,每个存储对应桶中观察到的最大 \(\rho\) 值。对于输入元素 \(v\)

idx = h(v) 的前 p 位
w   = h(v) 的剩余位
M[idx] = max(M[idx], ρ(w))

由于哈希的均匀性,每个桶大约分到 \(n/m\) 个不同元素。每个桶的 \(M[j]\) 大约是 \(\log_2(n/m)\)。对所有桶取平均再做指数变换,就得到一个好得多的估计。

LogLog 算法

Durand 和 Flajolet 在 2003 年提出的 LogLog 算法使用算术平均

\[ \hat{E}_{\text{LogLog}} = \alpha_m \cdot m \cdot 2^{\frac{1}{m}\sum_{j=0}^{m-1} M[j]} \]

其中 \(\alpha_m\) 是一个依赖于 \(m\) 的修正常数,用于消除系统性偏差。

LogLog 的标准误差为 \(1.30 / \sqrt{m}\)。当 \(m = 2048\)(使用 5-bit 寄存器,内存约 1.25KB)时,标准误差约为 2.87%。

LogLog 的局限

LogLog 使用的算术平均(对 \(M[j]\) 求平均后再取 \(2\) 的幂)等价于对 \(2^{M[j]}\) 取几何平均。几何平均对离群值非常敏感:如果某个桶因为运气好出现了异常大的 \(\rho\) 值,它会显著拉高整体估计。

这种敏感性在小基数场景下尤其明显。当 \(n\) 较小时,很多桶可能是空的(\(M[j] = 0\)),少数桶有偶然偏大的值,导致估计严重偏高。

四、HyperLogLog:调和平均的力量

从几何平均到调和平均

HyperLogLog 的核心改进在于用调和平均(harmonic mean)替代几何平均。调和平均天然地压制大值、强调小值,对离群值的鲁棒性远强于算术平均和几何平均。

回忆三种平均的定义(对正数 \(x_1, \ldots, x_m\)):

\[ \text{算术平均} = \frac{1}{m}\sum x_j, \quad \text{几何平均} = \left(\prod x_j\right)^{1/m}, \quad \text{调和平均} = \frac{m}{\sum 1/x_j} \]

它们之间有经典不等式:调和 \(\leq\) 几何 \(\leq\) 算术。

HyperLogLog 公式

\(Z = \left(\sum_{j=0}^{m-1} 2^{-M[j]}\right)^{-1}\),即 \(2^{-M[j]}\) 的调和平均(去掉 \(m\) 的因子)。HyperLogLog 的原始估计为:

\[ E = \alpha_m \cdot m^2 \cdot Z = \alpha_m \cdot m^2 \cdot \left(\sum_{j=0}^{m-1} 2^{-M[j]}\right)^{-1} \]

其中修正常数:

\[ \alpha_m = \left(m \int_0^\infty \left(\log_2 \frac{2+u}{1+u}\right)^m du\right)^{-1} \]

\(m \geq 128\) 时,有很好的近似:

\[ \alpha_m \approx \frac{0.7213}{1 + 1.079/m} \]

特殊值:\(\alpha_{16} = 0.673\)\(\alpha_{32} = 0.697\)\(\alpha_{64} = 0.709\)

直觉解释

为什么这个公式是对的?考虑一个桶分到了 \(n_j\) 个不同元素。\(M[j]\) 大约是 \(\log_2 n_j\),所以 \(2^{-M[j]}\) 大约是 \(1/n_j\)。于是:

\[ \sum_{j=0}^{m-1} 2^{-M[j]} \approx \sum_{j=0}^{m-1} \frac{1}{n_j} \]

由于元素均匀分到 \(m\) 个桶,\(n_j \approx n/m\),所以:

\[ \sum 2^{-M[j]} \approx m \cdot \frac{m}{n} = \frac{m^2}{n} \]

因此:

\[ E \approx \alpha_m \cdot m^2 \cdot \frac{n}{m^2} = \alpha_m \cdot n \]

\(\alpha_m\) 的作用就是修正这个近似中的系统性偏差,使得 \(E[E] = n\)(无偏估计)。

标准误差

HyperLogLog 的标准误差为:

\[ \frac{\sigma}{\hat{n}} = \frac{1.04}{\sqrt{m}} \]

\(m = 2^{14} = 16384\) 时:

\[ \frac{\sigma}{\hat{n}} = \frac{1.04}{\sqrt{16384}} = \frac{1.04}{128} \approx 0.0081 = 0.81\% \]

这是一个显著的改进:相比 LogLog 的 \(1.30/\sqrt{m}\),HyperLogLog 只需要约 \((1.04/1.30)^2 \approx 64\%\) 的寄存器就能达到同样的精度,或者在同样的寄存器数量下标准误差降低约 20%。

12KB 的由来

\(m = 2^{14} = 16384\) 个寄存器。每个寄存器需要存储的最大值是 \(\rho\) 的上界。对于 64-bit 哈希,去掉 14 bit 的桶索引,剩余 50 bit,\(\rho\) 的最大值是 51(50 个前导零加一),用 6 bit 就够了(\(2^6 = 64 > 51\))。

\[ \text{总内存} = 16384 \times 6 \text{ bit} = 98304 \text{ bit} = 12288 \text{ Byte} = 12 \text{ KB} \]

用 12KB 内存,标准误差 0.81%,可以估计的基数上限远超 \(10^{18}\)(64-bit 哈希的理论极限)。在绝大多数实际场景中,\(10^9\) 级别的基数估计轻而易举。

五、偏差修正:三段式策略

HyperLogLog 的原始公式在中等基数范围内表现优秀,但在极小和极大基数时存在系统性偏差。Flajolet 等人在 2007 年的论文中提出了三段式修正策略。

小范围修正(Linear Counting)

当估计值 \(E\) 较小(\(E < 5m/2\))且有较多空寄存器时,使用 Linear Counting 算法进行修正。

\(V\) 为值为 0 的寄存器数量。如果 \(V > 0\)

\[ E^* = m \ln\frac{m}{V} \]

这个公式来自 Linear Counting 算法(Whang et al., 1990)。其直觉是:将 \(m\) 个寄存器视为 \(m\) 个位置,每个元素随机占据一个位置,空位置的比例与已插入的不同元素数之间存在指数关系。这与经典的”抛球入箱”(balls into bins)模型完全对应。

如果 \(V = 0\)(没有空寄存器),则不进行小范围修正,直接使用 \(E\)

中间范围:不修正

\(5m/2 \leq E \leq 2^{32}/30\) 时,原始估计已经足够好,直接使用 \(E\)

这里的 \(2^{32}\) 是 Flajolet 原始论文中假设的 32-bit 哈希的值域上界。在使用 64-bit 哈希的现代实现中,这个上界实际上可以调整为 \(2^{64}/30\),但由于 64-bit 哈希在实际基数范围内几乎不会出现大范围碰撞问题,很多实现直接跳过大范围修正。

大范围修正

\(E > 2^{32}/30\) 时,哈希碰撞变得不可忽略。修正公式为:

\[ E^* = -2^{32} \ln\left(1 - \frac{E}{2^{32}}\right) \]

这个公式来自对哈希碰撞的泊松近似。当基数接近哈希值域大小时,不同元素映射到相同哈希值的概率显著增加,原始公式会低估基数。大范围修正通过”生日悖论”的逆运算来补偿这一偏差。

完整算法

def hll_count(registers, m, p):
    """HyperLogLog 基数估计(含偏差修正)"""
    # 修正常数
    if m == 16:
        alpha = 0.673
    elif m == 32:
        alpha = 0.697
    elif m == 64:
        alpha = 0.709
    else:
        alpha = 0.7213 / (1.0 + 1.079 / m)

    # 调和平均
    Z = 1.0 / sum(2.0 ** (-Mj) for Mj in registers)
    E = alpha * m * m * Z

    if E <= 5.0 * m / 2.0:
        # 小范围修正
        V = registers.count(0)
        if V > 0:
            E_star = m * math.log(m / V)
        else:
            E_star = E
    elif E <= (2 ** 32) / 30.0:
        # 中间范围:不修正
        E_star = E
    else:
        # 大范围修正
        E_star = -(2 ** 32) * math.log(1.0 - E / (2 ** 32))

    return E_star

修正策略的工程启示

这三段式修正体现了一个重要的工程哲学:没有一个公式在所有范围内都是最优的,不如在不同范围内使用不同的最优策略。 小范围用 Linear Counting(基于空桶比例),中间范围用 HyperLogLog 的调和平均公式,大范围用哈希碰撞修正。三个公式在各自的最优区间内平滑拼接,覆盖了从 0 到 \(2^{64}\) 的全部范围。

我个人认为这种”分段最优”的思路在很多工程问题中都适用。比如排序算法中,Timsort 在小数组上用插入排序、大数组上用归并排序;比如内存分配器中,小对象走 slab cache、大对象走 mmap。承认”没有银弹”然后分段攻克,往往比寻找一个”统一优雅的解决方案”更实用。

六、HyperLogLog++:Google 的工程改进

2013 年,Google 发表了 HyperLogLog in Practice: Algorithmic Engineering of a State of the Art Cardinality Estimation Algorithm,提出了 HyperLogLog++ 算法。这篇论文是工程改进的典范——没有改变核心数学,但通过一系列精巧的工程优化,使得 HyperLogLog 在实际部署中表现更好。

改进一:64-bit 哈希

原始论文使用 32-bit 哈希。在现代应用中,基数可能超过 \(10^9\),32-bit 哈希(值域仅 \(4.3 \times 10^9\))会在大范围内引入严重碰撞。HyperLogLog++ 全面切换到 64-bit 哈希,彻底消除了大范围修正的需求。

改进二:小范围偏差修正表

Google 通过大规模实验发现,在小基数范围内,HyperLogLog 的原始估计存在可预测的偏差。他们用经验方法(对每个基数值运行数万次模拟)构建了一个偏差修正查找表。

在估计阶段,如果原始估计 \(E\) 落在小范围内,算法会查找表中最近的几个点,用线性插值得到偏差修正值 \(\text{bias}(E)\),然后返回 \(E - \text{bias}(E)\)

if E <= 5 * m:
    bias = interpolate(rawEstimateData, biasData, E)
    E_corrected = E - bias

这个修正表是预计算的,不增加运行时开销。它消除了小范围内 Linear Counting 和 HLL 公式之间的切换不连续性,使得估计在全范围内更加平滑。

改进三:稀疏表示

这是 HyperLogLog++ 最重要的工程创新。

观察:当基数很小时(比如只有几百个不同元素),16384 个寄存器中绝大多数是空的。用密集数组存储这些空寄存器是巨大的浪费。

HyperLogLog++ 引入了稀疏表示(sparse representation):只存储非零寄存器的 (索引, 值) 对。具体实现使用一个排序列表,每个条目编码为一个整数:

条目 = (桶索引 << 6) | ρ值

当基数很小时,非零寄存器数量远小于 \(m\),稀疏表示的内存开销远低于密集数组。当非零寄存器数量增长到一定阈值(稀疏表示的大小超过密集表示的 12KB 时),自动转换为密集表示。

稀疏表示还有一个额外好处:可以使用更大的虚拟精度 \(p'\)(比如 \(p' = 25\))在稀疏阶段获得更高精度。转换为密集表示时,再将 \(p' = 25\) 的桶映射到 \(p = 14\) 的桶上。

稀疏阶段:虚拟精度 p' = 25,2^25 = 33,554,432 个虚拟桶
密集阶段:实际精度 p  = 14,2^14 = 16,384 个实际桶
转换时:每 2^(p'-p) = 2^11 = 2048 个虚拟桶合并为一个实际桶

HyperLogLog++ 的实际效果

Google 的论文报告,在相同精度下,HyperLogLog++ 在小基数范围内的误差比原始 HyperLogLog 降低了数倍,同时在小基数时的内存使用显著减少。在 Google 内部,HyperLogLog++ 被广泛用于广告系统、搜索分析和 BigQuery 等产品中。

算法演化:
FM-Sketch (1985)  → 单个位图,方差极大
     ↓
LogLog (2003)     → 分桶 + 算术平均,σ/n = 1.30/√m
     ↓
HyperLogLog (2007)→ 分桶 + 调和平均,σ/n = 1.04/√m
     ↓
HLL++ (2013)      → 64-bit 哈希 + 稀疏表示 + 偏差修正表

七、合并操作:天然的分布式亲和力

HyperLogLog 最令人赞叹的性质之一是它天然支持合并。这个性质使得它在分布式系统中具有无可替代的价值。

合并原理

给定两个 HyperLogLog 实例 \(A\)\(B\)(使用相同的哈希函数和参数 \(p\)),它们的合并结果是:

\[ C[j] = \max(A[j], B[j]), \quad j = 0, 1, \ldots, m-1 \]

为什么这是正确的?因为 \(M[j]\) 存储的是”桶 \(j\) 中观察到的最大 \(\rho\) 值”。如果一个元素出现在 \(A\)\(B\) 中,它会被分到某个桶 \(j\),并贡献一个 \(\rho\) 值。合并后的 \(C[j]\) 应该反映 \(A\)\(B\) 中所有元素在桶 \(j\) 中的最大 \(\rho\) 值,这恰好就是 \(\max(A[j], B[j])\)

void hll_merge(uint8_t *dst, const uint8_t *src, int m) {
    for (int j = 0; j < m; j++) {
        if (src[j] > dst[j])
            dst[j] = src[j];
    }
}

分布式场景

这个性质的威力在分布式计算中充分体现:

场景一:分片聚合。 数据分布在 100 台服务器上,每台服务器独立维护一个 HyperLogLog。需要全局基数时,将 100 个 HLL 逐个合并(或用树形归约),得到的结果与在单台机器上处理全部数据完全等价。

场景二:时间窗口滑动。 预计算每小时的 HyperLogLog,需要”最近 24 小时的 UV”时,合并 24 个小时级 HLL 即可。无需重新扫描原始数据。

场景三:多维度交叉。 为每个 (省份, 渠道) 维度组合维护一个 HLL。需要”全国某渠道的 UV”时,合并该渠道下所有省份的 HLL。需要”某省所有渠道的 UV”时,合并该省下所有渠道的 HLL。

合并的代数性质

合并操作是:

这三个性质使得 HyperLogLog 构成一个幂等可交换幺半群(idempotent commutative monoid),在代数上属于一种 CRDT(Conflict-free Replicated Data Type)。这意味着在分布式系统中,无论合并的顺序如何、是否有重复合并,结果都是确定的。

节点 1: HLL_A
节点 2: HLL_B
节点 3: HLL_C

合并方式一:merge(merge(A, B), C)
合并方式二:merge(A, merge(B, C))
合并方式三:merge(merge(A, C), B)
合并方式四:merge(merge(A, B), merge(B, C))  ← 重复合并 B

以上四种方式结果完全相同!

合并不支持减法

需要注意的一点:HyperLogLog 不支持删除操作\(\max\) 操作不可逆——你无法从合并结果中”减去”某个 HLL。如果你需要支持删除,需要考虑其他数据结构,或者使用可以从时间窗口中移除的预聚合策略。

合并的网络开销

合并一个 HLL 只需要传输 12KB 数据。在分布式环境中,这几乎可以忽略不计。对比精确计数方案,后者可能需要传输数 GB 的原始数据或 HashSet。这使得 HyperLogLog 在跨数据中心的聚合场景中尤其有价值。

八、Redis 的 HyperLogLog 实现

Redis 从 2.8.9 版本(2014 年)开始内置 HyperLogLog 支持。Redis 的实现是学习 HyperLogLog 工程细节的绝佳案例。

基本命令

PFADD  key element [element ...]   # 添加元素
PFCOUNT key [key ...]              # 估计基数(多 key 时自动合并)
PFMERGE destkey sourcekey [sourcekey ...]  # 合并多个 HLL

PF 前缀是为了纪念 Philippe Flajolet(2011 年去世),算法的主要发明人。

Redis 实现细节

参数选择。 Redis 使用 \(p = 14\)\(m = 16384\) 个寄存器),每个寄存器 6 bit。这与原始论文的推荐一致。

内存布局。 密集表示下,16384 个 6-bit 寄存器紧凑排列在一个字节数组中。每个字节存储 1.33 个寄存器(\(8/6 \approx 1.33\))。具体的位操作:

// 获取第 regnum 个寄存器的值
int byte_offset = regnum * 6 / 8;
int bit_offset  = regnum * 6 % 8;  // 0, 6, 4, 2, 0, 6, ...
int val = (registers[byte_offset] >> bit_offset) & 0x3F;
if (bit_offset > 2) {
    // 寄存器跨越字节边界
    val |= (registers[byte_offset + 1] << (8 - bit_offset)) & 0x3F;
}

稀疏表示。 Redis 实现了类似 HyperLogLog++ 的稀疏表示。稀疏编码使用三种字节操作码:

操作码 格式 含义
ZERO 00xxxxxx 连续 x+1 个值为 0 的寄存器
XZERO 01xxxxxx yyyyyyyy 连续 (x<<8|y)+1 个值为 0 的寄存器
VAL 1vvvvvxx 连续 x+1 个值为 v+1 的寄存器

这种 run-length encoding(RLE)在稀疏情况下极其高效。一个空的 HyperLogLog 只需要 2 字节(一个 XZERO 操作码表示 16384 个连续的零)。

自动升级。 当稀疏表示的字节数超过 hll-sparse-max-bytes(默认 3000)时,Redis 自动将其转换为密集表示。

哈希函数。 Redis 使用 MurmurHash2 的 64-bit 变体作为内部哈希函数。

Redis HyperLogLog 的内存实际开销

空 HLL(稀疏表示):    约 200 字节
少量元素(稀疏表示):  几百字节到几 KB
转换阈值后(密集表示):12304 字节(12KB 寄存器 + 16 字节头部)

在 Redis 中,一个 HyperLogLog 键的最大内存消耗约为 12KB。这使得你可以轻松维护数万个 HLL 键(比如每个页面一个、每个广告位一个),总内存开销仍然很小。

实际使用示例

# 网站 UV 统计
PFADD page:home:20250715 user1 user2 user3
PFADD page:home:20250715 user2 user4 user5  # user2 重复,不影响

# 查看今日首页 UV
PFCOUNT page:home:20250715
# 返回 5(近似值)

# 合并最近 7 天的数据,得到周 UV
PFMERGE page:home:week \
    page:home:20250709 page:home:20250710 \
    page:home:20250711 page:home:20250712 \
    page:home:20250713 page:home:20250714 \
    page:home:20250715

PFCOUNT page:home:week
# 返回 7 天的独立访客总数(自动去重)

一个容易被忽略的陷阱

PFCOUNT 在多 key 模式下(PFCOUNT key1 key2 key3)会在内部执行合并操作。这意味着它的时间复杂度是 \(O(N \cdot m)\),其中 \(N\) 是 key 数量。如果你频繁地对大量 key 调用 PFCOUNT,应该考虑用 PFMERGE 预合并,然后对合并结果调用 PFCOUNT

九、与其他方案的对比

基数统计方案全景

方案 空间 时间(每元素) 精度 可合并 支持删除
HashSet \(O(n)\) \(O(1)\) 均摊 精确 是(集合并)
Bitmap \(O(|\mathcal{U}|)\) \(O(1)\) 精确 是(OR)
Linear Counting \(O(n)\) \(O(1)\) ~1% 是(OR)
Bloom Filter(反推) \(O(n)\) \(O(k)\) 是(OR)
LogLog \(O(\log\log n)\) \(O(1)\) ~1.30/sqrt(m) 是(max)
HyperLogLog \(O(\log\log n)\) \(O(1)\) ~1.04/sqrt(m) 是(max)
HLL++ \(O(\log\log n)\) \(O(1)\) 更优 是(max)

HyperLogLog vs. Bloom Filter

这两者经常被混淆,但它们解决的是不同问题:

如果你需要同时回答两个问题,就需要同时维护两个数据结构。

HyperLogLog vs. Linear Counting

Linear Counting(Whang et al., 1990)使用一个 \(m\) 位的位图。每个元素哈希到一个位置并置 1。基数估计为 \(-m \ln(V/m)\),其中 \(V\) 是仍为 0 的位数。

Linear Counting 的精度与 HyperLogLog 相当,但它的空间是 \(O(n)\)——位图大小必须与基数同阶才能保证精度。当基数增长到位图接近全满时,精度急剧下降。

HyperLogLog 的空间是真正的 \(O(\log\log n)\),在任意基数范围内都保持恒定的相对误差。

HyperLogLog vs. 精确计数

在什么情况下应该使用 HyperLogLog 而非精确计数?

用 HyperLogLog: 基数可能很大(百万级以上),内存预算有限,能接受约 1% 的误差,需要合并多个计数器。

用精确计数: 基数不大(几千以下),需要 100% 精确,需要知道具体有哪些元素(不只是数量),需要支持删除。

一个常见的工程模式是两者结合:先用 HyperLogLog 做粗略估计,如果估计值超过阈值则触发精确计数流程。

十、完整 C 语言实现

以下是一个完整的 HyperLogLog 实现,约 200 行,包含所有核心操作。

#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <stdio.h>

/* --------------------------------------------------------
 *  HyperLogLog implementation
 *  p = 14, m = 16384 registers, 6-bit each => 12KB
 * -------------------------------------------------------- */

#define HLL_P          14
#define HLL_M          (1 << HLL_P)        /* 16384 */
#define HLL_ALPHA      0.7213 / (1.0 + 1.079 / HLL_M)
#define HLL_REG_MAX    51                   /* max ρ for 64-bit hash, p=14 */

typedef struct {
    uint8_t registers[HLL_M];
} HyperLogLog;

/* ---- MurmurHash3 finalizer (64-bit mix) ---- */
static uint64_t hll_hash(const void *key, int len) {
    const uint8_t *data = (const uint8_t *)key;
    uint64_t h = 0x5bd1e995ULL;
    for (int i = 0; i < len; i++) {
        h ^= (uint64_t)data[i];
        h *= 0xc6a4a7935bd1e995ULL;
        h ^= h >> 47;
    }
    h *= 0xc6a4a7935bd1e995ULL;
    h ^= h >> 47;
    return h;
}

/* ---- Count leading zeros in the lower (64-p) bits ---- */
static uint8_t hll_rho(uint64_t hash) {
    /* We look at bits [0 .. 64-HLL_P-1] of the remaining part.
     * ρ = position of the first 1-bit (1-indexed), or max+1 if all zero. */
    uint64_t w = hash & ((1ULL << (64 - HLL_P)) - 1);
    if (w == 0)
        return (uint8_t)(64 - HLL_P + 1);  /* all zeros */
    /* __builtin_ctzll: count trailing zeros (we treat LSB as leftmost) */
    return (uint8_t)(__builtin_ctzll(w) + 1);
}

/* ---- Initialize ---- */
void hll_init(HyperLogLog *hll) {
    memset(hll->registers, 0, sizeof(hll->registers));
}

/* ---- Add an element ---- */
void hll_add(HyperLogLog *hll, const void *elem, int len) {
    uint64_t h   = hll_hash(elem, len);
    uint32_t idx = (uint32_t)(h >> (64 - HLL_P));  /* top p bits as index */
    uint8_t  rho = hll_rho(h);

    if (rho > hll->registers[idx])
        hll->registers[idx] = rho;
}

/* ---- Estimate cardinality ---- */
double hll_count(const HyperLogLog *hll) {
    double sum = 0.0;
    int    zeros = 0;

    for (int j = 0; j < HLL_M; j++) {
        sum += 1.0 / (1ULL << hll->registers[j]);
        if (hll->registers[j] == 0)
            zeros++;
    }

    double alpha = HLL_ALPHA;
    double E = alpha * (double)HLL_M * (double)HLL_M / sum;

    /* Small range correction */
    if (E <= 2.5 * HLL_M) {
        if (zeros > 0) {
            E = (double)HLL_M * log((double)HLL_M / (double)zeros);
        }
    }

    /* Large range correction (for 32-bit hash; skip for 64-bit) */
    /*
    double two32 = 4294967296.0;
    if (E > two32 / 30.0) {
        E = -two32 * log(1.0 - E / two32);
    }
    */

    return E;
}

/* ---- Merge two HLLs (union) ---- */
void hll_merge(HyperLogLog *dst, const HyperLogLog *src) {
    for (int j = 0; j < HLL_M; j++) {
        if (src->registers[j] > dst->registers[j])
            dst->registers[j] = src->registers[j];
    }
}

/* ---- Estimate intersection via inclusion-exclusion ---- */
double hll_intersection_estimate(const HyperLogLog *a, const HyperLogLog *b) {
    /* |A ∩ B| ≈ |A| + |B| - |A ∪ B|
     * Note: this estimate can be very inaccurate for small intersections. */
    HyperLogLog merged;
    memcpy(&merged, a, sizeof(HyperLogLog));
    hll_merge(&merged, b);

    double count_a     = hll_count(a);
    double count_b     = hll_count(b);
    double count_union = hll_count(&merged);

    double intersection = count_a + count_b - count_union;
    return intersection > 0 ? intersection : 0;
}

/* ---- Debug: print register distribution ---- */
void hll_debug(const HyperLogLog *hll) {
    int dist[HLL_REG_MAX + 2];
    memset(dist, 0, sizeof(dist));
    for (int j = 0; j < HLL_M; j++)
        dist[hll->registers[j]]++;

    printf("Register distribution:\n");
    for (int i = 0; i <= HLL_REG_MAX; i++) {
        if (dist[i] > 0)
            printf("  rho=%2d : %d registers\n", i, dist[i]);
    }
}

/* ---- Memory usage ---- */
size_t hll_memory(void) {
    return sizeof(HyperLogLog);
}

/* ============================================================
 *  Demo / test
 * ============================================================ */

int main(void) {
    HyperLogLog hll;
    hll_init(&hll);

    printf("HyperLogLog demo (p=%d, m=%d, memory=%zu bytes)\n",
           HLL_P, HLL_M, hll_memory());

    /* Test 1: add known number of elements */
    int test_sizes[] = {100, 1000, 10000, 100000, 1000000};
    int n_tests = sizeof(test_sizes) / sizeof(test_sizes[0]);

    for (int t = 0; t < n_tests; t++) {
        hll_init(&hll);
        int n = test_sizes[t];

        for (int i = 0; i < n; i++) {
            char buf[32];
            int  len = snprintf(buf, sizeof(buf), "elem-%d", i);
            hll_add(&hll, buf, len);
        }

        double est = hll_count(&hll);
        double err = (est - n) / n * 100.0;
        printf("  n=%8d  estimate=%10.0f  error=%+.2f%%\n", n, est, err);
    }

    /* Test 2: merge two HLLs */
    HyperLogLog hll_a, hll_b;
    hll_init(&hll_a);
    hll_init(&hll_b);

    for (int i = 0; i < 50000; i++) {
        char buf[32];
        int  len = snprintf(buf, sizeof(buf), "a-%d", i);
        hll_add(&hll_a, buf, len);
    }
    for (int i = 25000; i < 75000; i++) {
        char buf[32];
        int  len = snprintf(buf, sizeof(buf), "a-%d", i);
        hll_add(&hll_b, buf, len);
    }

    HyperLogLog hll_merged;
    memcpy(&hll_merged, &hll_a, sizeof(HyperLogLog));
    hll_merge(&hll_merged, &hll_b);

    printf("\n  Merge test:\n");
    printf("    |A|=%10.0f (expect 50000)\n", hll_count(&hll_a));
    printf("    |B|=%10.0f (expect 50000)\n", hll_count(&hll_b));
    printf("    |A∪B|=%8.0f (expect 75000)\n", hll_count(&hll_merged));
    printf("    |A∩B|≈%7.0f (expect 25000)\n",
           hll_intersection_estimate(&hll_a, &hll_b));

    /* Test 3: duplicate insensitivity */
    hll_init(&hll);
    for (int round = 0; round < 10; round++) {
        for (int i = 0; i < 10000; i++) {
            char buf[32];
            int  len = snprintf(buf, sizeof(buf), "dup-%d", i);
            hll_add(&hll, buf, len);
        }
    }
    printf("\n  Duplicate test: 10000 unique elements inserted 10 times\n");
    printf("    estimate=%10.0f (expect ~10000)\n", hll_count(&hll));

    return 0;
}

编译与运行

gcc -O2 -o hll hyperloglog.c -lm
./hll

典型输出:

HyperLogLog demo (p=14, m=16384, memory=16384 bytes)
  n=     100  estimate=       100  error=+0.00%
  n=    1000  estimate=       999  error=-0.10%
  n=   10000  estimate=      9960  error=-0.40%
  n=  100000  estimate=    100260  error=+0.26%
  n= 1000000  estimate=    997614  error=-0.24%

  Merge test:
    |A|=     49821 (expect 50000)
    |B|=     49763 (expect 50000)
    |A∪B|=   74902 (expect 75000)
    |A∩B|≈   24682 (expect 25000)

  Duplicate test: 10000 unique elements inserted 10 times
    estimate=     10021 (expect ~10000)

十一、工程陷阱

在生产环境中使用 HyperLogLog 时,以下是常见的陷阱和解决方案:

陷阱 现象 解决方案
哈希函数质量差 估计严重偏离,偏差不随数据量增长而收敛 使用密码学级别或高质量非密码学哈希(MurmurHash3、xxHash、CityHash);避免 Java 默认 hashCode()
哈希位数不足 大基数时估计饱和,无法区分 \(10^8\)\(10^9\) 使用 64-bit 哈希;如果使用 32-bit 哈希,大范围修正也有天花板
合并不同参数的 HLL 结果错误且无明确报错 所有参与合并的 HLL 必须使用相同的 \(p\) 和哈希函数;在序列化格式中包含参数校验
小基数精度差 基数 < 1000 时相对误差达 5-10% 使用 HLL++ 的偏差修正表或稀疏表示;对精度要求高的小基数场景考虑精确计数
高估交集基数 包含-排斥公式 \(\|A \cap B\| = \|A\| + \|B\| - \|A \cup B\|\) 误差放大 \(\|A \cap B\|\) 远小于 \(\|A\|\)\(\|B\|\) 时,包含-排斥的相对误差被放大;考虑使用 MinHash 估计 Jaccard 相似度再推算交集
忘记元素编码一致性 同一个 user_id 在不同服务中被编码为不同字节序列(如 UTF-8 vs. ASCII、大端 vs. 小端),导致被视为不同元素 规范化元素编码:统一字符编码、统一字节序、统一序列化格式
Redis PFCOUNT 多键性能 对数百个键执行 PFCOUNT key1 key2 ... 导致延迟飙升 PFMERGE 预合并到目标键,然后对目标键执行 PFCOUNT
寄存器位数选择错误 自行实现时使用 5-bit 寄存器,导致溢出 64-bit 哈希配 \(p = 14\) 时,\(\rho\) 最大值为 51,需要 6-bit 寄存器(范围 0-63)
序列化兼容性 不同语言/版本的实现使用不同的序列化格式,合并时出错 定义明确的二进制序列化协议(参考 Redis 的格式或 Google 的 Protobuf 格式);在头部包含版本号和参数
过度信任单次估计 运气差时单次估计偏差可达 3-4 个标准差 理解 0.81% 是标准误差(1σ),95% 置信区间约 \(\pm 1.62\%\),99.7% 约 \(\pm 2.43\%\);关键决策场景应多次采样或使用精确计数

十二、个人思考

概率数据结构的哲学

HyperLogLog 所属的概率数据结构家族(Bloom Filter、Count-Min Sketch、MinHash 等)共享一个深刻的洞见:在很多实际场景中,“大致正确”比”精确但昂贵”更有价值。

这与传统计算机科学教育的重点相悖。我们被教导追求精确算法、最优复杂度、零误差。但现实世界中,数据量的增长速度远超硬件的提升速度,而业务对精度的要求往往没有那么高。HyperLogLog 用 0.81% 的误差换取了 \(10^6\) 倍的内存节省——这不是”退而求其次”,而是一种理性的工程选择。

数学之美

我一直觉得,HyperLogLog 的数学推导是概率论在工程中最优美的应用之一。从”哈希值前导零的最大值可以估计基数”这个粗糙的直觉,到通过分桶和调和平均将其打磨成一个精度可控、误差可证明的工程工具,整个过程展示了数学分析如何将一个看似不靠谱的 trick 变成一个严谨的算法。

Flajolet 本人是解析组合学(Analytic Combinatorics)领域的大师。他对 HyperLogLog 精度的分析使用了梅林变换(Mellin transform)和鞍点法(saddle-point method)等高深的复分析工具。如果你有兴趣深入,强烈推荐他与 Robert Sedgewick 合著的《Analytic Combinatorics》一书——你会发现,HyperLogLog 只是他宏大理论框架中的一个小应用。

工程与理论的平衡

Google 的 HyperLogLog++ 论文让我印象最深的一点是:它没有改变任何核心数学,所有改进都是工程层面的。64-bit 哈希消除了一个边界条件问题;偏差修正表用实验数据弥补了理论公式在小范围内的不足;稀疏表示利用了实际工作负载的特征(大多数 HLL 实例的基数远小于寄存器数量)。

这提醒我们:好的工程不是”实现论文”,而是”在论文的基础上,针对真实工作负载做持续优化”。 理论给你正确性保证和渐近最优性,工程给你常数因子和边界条件的处理。两者缺一不可。

关于 12KB 的思考

12KB 这个数字本身就很有教育意义。它告诉我们:信息论意义上的下界和工程意义上的实际开销之间,可能存在巨大的鸿沟。朴素方案需要 GB 级内存,HyperLogLog 需要 12KB,两者都在解决同一个问题,差距是五个数量级。

这种”以空间换不确定性”的交易,在我看来是未来大规模数据处理的核心范式之一。当数据增长到一定规模,你将不得不接受近似结果——不是因为你不想要精确答案,而是因为精确答案的代价已经超过了它带来的边际价值。

HyperLogLog 证明了这种交易可以做得非常划算。

参考文献

  1. Flajolet, P., Martin, G. N. (1985). Probabilistic Counting Algorithms for Data Base Applications. Journal of Computer and System Sciences, 31(2), 182-209.

  2. Durand, M., Flajolet, P. (2003). LogLog Counting of Large Cardinalities. In Annual European Symposium on Algorithms (ESA), 605-617.

  3. Flajolet, P., Fusy, E., Gandouet, O., Meunier, F. (2007). HyperLogLog: the Analysis of a Near-Optimal Cardinality Estimation Algorithm. In Conference on Analysis of Algorithms (AofA), 127-146.

  4. Heule, S., Nunkesser, M., Hall, A. (2013). HyperLogLog in Practice: Algorithmic Engineering of a State of the Art Cardinality Estimation Algorithm. In International Conference on Extending Database Technology (EDBT), 683-692.

  5. 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.

  6. Redis Documentation. PFADD / PFCOUNT / PFMERGE. https://redis.io/commands/pfadd

  7. Flajolet, P., Sedgewick, R. (2009). Analytic Combinatorics. Cambridge University Press.

  8. Antirez (Salvatore Sanfilippo). Redis new data structure: the HyperLogLog. http://antirez.com/news/75


算法系列导航上一篇:Unicode 算法 | 下一篇:Count-Min Sketch

相关阅读Bloom Filter 全家族 | 流式算法总论


By .