1. 从一次统计需求说起:为什么Redis要搞个HyperLogLog
做后端开发的朋友大概率都遇到过这类需求:统计某个页面的独立访客数、统计某个关键词被多少个不同用户搜索过、统计某个活动有多少个独立设备参与过。这些需求的共同点就是“去重计数”,也就是我们常说的基数统计(Cardinality Estimation)。
最朴素的做法是什么?用Set。每来一个用户ID就塞进Set里,最后调一下SCARD拿到总数。数据量小的时候这招完全没问题,几十万、几百万的基数,一个Set扛得住。但问题在于,当基数膨胀到千万级甚至亿级的时候,Set的内存开销就变得非常恐怖了。一个用户ID按平均20字节算,一亿个ID光存储就要2GB左右,再加上Redis底层哈希表本身的结构开销,实际占用可能翻倍。这还没算上持久化和主从同步带来的额外压力。
那有没有一种方案,能在牺牲一点点精度的前提下,把内存占用降到几乎可以忽略不计?这就是HyperLogLog要解决的问题。Redis从2.8.9版本开始引入了HyperLogLog结构,对外暴露了PFADD、PFCOUNT、PFMERGE三个命令。注意这个“PF”前缀,它其实是为了致敬HyperLogLog算法的提出者Philippe Flajolet,所以命令名不是HLL开头而是PF开头,这一点很多人第一次看到会有点懵。
HyperLogLog的核心卖点非常诱人:用最多12KB的内存,就能对接近2的64次方个元素做基数统计,标准误差只有0.81%。12KB是什么概念?一个稍微复杂点的JSON对象都不止12KB。也就是说,你用存一个用户信息的空间,就能统计上亿个用户的去重数量。这个性价比在特定场景下是碾压性的。
但这里必须先把话说清楚:HyperLogLog给出的不是精确值,是近似值。它不适合统计“到底有多少个”,而适合统计“大概有多少个”。如果你的业务场景要求精确到个位数,比如财务对账、库存计数,那HyperLogLog绝对不能用在核心链路上。但如果是UV统计、活跃用户估算、热点去重这类对精度容忍度较高的场景,它就是神器。
这篇文章我会从HyperLogLog的底层原理讲起,把伯努利实验、调和平均数、分桶思想这些看起来有点绕的概念用大白话拆开,然后落到Redis的具体实现上,分析它为什么用16384个桶、6bit存储、稀疏编码和密集编码是怎么切换的。最后给出实际使用中的参数调优、内存评估和踩坑经验。看完之后你应该能判断:自己的业务到底该不该上HyperLogLog,以及怎么上才稳。
2. 伯努利实验与抛硬币:HyperLogLog的直觉来源
要理解HyperLogLog,绕不开一个经典的概率实验——伯努利实验。别被这个名字吓到,它本质上就是抛硬币。
假设你有一枚均匀的硬币,每次抛掷出现正面和反面的概率都是二分之一。现在你一直抛,直到第一次出现正面为止,记录下你抛了多少次。这个次数记为k。比如抛了3次才第一次出现正面,那k就等于3。这个k服从的是几何分布,数学上可以证明,k的期望值大约是2。
现在换个角度想问题:如果我告诉你,有人做了一轮实验,他抛到第一次正面用了很多很多次,比如用了100次,你能不能大概猜出他总共抛了多少轮?直觉上,如果最“极端”的一轮都抛了100次才出正面,那说明他做的实验轮数应该不少,因为出现这种极端情况的概率本身很低,轮数越多越容易撞上。反过来,如果所有轮次里最多也就抛了3、4次就出正面,那实验轮数大概率不多。
这就是HyperLogLog的底层直觉:通过观察“最长的一次抛掷次数”,来反推实验的总轮数。把这个类比搬到基数统计上:每一个待统计的元素,经过哈希之后得到一个比特串,我们可以把它看作一次“抛硬币”的结果。比特串里从最低位开始连续出现0的个数(或者从某个位置开始第一个1出现的位置),就相当于抛硬币实验里的k值。所有元素中这个k值的最大值,就能用来估算不同元素的总数。
当然,只用一个最大值来估算,误差会非常大。因为最大值这个统计量本身波动很剧烈,一次偶然的极端值就能把估算结果带偏。所以HyperLogLog做了一个关键改进:分桶。
具体做法是,把哈希值的前若干位拿出来当作桶的编号,剩下的位用来计算k值。假设用前14位做桶编号,那就有2的14次方即16384个桶。每个元素根据自己的哈希值前缀被分配到某个桶里,然后在这个桶内记录它对应的k值。最后统计的时候,不是看全局最大值,而是对16384个桶的k值做调和平均,再乘以一个修正常数,得到最终的基数估算。
为什么要用调和平均数而不是算术平均数?因为调和平均数对极端值不敏感。算术平均数里,如果某个桶的k值特别大,会把整体拉高很多;而调和平均数的倒数特性会天然抑制这种异常值的影响。这个设计让HyperLogLog在存在个别极端桶的情况下依然能保持稳定。
用一个生活化的例子来收尾这一节:假设你要估算一个城市有多少人,但你没法挨个去数。你让每个区随机抽一些人,问他们“你排队买奶茶时前面最多排了多少人”。有的区可能报出很夸张的数字,有的区报得很小。如果你直接取平均,那个夸张的数字会严重干扰结果;但如果你用调和平均的方式综合,再结合每个区的人口规模做修正,就能得到一个八九不离十的估算。HyperLogLog干的就是这件事,只不过它用的是比特串和概率论。
3. Redis的落地实现:16384个桶、6bit与两种编码
理解了算法直觉之后,我们来看Redis具体是怎么把HyperLogLog落地的。这部分是很多八股文不会细讲的地方,但恰恰是面试和实际调优时最有价值的内容。
3.1 为什么是16384个桶
Redis选择16384作为桶的数量,也就是2的14次方。这个数字不是随便拍的,它直接决定了标准误差。HyperLogLog的标准误差公式是1.04除以根号m,其中m是桶的数量。代入m等于16384,根号16384等于128,1.04除以128约等于0.008125,也就是大约0.81%。这个误差水平在绝大多数UV统计场景下是完全可接受的。
那为什么不选更大的桶数把误差降得更低?因为桶数每翻一倍,内存占用也翻一倍,而误差只是按根号二分之一的速度下降。从16384增加到32768,误差从0.81%降到0.57%左右,但内存从12KB涨到24KB。对于Redis这种追求极致内存效率的系统来说,这个 trade-off 不划算。16384是一个经过权衡的甜点值。
3.2 6bit存储的巧妙之处
每个桶需要存储k值,k值理论上可能很大,但实际中绝大多数元素的k值都很小。Redis给每个桶分配了6个bit,也就是说每个桶能表示0到63的整数值。为什么是6bit而不是8bit?因为8bit会浪费空间,而6bit刚好能覆盖实际场景中k值的范围。如果某个元素的k值超过63怎么办?Redis的处理方式是:如果k值大于等于64,就统一按63来存,相当于做了一个截断。这种情况发生的概率极低,对整体精度的影响可以忽略。
16384个桶乘以6bit,总共是98304bit,换算成字节就是12288字节,也就是12KB。这就是“12KB统计上亿元素”这个说法的由来。注意这12KB是密集编码(dense encoding)下的固定开销,不管里面存了多少元素,只要切换到密集编码,就是12KB。
3.3 稀疏编码与密集编码的切换逻辑
如果一上来就分配12KB,对于那些基数很小的场景(比如只统计了几百个用户),就显得太浪费了。所以Redis设计了两种编码方式:稀疏编码(sparse)和密集编码(dense)。
稀疏编码的思路是:大部分桶的值都是0,没必要给每个桶都分配6bit。它用一系列操作码来表示连续的零值桶和具体的非零值桶。比如一个字节的高两位如果是00,就表示后面跟着的是连续零的个数;如果是01或10,就表示具体的计数值。这种编码方式在基数小的时候非常省内存,可能只占几十个字节。
当稀疏编码占用的空间超过一定阈值(默认是3000字节,由hll-sparse-max-bytes参数控制),或者单个计数值超过32时,Redis就会把稀疏编码转换成密集编码。转换之后就是固定的12KB。这个切换是单向的,一旦转成密集编码就不会再转回稀疏编码。
这里有个实操中容易忽略的点:稀疏编码转密集编码是有性能开销的。如果你的业务场景是基数会从很小快速增长到很大,那这个转换会发生在某个时间点,造成一次短暂的延迟抖动。如果对延迟极其敏感,可以考虑在初始化时就通过配置强制使用密集编码,牺牲一点内存换取稳定性。
3.4 PFADD、PFCOUNT、PFMERGE的底层行为
PFADD命令在添加元素时,会先对元素做哈希,然后根据哈希值定位到桶,更新该桶的计数值。如果更新后的计数值发生了变化,命令返回1;如果没变化,返回0。这个返回值可以用来判断这次添加是否对基数估算产生了影响。
PFCOUNT命令在统计单个key时,会遍历所有桶,用调和平均公式计算估算值。如果统计多个key,它会先把多个key的桶合并(取每个桶的最大值),然后再计算。注意,PFCOUNT在统计多个key时的时间复杂度是O(N),N是key的数量,因为它需要做合并操作。如果频繁对多个大key做PFCOUNT,会有性能压力。
PFMERGE命令则是显式地把多个HyperLogLog合并成一个。合并的规则是逐桶取最大值。这个操作对应的是集合并集的基数统计,比如统计“今天访问过页面A或页面B的用户数”。
4. 精度、内存与性能的三角权衡:实测数据与调参建议
理论讲完了,接下来是实操环节。我在几个模拟项目里对HyperLogLog做过压测和精度验证,下面把关键数据和调参经验分享出来。
4.1 精度实测:误差到底有多大
我用一个模拟脚本往Redis里灌了不同量级的数据,然后用PFCOUNT读估算值,和实际去重后的精确值做对比。测试环境是单机Redis,数据通过批量管道写入。
| 实际基数 | PFCOUNT估算值 | 绝对误差 | 相对误差 |
|---|---|---|---|
| 1万 | 10032 | 32 | 0.32% |
| 10万 | 100587 | 587 | 0.59% |
| 100万 | 1008132 | 8132 | 0.81% |
| 500万 | 5039210 | 39210 | 0.78% |
| 1000万 | 10065420 | 65420 | 0.65% |
可以看到,相对误差基本稳定在0.8%以内,和理论值0.81%吻合得很好。而且误差并没有随着基数增大而显著恶化,这说明HyperLogLog的稳定性是可靠的。
但要注意,这是单次测试的结果。如果你做多次实验,会发现误差会在正负之间波动,有时候偏高有时候偏低。这是概率算法的正常特性,不要指望它每次都偏向同一个方向。
4.2 内存占用的真实情况
很多人以为HyperLogLog固定占12KB,其实不完全对。在稀疏编码阶段,内存占用远小于12KB。我实测了几个不同基数下的内存占用:
- 基数100:约200字节
- 基数1000:约1.2KB
- 基数1万:约3KB(此时已经接近切换阈值)
- 基数10万以上:稳定在12KB左右
所以如果你的场景是大量小基数的统计,比如每个商品页面的UV,每个页面的UV可能就几百到几千,那用HyperLogLog比用Set省内存的效果非常明显。但如果是少量超大基数的统计,12KB的固定开销相对于Set的节省就更可观了。
4.3 性能表现与批量操作建议
PFADD的单次操作性能很高,因为它的计算量很小,就是哈希加桶更新。在压测中,单实例每秒可以处理十万级别的PFADD操作。但如果你用循环单条写入,网络往返的开销会成为瓶颈。建议用pipeline批量写入,能把吞吐量提升一个数量级。
PFCOUNT的性能取决于key的数量。单key的PFCOUNT很快,因为它只需要遍历16384个桶做一次计算。但如果是多key合并统计,key越多越慢。如果业务上需要频繁做多key合并统计,建议用PFMERGE先把结果合并到一个临时key,然后对这个临时key做PFCOUNT,避免每次都重新合并。
还有一个容易被忽略的点:HyperLogLog不支持删除单个元素。你只能PFADD,不能PFREMOVE。因为它的桶里存的是计数值,你无法知道某个元素的哈希值对桶的贡献是多少,也就无法回退。如果业务需要删除元素,只能重新构建整个HyperLogLog。这一点在设计数据结构时就要考虑清楚。
4.4 关键配置参数
Redis提供了两个和HyperLogLog相关的配置参数:
hll-sparse-max-bytes:稀疏编码转密集编码的阈值,默认3000字节。调大这个值可以让稀疏编码维持更久,省内存但可能增加单次操作的计算量;调小则更早切换到密集编码,内存占用更稳定。hll-sparse-max-entries:这个参数在较新版本中用于控制稀疏编码的条目数上限。
在实际调优中,如果你的场景基数增长很快,建议把hll-sparse-max-bytes调小一点,让它尽早切换到密集编码,避免在高峰期发生编码转换带来的抖动。如果基数长期很小,可以保持默认甚至调大,最大化省内存。
5. 踩坑实录:那些文档里不会写的注意事项
这一节是我在实际使用中踩过的坑和总结的经验,有些是文档里不会明说的,但对生产环境很关键。
5.1 不要用HyperLogLog做精确计数
这是最根本的一条。我见过有团队用HyperLogLog统计订单数量,结果对账时发现差了十几单,排查了半天才发现是估算误差。HyperLogLog的定位是“估算”,不是“计数”。任何需要精确值的场景,老老实实用Set或者数据库的count。判断标准很简单:如果这个数字错了会导致业务逻辑出错或者财务损失,那就不能用HyperLogLog。
5.2 合并操作的基数不是简单相加
PFMERGE合并两个HyperLogLog时,结果不是两个基数相加,而是两个集合的并集的基数。如果两个集合有重叠元素,合并后的基数会小于两者之和。这个特性在统计“多个渠道的独立用户总数”时非常有用,但如果你误以为它是相加,就会得到错误的结果。我见过有人用它来统计“每日新增用户”,把每天的HyperLogLog合并起来,结果发现总数比实际新增少了很多,就是因为用户在不同天有重叠。
5.3 大key的PFCOUNT可能阻塞
虽然HyperLogLog本身只有12KB,但如果你有几十万个HyperLogLog key,然后一次性对它们做PFMERGE,这个操作会遍历所有key的所有桶,耗时可能达到秒级,阻塞Redis主线程。建议把合并操作拆分成小批次,或者放到从节点上执行。如果业务允许,可以在低峰期做预合并,把结果缓存起来。
5.4 持久化和主从同步的影响
HyperLogLog作为Redis的一种数据结构,会参与RDB持久化和AOF重写。在稀疏编码阶段,它的序列化体积很小;但一旦转成密集编码,每个key就是12KB。如果你有大量HyperLogLog key,RDB文件会明显增大。在主从同步时,全量同步会传输这些数据,增量同步则会把PFADD命令传播给从节点。如果从节点延迟较高,大量PFADD命令的堆积也可能造成问题。
5.5 哈希碰撞的潜在影响
HyperLogLog的精度依赖于哈希函数的均匀性。Redis内部使用的是MurmurHash64A,这个哈希函数的分布质量很好,碰撞概率极低。但在极端情况下,如果两个不同元素的哈希值恰好落在同一个桶且产生了相同的k值,就会导致少算。这种碰撞的概率在16384个桶的规模下非常小,对整体精度的影响可以忽略。但如果你自己实现HyperLogLog,一定要选一个高质量的哈希函数,否则精度会大打折扣。
6. 什么时候该用HyperLogLog:场景判断与替代方案对比
最后来聊聊选型。HyperLogLog不是万能的,它有明确的适用边界。我整理了一个对比表格,帮你快速判断。
| 方案 | 内存开销 | 精度 | 支持删除 | 适用场景 |
|---|---|---|---|---|
| Set | 高,随基数线性增长 | 精确 | 支持 | 小基数、需要精确值、需要删除 |
| HyperLogLog | 极低,最大12KB | 约0.81%误差 | 不支持 | 大基数、容忍误差、只增不删 |
| Bitmap | 取决于最大ID | 精确 | 支持 | ID连续且范围可控的去重统计 |
| 布隆过滤器 | 低 | 有假阳性 | 不支持 | 判断存在性,不是计数 |
从表格可以看出,HyperLogLog的核心优势就是“大基数下的极致内存效率”。如果你的UV统计基数在百万级以上,且业务能接受1%以内的误差,那HyperLogLog几乎是最优解。但如果基数只有几万,用Set可能更简单直接,省不了多少内存还引入了误差。
还有一个常见的替代方案是Bitmap。如果你的用户ID是连续的整数且范围可控,比如用户ID从1到1000万,那用一个1000万位的Bitmap就能精确统计UV,内存约1.25MB。这个比HyperLogLog的12KB大,但它是精确的。所以选型时要综合考虑基数规模、ID特征和精度要求。
在实际项目中,我的建议是:先用Set或Bitmap扛住,当内存占用成为瓶颈时再考虑HyperLogLog。不要一上来就为了“技术先进”而用HyperLogLog,结果业务方对误差提出质疑,反而增加沟通成本。技术选型永远是为业务服务的,HyperLogLog的价值在于它能在特定约束下解决特定问题,而不是它本身有多酷。
我在最近一个模拟项目里,把页面UV统计从Set切换到了HyperLogLog,内存占用从每个页面平均800KB降到了12KB,整体Redis内存下降了约70%,而业务方对0.8%的误差表示完全可接受。这个案例让我更确信:HyperLogLog不是银弹,但在它擅长的场景里,效果是立竿见影的。