从抛硬币到亿级数据:HyperLogLog算法原理与Redis实战深度解析
如果你曾好奇,像希音这样的平台,如何用极小的代价统计数亿用户的日活、月活,答案很可能就藏在HyperLogLog这个看似简单的数据结构里。面试官们对它情有独钟,不仅因为它能解决海量数据基数统计的痛点,更因为它背后精巧的概率论思想——从经典的伯努利实验出发,一路延伸到现代分布式系统的核心。今天,我们不谈表面的API调用,而是深入骨髓,拆解其数学根基、算法设计,并一窥Redis源码中那些为了极致内存优化所做的精妙权衡。无论你是准备冲击大厂面试的进阶开发者,还是希望理解现代数据系统设计哲学的技术爱好者,这篇文章都将带你进行一次硬核而透彻的探索。
1. 基数统计的困境与概率算法的曙光
在日常的数据分析中,“去重计数”是一个高频且基础的需求。无论是统计一个网页的独立访客(UV),还是计算一个社交平台某日的活跃用户数(DAU),其本质都是计算一个集合中不重复元素的个数,即基数。当数据量停留在百万级别时,传统的数据结构如HashSet或数据库的DISTINCT计数尚可应付。然而,当面对希音、淘宝这类拥有亿级用户体量的平台时,问题就变得棘手起来。
设想一下,如果使用一个HashSet来存储每个访问用户的ID。假设每个用户ID平均占用20字节,那么1亿个不重复的用户ID将直接消耗约2GB的内存。这仅仅是为了一个统计指标,显然是不可接受的。即便是采用Bitmap(位图)进行优化,将用户ID映射到比特位,1亿用户也需要大约12MB的内存(1亿 / 8 / 1024 / 1024)。虽然相比HashSet已是巨大的进步,但在需要同时统计成千上万个维度或长时间窗口(如全年每日DAU)的场景下,内存消耗依然可观。
更关键的是,在很多业务场景下,统计结果并不需要100%的精确。产品经理可能更关心DAU是1.01亿还是1.02亿,这个微小的误差(约1%)通常不会影响核心的业务决策。这种允许一定误差以换取巨大资源节省的 trade-off,正是概率算法大显身手的舞台。HyperLogLog便是这类算法中的佼佼者,它仅用约12KB的固定内存,就能以标准误差约0.81%的精度,估计出高达2^64(约1.8e19)个元素的基数。
注意:这里的“误差”是一个统计概念,指估计值与真实值之间偏差的期望范围,并非每次计算都固定偏差0.81%。在实际测试中,误差分布通常符合正态分布。
那么,一个仅用12KB内存的算法,是如何“感知”到数亿乃至更大量级的不同元素呢?答案要从一场“抛硬币”的游戏说起。
2. 数学基石:伯努利实验与极大似然估计
要理解HyperLogLog,我们必须先回到概率论中最基础的模型之一:伯努利实验。它描述的是只有两种可能结果(如“正面”或“反面”)的随机试验,且每次试验的结果相互独立,概率恒定。
2.1 从抛硬币到基数估计的直觉
让我们玩一个简单的思想实验:
- 你有一枚均匀的硬币,抛出后出现正面的概率是1/2。
- 你连续抛这枚硬币,直到第一次出现正面为止,并记录下总共抛掷的次数
k。- 如果第一次就抛出正面,则
k=1。 - 如果前三次是反面,第四次是正面,则
k=4。
- 如果第一次就抛出正面,则
现在,关键的问题来了:如果我告诉你,我进行了这样一组实验,得到的最大 k 值是10(即我连续抛了9次反面,第10次才抛出正面),请你猜一猜,我大概抛了多少次硬币(即进行了多少次独立的伯努利实验)?
直觉上,出现“连续9次反面后第10次正面”这种事件是相当罕见的,其概率是 (1/2)^10 = 1/1024。这意味着,平均而言,你需要进行大约1024次这样的“抛到出现正面为止”的系列实验,才有较大概率观察到一次 k=10 的情况。这就是极大似然估计的思想:我们观察到的结果(k_max=10)最有可能在什么条件下(总实验次数 N)发生?这里,一个合理的估计是 N ≈ 2^k_max = 2^10 = 1024。
将这个过程抽象化:
- 观察结果:
n次独立伯努利实验中,记录每次首次出现正面所需的抛掷次数k1, k2, ..., kn,并找到其中的最大值k_max。 - 估计总量:对总实验次数
n的估计为ñ ≈ 2^k_max。
这个简单的关联,就是HyperLogLog灵魂所在。它巧妙地将“统计不同元素数量”的问题,转化为了“观察一系列伯努利实验极端结果”的问题。
2.2 哈希函数:将元素映射为硬币序列
在计算机中,我们如何为每个用户ID(或任何需要统计的元素)模拟一次伯努利实验呢?答案就是哈希函数。
一个理想的哈希函数可以将任意输入(如用户ID字符串)映射为一个固定长度的、看似随机的比特串(例如64位)。我们可以将这个比特串看作一连串的硬币抛掷结果:
- 比特
0视为硬币的“反面”。 - 比特
1视为硬币的“正面”。
我们从比特串的最低位(LSB)开始向高位扫描,寻找第一个出现 1 的位置。这个位置索引(从1开始计数)就对应了我们思想实验中的 k 值——即“首次出现正面所需的抛掷次数”。
示例: 假设对用户ID “user_12345” 进行哈希,得到一个64位二进制表示(这里简化为8位示意): 哈希值(二进制): 00101011 我们从右向左扫描(低位在右):
- 第1位(最低位):
1-> 第一次就出现“正面”!记录k = 1。


被折叠的 条评论
为什么被折叠?



