上一节的 BitMap 非常适合统计“某个用户在一段连续时间内的状态(如签到)”,但如果我们要统计“一篇文章、一个商铺或者整个网站,每天有多少不同的人来看过”,面对千万级甚至亿级的用户量,BitMap 或者传统的 Set 结构就会暴露出严重的短板。
这时候,Redis 的又一项黑科技——HyperLogLog 就要闪亮登场了。
📚 实战篇 18. UV统计 - HyperLogLog 原理与实战文档
一、 业务场景:什么是 PV 和 UV?
在网站数据统计中,有两个最基础的核心指标:
- PV (Page View - 页面访问量): 每次页面的刷新、点击都会被记录。同一个用户点击 10 次,PV 就是 10。
- UV (Unique Visitor - 独立访客): 关注的是“有多少个不同的人”。同一个用户在一天内不管点击多少次,UV 都只算 1。
核心痛点:千万级 UV 怎么存?
要想知道一个用户今天有没有访问过,我们就必须把他“记下来”进行去重校验。
- 用 Redis Set: 存入所有访问过的
userId。如果一天有 1000 万 UV,一个 ID 算 8 字节,大约需要 80MB 内存。如果要统计 100 个页面的 UV,内存瞬间爆炸。 - 用 Redis BitMap: 看起来很省空间,但前提是用户的 ID 必须是连续的数字(作为 offset)。如果用户 ID 是随机生成的长数字、甚至 UUID 字符串,根本无法映射到 BitMap 中,强行映射会造成巨大的内存黑洞。
面对海量数据的去重统计,我们需要一种**“即便牺牲一点点准确率,也要把内存压缩到极致”**的方案。
二、 核心认知:什么是 HyperLogLog (HLL)?
HyperLogLog (简称 HLL) 是一种概率数据结构,专门用来做基数统计(即统计一个集合中不重复元素的个数)。
🌟 HLL 的“神仙”特性(面试必背):
- 内存占用极小且恒定: 无论你统计 1 万个 UV,还是 1 个亿的 UV,一个 HLL 结构在 Redis 中最多只会占用 12 KB 的内存!
- 标准误差率: 既然内存这么小,它肯定存不下具体的用户 ID。HLL 内部采用了极其复杂的概率算法(基于哈希和位运算),用极小的空间估算出基数。它的标准误差率大约在 0.81% 。
- 不可逆转: HLL 只能告诉你“大概有多少个不同的人”,但无法告诉你具体是哪几个人(因为它根本没存原始数据)。
商业权衡: 在绝大多数互联网业务中(比如 B 站视频播放量、微博文章阅读量),告诉用户这篇文章有 100 万人看过,还是 100.8 万人看过,其实没有任何实质区别。用 0.81% 的误差换取成千上万倍的内存节省,这笔买卖在架构设计上绝对血赚!
三、 核心命令实操指南
Redis 中操作 HyperLogLog 的命令非常有特点,都以 PF 开头(为了纪念发明这个算法的数学家 Philippe Flajolet)。
1. 添加元素:PFADD
-
语法:
PFADD key element [element ...] -
业务映射: 当用户访问页面时,把他的
userId丢进去。 -
演示:
Bash
PFADD uv:20260317 "user1" "user2" "user3" PFADD uv:20260317 "user1" # user1 已经存在,不会重复计算
2. 统计基数:PFCOUNT
-
语法:
PFCOUNT key [key ...] -
业务映射: 获取当天的 UV 总数。
-
演示:
Bash
PFCOUNT uv:20260317 # 返回 3
3. 合并统计:PFMERGE ⚡ (大招)
-
语法:
PFMERGE destkey sourcekey [sourcekey ...] -
业务映射: 将多个 HLL 合并成一个新的 HLL。极度适合做**“周活跃用户(WAU)”或“月活跃用户(MAU)”**的聚合统计!
-
演示场景: 把周一到周日的 UV 数据合并,就能光速算出这一周的独立访客数(内部会自动进行跨天去重)。
Bash
PFMERGE uv:weekly:1 uv:monday uv:tuesday uv:wednesday ...
四、 Java 代码实战与百万数据误差测试
我们可以写一个 Spring Boot 的单元测试,向 HLL 中塞入 100 万个不同的数字,来看看它的速度、内存和误差率。
Java
@SpringBootTest
class HmDianPingApplicationTests {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Test
void testHyperLogLog() {
// 准备一个数组,用于批量提交 (减少网络 I/O)
String[] users = new String[1000];
int j = 0;
// 循环 100 万次
for (int i = 0; i < 1000000; i++) {
j = i % 1000;
users[j] = "user_" + i;
// 每满 1000 个元素,批量执行一次 PFADD
if (j == 999) {
stringRedisTemplate.opsForHyperLogLog().add("hll:uv:test", users);
}
}
// 获取统计的基数
Long count = stringRedisTemplate.opsForHyperLogLog().size("hll:uv:test");
System.out.println("100万条数据插入后,HyperLogLog 统计结果为:" + count);
// 控制台打印结果通常在 997577 左右
}
}
测试结论:
- 插入 100 万条不重复的数据,
PFCOUNT查出来的结果大概是 997577。 - 误差为:
(1000000 - 997577) / 1000000 ≈ 0.24%,甚至远低于官方标称的 0.81%! - 如果你去 Redis 客户端看这个 Key 的内存占用,你会发现它真的不到 12KB!
学习总结与数据结构选型大 PK (面试终极杀招)
在面试中,如果被问到“如何实现一个千万级访问量的 UV 统计?”,你可以拿出一套标准的高阶对比话术:
“对于海量 UV 统计,我绝不会使用传统的 Set,因为它会随着用户量呈线性增长,导致极严重的内存溢出。
很多人会想到 BitMap。如果用户的 ID 是连续的自增数字,BitMap 确实是个不错的选择,比如 1 亿用户只需要 12MB。但现实中,我们的用户 ID 往往是分布极度稀疏的雪花算法 ID 或者字符串 Token,这会导致 BitMap 生成极多无用的空白位,同样会造成内存浪费,且无法存储非整型的 ID。
因此,我的最终架构选型是 Redis 的 HyperLogLog。虽然它有不到 1% 的标准误差率,并且无法逆向查出具体是哪些用户访问了,但在宏观流量统计的业务背景下,用 12KB 的恒定极小内存换取千万级数据的去重统计能力,在空间复杂度上达到了极致的平衡。另外,HLL 提供的
PFMERGE命令也能极快地解决我们计算月活、周活的跨天去重聚合需求。”