← blog 技术原理与实践 · 2026-09-12

HyperLogLog 详解:从抛硬币直觉到 Redis 12KB 做 UV 统计

从抛硬币直觉推导 HyperLogLog 的哈希分桶、调和平均与偏差修正,拆解 Redis 的 12KB 定长寄存器与 sparse/dense 编码,并给出用 HLL 统计网站 UV 的标识符选型、按天分 key、PFMERGE 区间去重与三条工程注意点。

13 min read

HyperLogLog(HLL)是 Redis 里用来做基数估计(统计不重复元素个数)的概率数据结构,核心卖点是:无论塞进去多少元素,内存固定只占 12KB(16384 个 6-bit 寄存器),标准误差约 0.81%。它不存原始元素,所以能统计的量级远超内存上限(理论上到 2⁶⁴)。

下面按「直觉 → 算法 → Redis 实现细节 → UV 落地」的顺序展开。


1. 直觉:抛硬币问题

想象你抛一枚均匀硬币,记录第一次出现正面「1」之前连续抛出了几个反面「0」。

  • 连续 k 个反面后才出现正面的概率是 2⁻ᵏ
  • 所以「连续 10 个反面」这个事件,大概要抛 2¹⁰ = 1024 次才会遇到一次;「连续 20 个反面」大概要抛 2²⁰ ≈ 100 万次

反过来看:如果你看到一个哈希值开头有 k 个连续的 0,那就强烈暗示你手上大概有 2ᵏ 个不同的元素。这就是全部直觉的起点——「最长前置零串长度」可以作为去重数量的估计器。

2. 核心算法

第一步:哈希

每个元素(例如 user:123)经过哈希函数(Redis 用 MurmurHash64A)映射成一个 64 位均匀分布的二进制串。哈希保证:同一个元素永远得到同一个串,不同元素概率上均匀散布。

第二步:分桶(stochastic averaging,随机平均)

只用「全局最长零串」方差太大,所以把哈希值拆成两段:

  • 前 14 位 → 桶索引(Redis 有 2¹⁴ = 16384 个寄存器/register)
  • 后 50 位 → 统计前置零个数

每个寄存器 M[j] 只保存「掉进桶 j 的元素里,最大的前置零个数」。插入元素时:

idx = hash 前 14 位
zeros = hash 后 50 位的前置零个数
if zeros > M[idx]: M[idx] = zeros    # 只更新,只增不减

第三步:调和平均估计

最终估计公式(Flajolet et al.):

E = α_m · m² / Σ( 2^(-M[j]) )     # m = 16384, α_m ≈ 0.7213/(1+1.079/m)

用调和平均而不是算术平均或最大值,是因为它对方差更鲁棒,配合修正系数 α_m 后无偏性最好。

第四步:偏差修正

  • 小基数修正:当估计值 < 2.5m 且还有空寄存器(V 个)时,改用 linear counting:E = m · ln(m/V),消除小数据时的系统性偏差
  • 大基数修正:当估计值极大时加 -2⁶⁴·ln(1 - E/2⁶⁴) 修正(64 位哈希下实际几乎不会触发)

误差上界:1.04/√m = 1.04/128 ≈ 0.81%,这是概率保证,不随数据量增长而变化。

3. Redis 的具体实现

内存布局

  • 16384 个寄存器 × 6 bit = 98304 bit = 12KB,固定不变
  • 寄存器最大值是 50(64 位哈希去掉 14 位桶索引后剩 50 位),6 bit 足够存(最大 63)

稀疏编码(sparse)

刚创建时寄存器几乎全空,直接用 12KB 太浪费。所以 Redis 先用压缩的 sparse 表示(把连续的 0 寄存器做游程编码,约 2 bit/寄存器),只有当编码后大小超过 3000 字节(HLL_SPARSE_MAX_BYTES)才转成 dense(12KB 固定数组)。这也是 PFADD 大量数据时偶尔会变慢的原因——触发了编码转换。

三个命令

命令行为
PFADD key ele...哈希并更新寄存器(只增)
PFCOUNT key用公式算基数,结果缓存在 HLL 头部(cardinality cache),源数据变了才重算
PFMERGE dest src...逐寄存器取 max(即合并),合并后同样写缓存

使用示例:

PFADD users:2024-06-01 user:1 user:2 user:3
PFCOUNT users:2024-06-01   # 3
PFADD users:2024-06-02 user:2 user:4
PFMERGE users:uv users:2024-06-01 users:2024-06-02

典型场景:UV 统计、IP 去重、爬虫 URL 去重计数。

4. 和其他结构对比

结构内存是否精确能否删元素
Set与元素数成正比精确能
Bloom Filter固定但较大假阳性(可能误判存在)不能
HyperLogLog固定 12KB有误差(±0.81%)不能(只能增)

局限:

  • 只能插入不能删除(寄存器只增不减)
  • 近似而非精确
  • 结果由哈希而来,无法从 HLL 里查回原始元素值——它根本不存元素

一句话总结:HLL 用 12KB 换来了「海量数据去重计数」的能力,代价是把精确答案换成 0.81% 误差内的近似值,以及不可删除。

