HyperLogLog 深度实战:从 Flajolet-Martin 草图、调和均值估计到 HLL++ 稀疏表示与可合并基数估计的工程全解

引言

在大数据与流式系统里,"集合里有多少个不同元素"(基数 / Cardinality)是一个看似简单、实则昂贵的问题。直接 SELECT COUNT(DISTINCT user_id) 在大表上意味着全量扫描 + 去重排序,在 TB 级数据、每秒百万事件的实时管道里既慢又烧内存。更麻烦的是:基数本身往往只需要近似答案——DAU 是 1,283,400 还是 1,284,000 对决策毫无影响,但为此付出一次全表扫描的代价却难以接受。

HyperLogLog(HLL) 就是为这个问题而生的概率数据结构:它用约 12KB 内存,以 1% 左右的标准误差估计高达 10^9 量级集合的基数,且支持可合并(mergeable)——多个分片各自维护草图,最后按桶取 max 即可无损合并出全局基数。它与本站已发的《布隆过滤器》(16214)、《Count-Min Sketch》(16270) 共同构成"概率数据结构工程"三部曲:Bloom 回答"是否在集合中",Count-Min 回答"某元素频次多少",HLL 回答"集合有多大"。本文从第一性原理推导 HLL 的调和均值估计与偏差校正,落地 HLL++ 的稀疏表示,给出可合并的生产级实现与 12 项工程陷阱清单。

一、为什么不能 COUNT(DISTINCT)

精确去重的成本随数据量超线性增长:要么维护一个哈希集合(内存 O(n)),要么排序后相邻比较(CPU O(n log n) + 磁盘 IO)。在以下场景精确法直接出局:

  • 实时大屏:每秒百万条事件,要求秒级更新 UV。全量去重无法在窗口内完成。
  • 跨分片聚合:数据分散在 1000 个分片,精确去重需把所有 user_id shuffling 到单点,网络与内存爆炸。
  • 长周期留存:7 日/30 日 UV,状态必须在内存中长期驻留。

近似基数算法的核心思想:用哈希值的统计特征代替存储元素本身。只要哈希足够均匀,某个比特模式的出现频率就携带了基数的信息。

二、概率计数演化:FM → LogLog → HyperLogLog

2.1 Flajolet-Martin(FM)草图

Flajolet 与 Martin 在 1985 年给出第一个概率计数方案。对集合中每个元素 x:

  1. 计算一个充分随机的哈希 h(x)(如 64 位)。
  2. 记 ρ(w) 为 w 的二进制表示中从最低位起第一个 1 的位置(从 1 计数),即尾随零的个数 + 1。
  3. 所有元素中 ρ(h(x)) 的最大值大致正比于 log2(N)。

直觉:若已看到 N 个不同元素,哈希空间被填充到约 2^N 量级,才会出现"末尾连续 N 个 0"的模式。于是估计 N ≈ 2^{max ρ}。FM 取多个独立哈希的均值降低方差。

2.2 LogLog 计数

LogLog 引入分桶(bucket) 思想:用哈希高 p 位把元素映射到第 j ∈ [0, m) 个桶(m = 2^p),每个桶独立记录 ρ 的最大值 M[j]。估计值为各桶 2^{M[j]} 的算术平均再乘以常数:


E = α_m · m · (1/m) · Σ 2^{M[j]}   (LogLog 用算术平均)

2.3 HyperLogLog 的关键改进

LogLog 的算术平均对个别异常大的桶非常敏感——一个哈希碰撞导致的超大 M[j] 会严重高估。HyperLogLog(Flajolet 等,2007)用调和平均替代算术平均:


E = α_m · m^2 / Σ 2^{-M[j]}

调和平均天然压制离群桶(分母中 2^{-M[j]} 对大桶很小,权重被压低),把标准误差从 LogLog 的 1.30/√m 降到 1.04/√m,在同等内存下估计精度提升约 25%。

三、核心数学推导

3.1 哈希与分桶

