【Redis从入门到精通】第68篇:HyperLogLog——用极小内存统计超大基数

【Redis从入门到精通】第68篇:HyperLogLog——用极小内存统计超大基数 上一篇【第67篇】Redis Stream——终于有了真正的消息队列下一篇【第69篇】Redis性能调优实战——从监控到参数调整的全套方案产品经理“上个月咱们网站的UV是多少”程序员“我看看……1.2亿。”产品经理“怎么算的”程序员“用Redis的Set存的。”产品经理“用了多少内存”程序员“大概……”看了一眼监控“7.6GB。”产品经理“下个月预算要对齐技术成本内存该缩减了。”程序员“没问题我用HyperLogLog12KB搞定。”产品经理“12KB不可能吧”程序员“可以误差只有0.81%。”产品经理“那你在Set上浪费的7.6GB是……”程序员“……是历史的教训。”这就是HyperLogLog简称HLL的魔力——用固定的12KB内存统计任意规模的基数去重计数误差控制在0.81%以内。今天我们就来揭开这个神奇数据结构的面纱。一、基数统计的场景与挑战1.1 什么是基数统计基数Cardinality就是一个集合中不重复元素的数量。通俗说就是来了多少人而不是来了多少次。基数 vs 计数 原始数据张三、李四、张三、王五、李四、张三、赵六、李四 计数Count 一共来了8次 → COUNT 就能算O(1)内存O(1)时间 基数Cardinality 一共来了4个不同的人 → 需要去重内存开销巨大 张三、李四、王五、赵六 41.2 常见基数统计场景场景举例数据量级UV统计今天有多少独立访客百万~亿级DAU统计今日活跃用户数百万~亿级搜索词去重今天有多少不同的搜索词千万级IP去重今天有多少独立IP访问百万~亿级爬虫URL去重已抓取了多少不同URL亿~万亿级1.3 精确去重的内存代价用Redis Set做精确去重内存开销有多大我们来算一算Set存储1亿个用户ID的内存开销 假设用户ID是 user_12345678914字节 Redis Set存储开销约 64字节/元素包含内部指针和开销 1亿元素 × 64字节 6.4 GB 这还只是一个指标如果你还要统计 - 今日UV6.4GB - 本周UV6.4GB - 本月UV6.4GB - 每个页面的UV每个6.4GB × N个页面 → 内存成本爆炸式增长 HLL方案 每个指标固定12KB - 今日UV12KB - 本周UV12KB - 本月UV12KB - 100个维度的UV100 × 12KB 1.2MB → 相差5000倍内存对比直观图 Set方案的内存增长 ┌──────────────────────────────────────────────────────────┐ │ 内存 │ │ 6.4GB ┤ ● │ │ │ ● │ │ 4.8GB ┤ ● │ │ │ ● │ │ 3.2GB ┤ ● │ │ │ ● │ │ 1.6GB ┤ ● │ │ │● │ │ 0 ┼─────┬─────┬─────┬─────┬─────┬─────┬───── 基数 │ │ 2千万 4千万 6千万 8千万 1亿 │ │ │ │ ← 线性增长基数越大内存越大 → │ └──────────────────────────────────────────────────────────┘ HLL方案的内存增长 ┌──────────────────────────────────────────────────────────┐ │ 内存 │ │ 24KB ┤ │ │ 12KB ┤●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●● │ │ 0 ┼─────┬─────┬─────┬─────┬─────┬─────┬───── 基数 │ │ 2千万 4千万 6千万 8千万 1亿 10亿 100亿 │ │ │ │ ← 固定12KB基数再大也不变 → │ └──────────────────────────────────────────────────────────┘二、HyperLogLog基本原理2.1 抛硬币的启发理解HLL需要一个思维跳跃。先看一个概率实验抛硬币实验 规则反复抛硬币直到出现正面记录抛的次数 比如反反正 → 3次 问题如果你记录到的最长记录是3次总共大约抛了多少次 直觉答案大约2^3 8次 统计学原理概率为1/(2^k)的事件期望需要2^k次实验才会发生一次 把抛硬币换成哈希值的二进制 哈希值是一个二进制串比如 00010101001... 连续前缀0的个数就像一个硬币反面连抛次数 最长前缀0的长度k → 估计基数 ≈ 2^kHLL的核心思想 用户ID user_123456 │ ▼ 哈希函数如MurmurHash 哈希值: 0010101110011010... (64位二进制) ^^ ││ 00 前缀2个0 → 记录值2 用户ID user_999999 │ ▼ 哈希函数 哈希值: 0000001010110101... (64位二进制) ^^^^^ 00000 前缀5个0 → 记录值5 经过大量用户后 记录的最大前缀0个数 5 估计基数 ≈ 2^5 32实际上可能是30或35有误差2.2 从LogLog到HyperLogLog光靠一个计数器还不够误差太大了。HLL的改进分为三步进化史 1. LogLogDurand Flajolet, 2003 把哈希值分成多个桶每个桶独立统计 多个桶的调和平均数 → 减少方差 桶数m1024每个桶记录max前导零 内存 1024 × 5bit 640字节 2. HyperLogLogFlajolet et al., 2007 上面用的是算术平均HLL改用调和平均 偏差修正小基数和大基数分别修正 标准误差 ≈ 1.04/√m m16384(2^14) → 误差约0.81% 3. Redis HLLRedis 2.8.9 注册数≤阈值 → 稀疏存储省内存 注册数阈值 → 密集存储标准HLL 自动切换无需人工干预2.3 Redis HLL的内存结构Redis HLL的存储格式 稀疏模式少量元素时 ┌─────────────────────────────────────────┐ │ 使用类似ZIPLIST的结构 │ │ 每个元素用一个操作码少量字节表示 │ │ 变长编码极致省空间 │ │ │ │ 适用场景基数较小的HLL │ └─────────────────────────────────────────┘ 密集模式元素足够多时 ┌─────────────────────────────────────────┐ │ 16384个寄存器每个6bit │ │ 16384 × 6bit 98304 bit │ │ 12288 bytes ≈ 12KB │ │ │ │ 适用场景基数足够大 │ └─────────────────────────────────────────┘ 自动切换阈值 当稀疏编码的字节数 16384 × 6/8 12288字节时 → 自动切换到密集存储三、PFADD / PFCOUNT / PFMERGE 命令详解3.1 PFADD添加元素# 基本用法127.0.0.1:6379PFADD uv:20260526 user_001 user_002 user_003(integer)1# 1基数发生了变化0没有变化# 大量添加同上127.0.0.1:6379PFADD uv:20260526 user_001# 重复添加(integer)0# 已经计数过基数不变# PFADD可以接受多个参数127.0.0.1:6379PFADD uv:20260526 user_004 user_005 user_006(integer)13.2 PFCOUNT估算基数# 单个HLL127.0.0.1:6379PFCOUNT uv:20260526(integer)6# 估计6个不同元素实际就是6个小基数时误差更小# 实际测试添加100万个元素127.0.0.1:6379PFADD test:million[100万个元素...]127.0.0.1:6379PFCOUNT test:million(integer)997837# 估计998K误差约0.22%比理论0.81%更好# 多个HLL的联合基数127.0.0.1:6379PFCOUNT uv:20260525 uv:20260526 uv:20260527(integer)150234# 三天总UV的去重计数3.3 PFMERGE合并多个HLL# 合并两个HLL127.0.0.1:6379PFMERGE uv:this_week uv:20260525 uv:20260526 uv:20260527 OK127.0.0.1:6379PFCOUNT uv:this_week(integer)150234# 与上面的联合查询结果一致# MERGE的威力可以快速聚合任意时间维度# 日UV → 周UV → 月UV → 年UV 都能合并计算PFMERGE的典型应用场景 按天记录UV ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ uv:0523 │ │ uv:0524 │ │ uv:0525 │ │ uv:0526 │ │ 12KB │ │ 12KB │ │ 12KB │ │ 12KB │ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ │ │ │ └──────────────┴──────┬───────┴──────────────┘ │ PFMERGE ↓ ┌─────────────────┐ │ uv:this_week │ │ 12KB │ │ 周UV去重计算 │ └─────────────────┘ 再按需合并 PFMERGE uv:this_month uv:week1 uv:week2 uv:week3 uv:week4 → 月度UV也是12KB四、UV统计实战4.1 日UV统计ServicepublicclassUVStatisticsService{AutowiredprivateStringRedisTemplateredisTemplate;/** * 记录一次访问 * param userId 用户ID */publicvoidrecordVisit(LonguserId){StringtodayLocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);Stringkeyuv:day:today;redisTemplate.opsForHyperLogLog().add(key,userId.toString());}/** * 查询今日UV */publiclonggetTodayUV(){StringtodayLocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);returnredisTemplate.opsForHyperLogLog().size(uv:day:today);}/** * 查询过去N天总UV去重 */publiclonggetLastNDaysUV(intdays){String[]keysnewString[days];LocalDatetodayLocalDate.now();for(inti0;idays;i){keys[i]uv:day:today.minusDays(i).format(DateTimeFormatter.BASIC_ISO_DATE);}returnredisTemplate.opsForHyperLogLog().size(keys);}}4.2 多维度UV统计/** * 多维度UV统计页面UV 渠道UV 端UV */ServicepublicclassMultiDimUVService{AutowiredprivateStringRedisTemplateredisTemplate;publicvoidrecordVisit(LonguserId,StringpageId,Stringchannel,Stringdevice){StringtodayLocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);// 多个HLL key每个12KB互不影响redisTemplate.opsForHyperLogLog().add(uv:day:today,userId.toString());redisTemplate.opsForHyperLogLog().add(uv:page:today:pageId,userId.toString());redisTemplate.opsForHyperLogLog().add(uv:channel:today:channel,userId.toString());redisTemplate.opsForHyperLogLog().add(uv:device:today:device,userId.toString());}// 合并多天数据计算周/月活publiclonggetWeeklyUV(){ListStringkeysgetLast7DayKeys();StringmergeKeyuv:week:getCurrentWeekKey();// 先合并到一个临时keyredisTemplate.opsForHyperLogLog().union(mergeKey,keys.toArray(newString[0]));redisTemplate.expire(mergeKey,7,TimeUnit.DAYS);// 一周后自动清理returnredisTemplate.opsForHyperLogLog().size(mergeKey);}}4.3 存储七天的历史数据ComponentpublicclassUVHistoryStorage{AutowiredprivateStringRedisTemplateredisTemplate;// 每天凌晨自动汇总前一天的HLLScheduled(cron0 5 0 * * ?)publicvoidarchiveYesterdayUV(){StringyesterdayLocalDate.now().minusDays(1).format(DateTimeFormatter.BASIC_ISO_DATE);StringyesterdayKeyuv:day:yesterday;// 合并到周活StringweekKeyuv:week:WeekFields.ISO.weekOfYear();redisTemplate.opsForHyperLogLog().union(uv:week:weekKey,yesterdayKey);redisTemplate.expire(uv:week:weekKey,30,TimeUnit.DAYS);// 合并到月活StringmonthKeyuv:month:YearMonth.now().format(DateTimeFormatter.ofPattern(yyyyMM));redisTemplate.opsForHyperLogLog().union(uv:month:monthKey,yesterdayKey);redisTemplate.expire(uv:month:monthKey,90,TimeUnit.DAYS);// 日HLL保留7天redisTemplate.expire(yesterdayKey,7,TimeUnit.DAYS);}}⚠️ 注意PFMERGE/PFCOUNT支持多个key联合查询但这与预先MERGE到目标key有本质区别。联合查询是实时计算的频繁执行有性能开销预先MERGE是一次性操作后续查询PFCOUNT目标key即可。对于固定需要的历史聚合如周活/月活建议提前MERGE。五、HyperLogLog vs Set vs BitMap 选型对比5.1 三种方案总览用户ID范围1 ~ 1亿统计活跃用户数 方案ASet ┌──────────────────────────────────────┐ │ SADD active:today user_001 │ │ SCARD active:today → 精确结果 │ │ │ │ 内存6.4GB1亿活跃 │ │ 精确度100% │ │ 能枚举✓ │ └──────────────────────────────────────┘ 方案BBitMap ┌──────────────────────────────────────┐ │ SETBIT active:today user_id 1 │ │ BITCOUNT active:today → 精确结果 │ │ │ │ 内存1亿bit 12.5MB压缩后约1MB │ │ 精确度100% │ │ 能枚举✓遍历bit │ │ 前提用户ID必须是连续整数 │ └──────────────────────────────────────┘ 方案CHyperLogLog ┌──────────────────────────────────────┐ │ PFADD active:today user_001 │ │ PFCOUNT active:today → 近似结果 │ │ │ │ 内存12KB固定 │ │ 精确度≈99.19%误差0.81% │ │ 能枚举✗ │ │ 前提无ID可以是任意字符串 │ └──────────────────────────────────────┘5.2 详细对比表格维度SetBitMapHyperLogLog精确度100%精确100%精确约99.2%0.81%误差内存O(N)线性增长O(max_id)最大ID有关O(1)固定12KB1亿元素内存~6.4GB~12.5MB~12KB能枚举元素是SMEMBERS是遍历bit否能查询存在性是SISMEMBER是GETBIT否元素类型要求任意需要ID连续整数任意合并操作是SUNION是BITOP OR是PFMERGE增删改查全支持全支持SETBIT只支持添加适用场景需要精确计数枚举ID连续需要精确可枚举海量数据去重计数5.3 选型决策树你的需求是什么 │ ├─ 需要精确计数 │ ├─ YES → 用户ID是连续整数 │ │ ├─ YES → 用户量上亿→ BitMap │ │ └─ NO → 数据量百万级→ Set │ │ │ │ └─ NO → 可以接受0.81%误差 │ │ ├─ YES → 数据可能上亿 │ │ │ ├─ YES → HLL │ │ │ └─ NO → HLL也行Set更简单 │ │ │ └─ NO → 回到需要精确分支 │ ├─ 需要列举有哪些用户→ Set或BitMapHLL不行 │ └─ 需要判断某个用户是否在集合中→ Set或BitMapHLL不行六、HLL的局限与注意事项6.1 三大局限局限1不能枚举元素 ┌─────────────────────────────────────────┐ │ PFCOUNT告诉你大约有100万人 │ │ 但你没法知道这100万人具体是谁 │ │ │ │ 如果你需要给所有活跃用户推送通知 │ │ → HLL不行用Set吧 │ └─────────────────────────────────────────┘ 局限2有误差0.81% ┌─────────────────────────────────────────┐ │ 真实基数100万时PFCOUNT可能返回 │ │ 991,900 ~ 1,008,100 │ │ │ │ 对于运营大概一百万的报告没问题 │ │ 但用来做计费结算或精确对账不行 │ │ 切记HLL是估算不是统计 │ └─────────────────────────────────────────┘ 局限3不能删除元素 ┌─────────────────────────────────────────┐ │ HLL只记录最大值无法回退 │ │ 删了用户后HLL不会变小 │ │ │ │ 适合统计来过的人不适合当前在线 │ │ 后者应该用Set在线时SADD离线时SREM │ └─────────────────────────────────────────┘6.2 使用建议场景建议原因网站日UV/月UVHLL数据量极大0.81%误差无影响搜索词去重HLL同上而且合并不重复广告曝光去重HLL Set兜底大基数用HLL小基数用Set计费结算Set差一分钱都不行用户画像分析Set需要知道具体是谁实时在线统计Set需要增删操作⚠️ 注意Redis的HLL命令以PF开头这是为了纪念HLL的发明者Philippe Flajolet1948-2011。这位法国数学家在概率计数算法领域的贡献让今天的我们能在12KB内存里做1亿用户的去重统计。七、总结HyperLogLog是典型的用精确度换空间的算法。它不完美但在它的适用场景里它是完美的。核心记住三点12KB内存无论多少数据固定不变0.81%标准误差够用但不精确只能加不能删适合来过多少人不适合现在有多少人如果你在做UV统计、DAU统计、搜索词去重HLL是最优解。如果需要精确计数或元素枚举回去用Set或BitMap吧。上一篇【第67篇】Redis Stream——终于有了真正的消息队列下一篇【第69篇】Redis性能调优实战——从监控到参数调整的全套方案