想看源码的话,核心在 Redis 的 src/hyperloglog.c(约 1500 行,注释详细,是理解上文所有公式的最佳现场)。


5. 用 HyperLogLog 统计网站 UV

前面讲的是数学内核(哈希 + 分桶 + 调和平均),这一部分聚焦**「UV 问题怎么映射成 HLL 能算的东西」**,以及工程上怎么落地。

5.1 核心映射:一次访问 → 一个「元素」

UV 的定义是去重后的不同访客数。HLL 恰好只认「元素」——它不管元素出现了多少次,只在乎「这个值我见没见过」。

所以第一步是:给每个访客挑一个稳定标识符,作为 PFADD 的元素:

访客 V 访问页面
  → PFADD uv:2024-06-01  V

关键语义:同一个访客一天访问 100 次,每次都哈希出同一个值,寄存器内容一模一样,所以只算 1 次。HLL 天然去重,这是它和 PV(计数器,每次都 +1)的本质区别。

标识符怎么选,直接决定统计质量:

标识符优点缺点
user_id(登录用户)最准未登录的匿名访问算不进
cookie / device_id覆盖全部访问同一人换设备或清 cookie 会被算成多个
IP无需埋点NAT / 公司出口共享 IP 会严重低估

现实做法通常是「cookie + user_id 合并」取较优值,并接受误差。

5.2 时间维度:三种 UV 和它们的做法

网页统计里几乎总需要按时间维度拆分:

① 每日 UV——一个 key 对应一天,当天访问就 PFADD day_{date} 访客。

② 自然增长型累计 UV(比如「产品上线以来总访客」)——同一个 key 一直往里加,永不 reset。

③ 区间 UV(比如「近 7 天去重访客」)——这是 HLL 的杀手锏场景,靠的是它的可合并性:

PFMERGE uv:last7 uv:2024-06-01 uv:2024-06-02 ... uv:2024-06-07
PFCOUNT uv:last7

逐寄存器取 max 合并,合并成本与数据量无关、只和寄存器数(16384)有关。而且 Redis 更贴心——PFCOUNT 本来就能直接接多个 key,内部先 merge 再算,一条命令搞定:

PFCOUNT uv:2024-06-01 ... uv:2024-06-07

对比一下:不用 HLL 的话,区间去重要么把 7 天明细拉出来塞 Set(内存爆炸),要么用一个带窗口的布隆(删不了旧的)。HLL 用「每天一个 12KB 的 key + 一次合并」优雅解决,而且天然满足跨天去重:同一个人周一看过、周三又看过,合并后只算一次。

5.3 一个完整的埋点流水

[Nginx/CDN 日志] ——(异步消费)--> [ClickHouse/队列]
                                     |
                    解析出 { 访客ID V, 日期 D }
                                     |
                    对每个 (日期, 访客) 去重后:
                      PFADD uv:{D}  V

报表层:
  今日UV    = PFCOUNT uv:2024-06-01
  7日UV     = PFCOUNT uv:06-01 ... uv:06-07
  近30日UV  = PFCOUNT 30 个 key(或先 PFMERGE 再 count,结果缓存更快)

5.4 工程上的三个实战注意点

  1. 合理分桶粒度:key 不要只按天分,很多团队按 (天, 站点/频道) 分,这样能自由组合出任意区间的去重,合并是 O(16384) 量级,组合再多也不怕。

  2. 不要在高频热路径里重复 PFCOUNT:PFCOUNT 结果缓存在 HLL 头部,同一 key 短期内重复计数几乎零成本;但跨 30 个 key 的区间计数每次都要 merge,量大的话建议计一次、缓存到报表层(比如每分钟刷新一次即可,UV 对延迟不敏感)。

  3. 误差心里要有数:±0.81% 是概率保证,对 UV 报表(几十万量级,差几百人)完全可接受,但计费、KPI 对赌、法律要求的精确数不要用 HLL——那种场景用精确去重(bitmap 或明细表),数据量不够大时成本也不高。

5.5 方案对比小结

方案内存精确度区间去重明细适合
明细表 COUNT DISTINCT最大精确最灵活有量小 / 要精确
Set大精确可有单日几十万内
HLL每 key 12KB 固定±0.81%合并 O(16384)无海量 UV、跨天去重
Bitmap依赖用户 id 上限精确(若 id 连续)可无id 可压缩成位的场景

一句话总结:HLL 做 UV 的本质,是把「第 n 次见到这个访客」压缩成对 16384 个寄存器里某个计数器的单调上升更新;UV 去重的正确性来自「同一标识符哈希恒等、寄存器只增不减」,区间 UV 的效率来自「合并 = 逐寄存器取 max」。12KB 换一个 0.81% 误差内的答案,正是 UV 这种「量很大、不要求精确到最后一个人」的场景该付出的代价。

顺带一算:如果按天建 key,一年就是 365 个 key × 12KB ≈ 4.4MB,整年 UV 数据躺在内存里都没压力——这也是它在实时看板里几乎成为默认选项的原因。

Sources

No external sources for this entry.

Related