☰
Redis HyperLogLog实战:12KB内存搞定亿级UV去重统计
2026/10/17 12:18:25 网站建设 项目流程

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万10032320.32%
10万1005875870.59%
100万100813281320.81%
500万5039210392100.78%
1000万10065420654200.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不是银弹,但在它擅长的场景里,效果是立竿见影的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询