引言:当 Set 遇到 10 亿用户
你负责开发一款日活千万级的 App。产品经理找到你,要一个实时显示“今日独立访客数”的数据看板。
第一反应是:用 Redis 的 Set 结构存储所有用户 ID,最后 SCARD 一下。这个思路简单直接,但在大数据量下会迅速崩溃——假设有 1 亿用户,每个 ID 按 16 字节算,一天就需要 1.6GB 内存;如果同时统计多个页面,内存开销将直线上升至几十 GB,服务器根本扛不住。
问题的本质在于,传统精确计数方案的内存占用与数据规模呈线性关系。当数据量达到亿级乃至十亿级,精确统计变得不切实际。
而实际上,对于大多数业务场景来说,我们并不需要精确到个位数的 UV。“知道一个非常接近真实值、误差很小的估计值就足够了。”这正是概率型数据结构大放异彩的地方——HyperLogLog(简称 HLL)仅需约 12KB 的固定内存,就能估算亿级甚至十亿级数据的不重复元素数量(即基数),标准误差仅为 **0.81% **。
一、什么是基数统计,为什么它如此重要
在深入算法原理之前,先明确两个核心概念。
**基数(Cardinality) ** 是指一个集合中不同元素的数量。比如,集合 {A, B, A, C, B} 中有 3 个不同元素,它的基数就是 3。
基数统计 在互联网场景中应用广泛:网站独立访客数、App 日活月活用户数、广告曝光的去重人数、搜索引擎的独立搜索词数等。
对于小数据量,精确计数不成问题。但当数据规模达到亿级以上时,精确计数面临两个核心挑战:一是内存爆炸——需要存储所有已见过的元素来去重,内存消耗与数据规模成正比;二是性能瓶颈——大数据量下的去重计算耗时严重。
HyperLogLog 正是为解决这两个挑战而生的概率型数据结构:它不存储原始数据,只存储一个约 12KB 的“摘要信息”,通过概率统计方法估算出集合的基数。
二、从抛硬币到 HyperLogLog:算法演进之路
理解 HyperLogLog 需要一个循序渐进的过程。我们从最简单的概率思维开始,逐步构建完整的认知。
2.1 抛硬币实验
想象一个实验:你不停地抛一枚公平的硬币,直到它第一次出现正面朝上。记录这个次数 K。比如结果是 反,反,正,那么 K = 3。这个实验重复很多次。你会发现,出现 K 很大的情况(比如连续10次反面)是非常罕见的。
反过来,如果你在一次实验中观察到了一个很大的 K,那么我们可以合理地推断,这个实验很可能已经进行了很多很多次。
2.2 从观察“硬币”到观察“哈希值”
HLL 就是基于这个直觉。但它不抛硬币,而是抛“哈希函数”:
哈希转换:将输入的每个元素(如用户ID)通过一个哈希函数处理,得到一个固定长度的二进制串(例如64位)。这个哈希值可以看作是“抛硬币”的一系列结果。哈希值的每一位都类似于一次抛硬币:0 可以代表反面,1 代表正面。寻找“第一次正面”:HLL 关心的是,从这个二进制串的低位(或高位,约定俗成即可)开始看起,第一次出现 1 的位置(即第一个 1 所在的比特位序号)。
比如:哈希值二进制为 ...1000 0000(共64位)。我们从最低位(最右边)向高位(左边)寻找第一个 1。
位置0(最右位):是0 -> 不是。位置1:是0 -> 不是。...位置7:是1 -> 找到了!
那么这次实验的 K = 7 + 1 = 8(如果从1开始计数)。在HLL中,这个值通常记为 ρ (rho),即前导零的个数加一。如果二进制是 ...001,前导零个数为2,则 ρ = 3。
这个 ρ 值就是我们观测到的“第一次出现正面的次数”。一个很大的 ρ 值意味着这个哈希结果非常罕见,从而暗示了我们可能已经看到了很多元素。
2.3 从单一估计到分桶策略:降低偶然误差
但只用一个最大值来做估计存在严重问题:如果运气特别好,某一次哈希出现了极长的连续 0,整个估算就会严重偏大。
为了降低偶然性,HLL 引入了 **“分桶” ** 思想:把哈希值的前 p 位作为“桶编号”(相当于划分到不同的独立实验组),把后 64-p 位用来计算前导零。每个桶独立记录自己见过的最大前导零数量,最后汇总时计算所有桶的调和平均值,并乘以一个数学修正因子,得到最终的基数估计值。
这种“分桶 + 调和平均”的设计极大地提升了估计的稳定性——即使某个桶运气特别好,也不会过度影响全局结果。
原始的 LogLog 算法估算公式是:n ≈ constant * m * 2^(算术平均 of M[1..m])
HyperLogLog 算法相比 LogLog 的最终改进是:使用调和平均数而不是算术平均数。
算术平均:对异常值(极端大的数)敏感。一个非常大的 ρ 值会严重拉高算术平均,从而导致估算值偏差巨大。
调和平均:H = n / (1/x1 + 1/x2 + ... + 1/xn)。它对小值更敏感,能有效地平滑掉大值带来的负面影响。
因此,HLL 的最终估算公式为:
n ≈ (constant * m²) / (∑{i=1}^{m} 2^{-M[i]})
其中 constant 是一个修正常数,用于修正系统偏差。
这个小小的改进(使用调和平均)极大地提升了估算精度,使其在相同空间下误差率更低,
2.4 12KB 的精确由来:误差与内存的平衡
Redis 的 HLL 选择 16384(2^14)个桶,并不是拍脑袋决定的。桶的数量 m 与标准误差 ε 之间存在明确的数学关系:ε ≈ 1.04 / √m。将目标误差率 0.81%(约 0.008)代入公式反推,可以得到 m ≈ 16384。
每个桶需要存储的最大前导零数量范围是 0~50(因为哈希值用 50 位来计算前导零),因此需要 6 位 来存储(2^6=64≥50)。总内存就是:16384 个桶 × 6 位 = 98304 位 = 12288 字节 = 12KB。这 12KB 正是误差率与内存占用之间经过精密数学计算后的最优平衡点。
2.5 算法演进简史
HyperLogLog 并非一夜之间诞生的。它的前身包括 Linear Counting(线性计数法)和 LogLog Counting。Linear Counting 用一个位数组跟踪不同哈希值,适用于小数据量,但空间需求随数据量线性增长。LogLog Counting 率先使用了对数计数方法,大大降低了空间需求。HyperLogLog 在 LogLog 的基础上,通过调和平均数替换几何平均数、引入偏差校正等方法,将误差进一步缩小到约 0.81%。此后,Google 还提出了 **HyperLogLog++ **,增加了稀疏表示和更精细的偏差校正,是 HLL 在 BigQuery 等系统中的增强版本。
三、Redis 中的 HyperLogLog:设计与实现
3.1 内存中的数据结构
Redis 将 HyperLogLog 作为一种特殊的字符串进行存储,其内部数据结构定义如下:
struct hllhdr {
char magic[4]; // 固定为"HYLL",标识这是 HLL 结构
uint8_t encoding; // 编码方式:0=稠密,1=稀疏
uint8_t notused[3]; // 保留字段,必须为 0
uint8_t card[8]; // 缓存的基数估计值
uint8_t registers[]; // 16384 个寄存器数据
};
magic 字段用于快速识别数据类型,card 字段缓存了最近一次计算的基数估计值,避免重复计算。真正核心的是 registers 数组,每个寄存器占 6 位,总共 16384 个。
3.2 稀疏编码与稠密编码
Redis 的 HLL 实现了两种存储编码:
- **稠密编码(Dense Encoding) ** :为所有 16384 个寄存器都分配空间,固定占用 12KB。当 HLL 中存储的元素较多时自动切换到此模式。
- **稀疏编码(Sparse Encoding) ** :当 HLL 中元素较少时,大多数寄存器的值仍为 0,稀疏编码只存储非零寄存器,极大节省内存。稀疏编码通过三种操作符(ZERO、XZERO、VAL)来紧凑表示连续的 0 值和非 0 值。
这种自适应设计使得 HLL 在小数据量时也能保持极低的内存占用,只在必要时才升级为完整的 12KB 结构。
3.3 三大核心命令
Redis 为 HyperLogLog 提供了三个简单易用的命令:
| 命令 | 作用 | 时间复杂度 |
| PFADD key element [element ...] | 向 HLL 中添加一个或多个元素 | O(1) 每个元素 |
| PFCOUNT key [key ...] | 返回 HLL 中基数的估计值(支持多 key 并集) | O(1)(缓存命中)或 O(m) |
| PFMERGE destkey sourcekey [sourcekey ...] | 将多个 HLL 合并为一个 | O(m),m 为寄存器数 |
命令以“PF”开头,是为了纪念 HyperLogLog 算法的共同提出者 Philippe Flajolet。
3.4 合并操作的强大之处
PFMERGE 是 HLL 最强大的特性之一。由于每个 HLL 只存储了寄存器状态(而非原始数据),合并操作只需简单地取各寄存器中每个桶对应值的最大值即可,复杂度极低且结果语义正确——合并后的 HLL 估算的正是所有输入 HLL 的并集的基数。
示例:假设有 2 个 HLL:A、B,它们的第 0 号桶、第 1 号桶… 存储的值如下:
| 桶编号 | HLL A | HLL B | MERGE 结果(取最大) |
|---|---|---|---|
| 0 | 3 | 5 | 5 |
| 1 | 2 | 2 | 2 |
| 2 | 7 | 4 | 7 |
| 3 | 1 | 3 | 3 |
PFMERGE dest A B→ dest 的每个桶 = max (A 桶,B 桶)
这意味着我们可以:
- 先按天统计每日 UV(每天一个 HLL),再用 PFMERGE 快速得到周 UV 和月 UV;
- 在分布式系统中,各节点独立统计本节点的基数,最后在中心节点合并;
- 跨数据源合并统计,例如合并不同渠道的用户数据。
四、实战场景深度解析
4.1 亿级 UV 统计:从 32 分钟到 0.3 秒
统计网站独立访客是 HyperLogLog 最经典的应用场景。在某社交平台的真实案例中,UV 统计从每日 500 万激增到 1.2 亿,传统数据库方案直接崩溃。切换到 Redis HyperLogLog 后,统计耗时从 32 分钟降至 0.3 秒,服务器成本降低 80%。
以下是三种方案的对比:
| 方案 | 内存占用(1 亿用户) | 统计耗时 | 误差率 | 支持聚合 |
| MySQL | ~1.6 GB | ~47 min | 0% | ❌ |
| Redis Set | ~800 MB | ~18 min | 0% | ✅ |
| HyperLogLog | 12 KB | ~0.3s | 0.81% | ✅ |
在这个对比中,HLL 的内存占用仅为 Set 方案的约 **0.0015% **(800MB → 12KB)。
4.2 海量数据去重采集:Bloom Filter + HLL 组合
在分布式数据采集系统中,去重面临两个需求:一是快速判断某个 URL 是否已经采集过,二是统计总共采集了多少不重复的 URL。Bloom Filter 擅长“判断是否存在”,但无法给出准确的数量;HyperLogLog 擅长统计总数,但无法回答“这个 URL 有没有见过”。
两者的组合可以实现优势互补:Bloom Filter 负责实时查重(极快但有少量误判),HyperLogLog 负责全局唯一统计(看总共抓了多少不同 URL)。此外还可以配合定时持久化备份 Bloom Filter 状态,防止重启丢失。这个组合方案在某高并发采集项目中,成功将内存占用从数 GB 降低到数十 MB。
4.3 更多典型应用场景
HyperLogLog 的应用远不止于 UV 统计:
- 社交网络:统计话题参与人数、用户活跃度;
- 广告系统:统计广告曝光的去重人数、广告点击的独立用户数;
- 数据库优化:统计查询语句的去重数量,用于优化器进行基数估计和连接顺序决策;
- 网络安全:统计独立 IP 地址数量、检测恶意攻击的源 IP 规模;
- 实时分析:统计实时数据流中的唯一事件数量。
五、误差特性深度剖析
5.1 0.81% 标准误差的含义
0.81% 是一个标准误差(Standard Error),遵循正态分布规律,这意味着:
- 约 **68.2% ** 的情况下,实际基数在估算值 ±0.81% 范围内;
- 约 **95.4% ** 的情况下,实际基数在估算值 ±1.62% 范围内;
- 约 **99.7% ** 的情况下,实际基数在估算值 ±2.43% 范围内。
例如,当估算 UV 为 1 亿时,标准误差范围约为 ±81 万。
5.2 误差的实际表现
有开发者报告了一个现象:当基数达到约 2.44 亿后,继续添加新元素时 PFCOUNT 返回值不再变化。这是算法的正常表现——当基数很大时,需要添加约估算值 × 0.0081 数量的新元素(约 200 万),才可能在统计意义上观察到估算值的变化。
这说明在大基数场景下,单个时间点的绝对值参考意义有限,更应关注变化趋势。
5.3 影响误差的因素
HLL 的误差主要由两个因素决定:
- 桶的数量:桶越多,误差越小。误差与 √m 成反比,要误差减半,桶数需要增加到 4 倍。Redis 选择 16384 个桶,是在精度和内存之间取得的最优平衡。
- 哈希函数质量:哈希分布越均匀,误差越小。Redis 使用的 64 位哈希函数保证了良好的均匀性。
六、与其他方案的深度对比
6.1 HLL vs Bitmap vs Set
| 维度 | Set | Bitmap | HyperLogLog |
| 精度 | 100% 精确 | 100% 精确 | ≈0.81% 误差 |
| 内存占用(1 亿元素) | ~800 MB | ~12.5 MB(连续 ID) | 12 KB |
| 是否可合并 | ✅(并集) | ✅(位运算) | ✅(取最大值) |
| 支持的数据类型 | 任意字符串 | 仅数字(offset) | 任意字符串 |
| 可获取具体元素 | ✅ | ❌ | ❌ |
Bitmap 在存储连续 ID 的布尔状态时非常高效——1 亿用户签到记录仅需约 12.5MB。但 Bitmap 要求元素能映射为连续的整数 offset,对任意字符串无能为力。HLL 不受此限制,且内存占用更低,代价是概率性误差。
6.2 HLL 的适用与不适用场景
适合的场景:
- 需要统计海量数据的唯一元素数量
- 可以接受约 0.81% 的相对误差
- 内存资源有限
- 需要合并多来源统计数据
不适合的场景:
- 需要精确计数的业务(如财务报表)
- 对误差有严格上限要求的场景
- 基数较小的情况(此时用 Set 更合适,精确且内存开销可接受)
- 需要获取具体元素列表的场景(HLL 不存储原始数据)
七、最佳实践与注意事项
7.1 合理使用与注意事项
在实际项目中使用 HyperLogLog,建议遵循以下最佳实践:
- 评估误差容忍度:先确认业务场景是否接受 0.81% 的误差。对于大多数运营报表和趋势监控场景,这个误差完全可接受;但对于涉及计费、结算的场景,应选择精确计数方案。
- 小数据量用 Set 更合适:当基数较小时(例如小于几千),Set 的内存开销同样很小且能提供精确值。可以考虑在 HLL 中设置一个阈值,基数较小时用 Set 精确记录,超过阈值后切换为 HLL 近似统计。
- 关注趋势而非单点:由于 HLL 的误差特性,单个时间点的绝对值意义有限,更应关注变化趋势。
- 合理设计 key 的时间粒度:按天创建 HLL 是一个常见模式,既便于按天查询,又可以通过 PFMERGE 灵活聚合到周、月、年。
- 避免过度合并:PFMERGE 操作的时间复杂度为 O(m),即需要遍历 16384 个寄存器。虽然单次很快,但频繁大量合并仍会有性能开销。
- 理解“不变化”现象:在大基数场景下,新增少量元素可能不会改变 PFCOUNT 的返回值,这是正常的统计现象,不是 bug。
7.2 性能优化与扩展
Redis 社区的贡献者曾尝试使用 SIMD(单指令多数据流)指令集对 HyperLogLog 进行性能优化。通过引入 AVX2 指令集并行处理多个寄存器,实验环境下 PFCOUNT 操作从约 5280 次/秒提升到约 69802 次/秒,提升约 12 倍;PFMERGE 操作从约 9445 次/秒提升到约 120642 次/秒,同样提升约 12 倍。这种优化对于需要频繁统计基数的高并发场景具有重要参考价值。
7.3 安全性提示
2025 年曝光的 CVE-2025-32023 漏洞提醒我们,在使用 PFMERGE 命令时,需要注意 HLL 数据的来源安全性。该漏洞通过构造恶意的 HLL 稀疏编码数据,在合并时触发负数索引导致越界写,存在安全风险。生产环境中应确保只合并可信来源的 HLL 数据,并及时更新 Redis 版本。
八、总结
HyperLogLog 是现代大数据技术栈中一颗璀璨的明珠。它以约 12KB 的固定内存、O(1) 的时间复杂度、约 0.81% 的误差率,优雅地解决了海量数据的基数统计难题。从抛硬币的概率直觉到分桶调和平均的数学精密设计,再到 Redis 中的高效工程实现,HyperLogLog 处处体现着“以有限精度换取极致效率”的设计哲学。
在实际开发中,理解 HyperLogLog 的特性与限制,才能将这把利器用在合适的地方。当你需要统计海量数据的去重数量,且可以接受微小误差时,HyperLogLog 就是你最好的选择。