取一个 64 位均匀哈希 h(x)。用低 p 位(或高 p 位,实现自由)定位桶:


j = h(x) & (m - 1)          # m = 2^p,p 位桶索引
w = h(x) >> p               # 剩余 64-p 位用于计数
ρ(w) = 1 + (w 的二进制中从最低位起第一个 1 的位置)   # 即尾随零个数 + 1
M[j] = max(M[j], ρ(w))      # 每桶只保留最大 rank

工程要点:ρ 的分布是几何的。ρ = k 的概率为 2^{-k},期望约 2。一个桶长期观察不到大 rank 是常态,调和平均正是为此而生。

3.2 原始估计量


E = α_m · m^2 / Σ_{j=0}^{m-1} 2^{-M[j]}

其中 α_m 是随 m 变化的偏差校正常数(对有限 m 的修正),常用取值:


α_m = (m · (∫_0^∞ (log2(1+t)/t)^m dt)^{-1})^{-1}

小 m 时的实用近似:

m 16 32 64 128 256 1024 16384
α_m 0.673 0.697 0.709 0.715 0.719 0.728 0.730

3.3 标准误差

无论基数多大,HLL 的相对标准误差恒为:


σ_E / E ≈ 1.04 / √m
p m 内存(1B/桶) 标准误差
10 1024 1 KB 3.25%
12 4096 4 KB 1.63%
14 16384 12 KB 0.81%
16 65536 64 KB 0.41%

注意:实际实现中每桶常存 5–6 位(rank 上限约 64,需 6 bit),故 12KB 对应 16384 × 6 bit = 12 KB。"12KB 估 10^9 基数误差 <1%" 是 HLL 最经典的卖点。

四、偏差校正:从原始 HLL 到 HLL++

原始 HLL 在小基数时偏差显著(调和平均对稀疏桶不稳定),在大基数时接近理论极限。两处需校正。

4.1 小基数:线性计数混合

当很多桶仍为 0(说明基数远小于 m)时,用 Linear Counting 估计更准:


E_LC = m · log(m / V)     # V = 值为 0 的桶数量

实践(HLL 原始论文)按 E 与 m/30、5m/2 的阈值切换:极小时用 LC,中等用原始 HLL,极大时(接近 2^32)做 64 位修正。

4.2 HLL++(Google,Heule et al. 2013)的三大增强

  1. Empirical Bias Correction:用大量离线实验测量不同基数下的真实偏差,建表(或分段多项式)在运行期减去。这是 HLL++ 把误差再压一半的关键。
  2. Sparse 表示:基数小时绝大多数桶为 0,改为只存"非零桶 (index, rank)"的稀疏列表,用变长编码(varint)压缩,内存可省一个数量级;超过阈值再切换为 Dense。
  3. 64 位哈希 + 稀疏/dense 无缝合并:保证稀疏与密集两种内部表示能正确互操作。

工程现实:Redis 的 PFCOUNT/PFMERGE(HyperLogLog 结构)即 HLL 的实用实现;Spark 的 approx_count_distinct、BigQuery 的 APPROX_COUNT_DISTINCT、Apache DataSketches 均基于 HLL/HLL++ 家族。

五、可合并性:分布式基数的基石

HLL 最被低估的能力是逐桶 max 合并:


M_merged[j] = max(M_a[j], M_b[j])    for all j

因为每桶 M[j] 存的是"该桶见过的最大 rank",合并两个集合的草图只需逐桶取 max,与合并顺序无关、无信息损失(不像均值需要权重)。这让 HLL 天然适配:

  • 分片聚合:每个 worker 维护本地 HLL,shuffle 时只传 12KB 草图而非原始 ID。
  • 流式窗口:每分钟一个 HLL,按时间窗 max-merge 得到任意区间 UV。
  • 增量更新:新事件只更新对应桶,O(1)。

六、与 Bloom / Count-Min / HLL 的分工

结构 回答的问题 是否可合并 典型误差 内存(典型)
布隆过滤器 "x 是否在集合?" 否(交集/并集受限) 误判率可调 1.2KB@1%
Count-Min Sketch "x 出现多少次?" 是(逐格 min/加) 有偏上界估计 随 ε/δ
HyperLogLog "集合有多大(去重数)?" 是(逐桶 max) 1.04/√m [email protected]%

三者可组合: Bloom 挡住"必不存在"的查询,Count-Min 给出频次做限流/热点识别,HLL 给出全局 UV 做大盘——同一套流式管道里各司其职。

七、生产级 Python 实现

下面给出一个可合并、带稀疏/密集切换与序列化的参考实现(教学向,强调正确性而非极致性能)。


import hashlib
import math
import struct

class HyperLogLog:
    def __init__(self, p=14):
        if not (4 <= p <= 16):
            raise ValueError("p must be in [4,16]")
        self.p = p
        self.m = 1 << p
        self.alpha = self._alpha(self.m)
        self.registers = [0] * self.m          # dense 桶
        self.sparse = {}                        # index -> rank  (稀疏模式)
        self.sparse_limit = max(64, self.m // 4)  # 超过则转 dense

    @staticmethod
    def _alpha(m):
        if m == 16: return 0.673
        if m == 32: return 0.697
        if m == 64: return 0.709
        return 0.7213 / (1 + 1.079 / m)

    def _hash(self, x):
        # 64 位哈希:用 sha1 取前 8 字节
        if isinstance(x, str):
            x = x.encode('utf-8')
        h = hashlib.sha1(x).digest()[:8]
        return int.from_bytes(h, 'big') & ((1 << 64) - 1)

    @staticmethod
    def _rho(w, max_bits=64):
        # 从最低位起第一个 1 的位置(1-based);全 0 则记为 max_bits+1
        if w == 0:
            return max_bits + 1
        return (w & (-w)).bit_length()

    def add(self, x):
        h = self._hash(x)
        idx = h & (self.m - 1)
        w = h >> self.p
        rank = self._rho(w)
        if self.sparse is not None:
            cur = self.sparse.get(idx, 0)
            if rank > cur:
                self.sparse[idx] = rank
            if len(self.sparse) > self.sparse_limit:
                self._to_dense()
        else:
            if rank > self.registers[idx]:
                self.registers[idx] = rank

    def _to_dense(self):
        for idx, rank in self.sparse.items():
            if rank > self.registers[idx]:
                self.registers[idx] = rank
        self.sparse = None

    def count(self):
        if self.sparse is not None and len(self.sparse) <= self.sparse_limit:
            # 稀疏模式:先尝试 Linear Counting
            M = self.registers[:]
            for idx, rank in self.sparse.items():
                M[idx] = max(M[idx], rank)
            zeros = sum(1 for v in M if v == 0)
            if zeros > 0:
                lc = self.m * math.log(self.m / zeros)
                if lc <= 2.5 * self.m:    # 小基数用 LC 更准
                    return lc
        else:
            M = self.registers

        # 调和均值估计
        sm = sum(2.0 ** (-v) for v in M)
        E = self.alpha * (self.m ** 2) / sm
        # 大基数 64 位修正
        if E > (1 << 32) / 30:
            E = -((1 << 32)) * math.log(1 - E / (1 << 32))
        return E

    def merge(self, other):
        if self.p != other.p:
            raise ValueError("p must match to merge")
        if other.sparse is not None:
            for idx, rank in other.sparse.items():
                self.add_from_idx_rank(idx, rank)
        else:
            for idx, rank in enumerate(other.registers):
                if rank:
                    self.add_from_idx_rank(idx, rank)
        return self

    def add_from_idx_rank(self, idx, rank):
        if self.sparse is not None:
            cur = self.sparse.get(idx, 0)
            if rank > cur:
                self.sparse[idx] = rank
            if len(self.sparse) > self.sparse_limit:
                self._to_dense()
        else:
            if rank > self.registers[idx]:
                self.registers[idx] = rank

使用示例:


h1 = HyperLogLog(p=14)
for u in users_shard_a:
    h1.add(u)

h2 = HyperLogLog(p=14)
for u in users_shard_b:
    h2.add(u)

merged = HyperLogLog(p=14)
merged.merge(h1).merge(h2)        # 可合并:逐桶 max
print(int(merged.count()))        # 全局近似去重数

八、12 项生产陷阱清单

  1. 哈希必须 64 位且均匀:用 32 位哈希(如 CRC32)在基数 > 2^32 时必然撞车,估计直接塌方;优先 Murmur3 / XXH3 / SHA 截断。
  2. p 一旦选定不可改:合并要求两边 m 完全相同,否则 merge 直接报错;线上变更 p 需全量重建草图。
  3. 跨语言一致性:Java/Go/Redis/Python 若哈希函数或 ρ 的位序(大端/小端)不同,合并结果无意义——统一哈希与字节序。
  4. 稀疏/密集切换阈值:未设阈值或阈值过高,小基数时内存反而膨胀;HLL++ 的稀疏模式是省内存的关键,但切换瞬间有一次性成本。
  5. 估计是有偏的:HLL 给期望近似而非区间;对外展示建议同时给出 ±1.04/√m 的误差带,避免被当成精确数。
  6. 序列化兼容:草图要持久化/跨进程传输,必须定义稳定格式(寄存器数组 + p + 模式),版本升级时做兼容。
  7. 空集合处理:全 0 桶时 Σ 2^{-M} = m,公式退化;务必在 count() 里对空/极小基数特判(LC 或返回 0)。
  8. 大基数 64 位修正:接近 2^32 时原始公式会溢出/失真,必须做 −2^32·ln(1−E/2^32) 修正。
  9. 不要对"已估计值"再算术平均:多分片合并必须 merge 草图再 count,绝不可先各算 count 再平均(丢失桶级信息,误差翻倍)。
  10. precision 与内存权衡:p=14(12KB)是线上甜点;p=16(64KB)换 0.4% 误差,仅在精度敏感场景使用。
  11. 哈希碰撞≠元素重复:HLL 统计的是"哈希去重",若业务把不同元素映射到同一哈希(碰撞),会被错误合并——这是概率法的固有代价。
  12. 监控与校准:定期用已知基数做离线回放,验证实际误差是否符合 1.04/√m,防止哈希退化或实现 bug 悄悄放大偏差。

九、落地场景速查

  • UV / DAU 大屏:每服务实例本地 HLL,分钟级 max-merge 出全局 UV。
  • 去重 SQL 加速:APPROX_COUNT_DISTINCT 替代 COUNT(DISTINCT),TB 表从分钟级降到秒级。
  • 爬虫/广告去重:估算"已见过的 URL/设备"规模,决定采样率。
  • 数据质量:异构数据源的"重叠度"粗估(两集合草图合并后对比各自基数)。

结语与系列衔接

HyperLogLog 用 12KB 内存把"10 亿级去重计数"从不可行变成秒级近似,其调和均值估计、偏差校正与逐桶可合并性,是概率数据结构工程的典范。它与《布隆过滤器》(16214)、《Count-Min Sketch》(16270) 一起,构成了"用极小空间回答大问题的三件套"。

下一篇可顺着"可合并近似聚合"继续深入 T-Digest(分位数估计) 或 Count-Min 的孪生 brother——Count Sketch(中位数去偏),把"近似大数据全景"补齐。生产落地时,请务必回到第八节的 12 项陷阱清单逐条核对——绝大多数 HLL 线上事故,都源于哈希不一致或合并前先平均。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
/* 跳过导航链接 (无障碍) */ position: absolute; top: -100px; left: 15px; z-index: 99999; padding: 8px 16px; background: #007bff; color: #fff; font-size: 14px; border-radius: 0 0 4px 4px; text-decoration: none; transition: top 0.2s; } top: 0; outline: 3px solid #0056b3; }