实战篇 18. UV统计 - HyperLogLog 原理与实战文档

6 阅读6分钟

上一节的 BitMap 非常适合统计“某个用户在一段连续时间内的状态(如签到)”,但如果我们要统计“一篇文章、一个商铺或者整个网站,每天有多少不同的人来看过”,面对千万级甚至亿级的用户量,BitMap 或者传统的 Set 结构就会暴露出严重的短板。

这时候,Redis 的又一项黑科技——HyperLogLog 就要闪亮登场了。


📚 实战篇 18. UV统计 - HyperLogLog 原理与实战文档

一、 业务场景:什么是 PV 和 UV?

在网站数据统计中,有两个最基础的核心指标:

  • PV (Page View - 页面访问量): 每次页面的刷新、点击都会被记录。同一个用户点击 10 次,PV 就是 10。
  • UV (Unique Visitor - 独立访客): 关注的是“有多少个不同的人”。同一个用户在一天内不管点击多少次,UV 都只算 1。

核心痛点:千万级 UV 怎么存?

要想知道一个用户今天有没有访问过,我们就必须把他“记下来”进行去重校验。

  1. 用 Redis Set: 存入所有访问过的 userId。如果一天有 1000 万 UV,一个 ID 算 8 字节,大约需要 80MB 内存。如果要统计 100 个页面的 UV,内存瞬间爆炸。
  2. 用 Redis BitMap: 看起来很省空间,但前提是用户的 ID 必须是连续的数字(作为 offset)。如果用户 ID 是随机生成的长数字、甚至 UUID 字符串,根本无法映射到 BitMap 中,强行映射会造成巨大的内存黑洞。

面对海量数据的去重统计,我们需要一种**“即便牺牲一点点准确率,也要把内存压缩到极致”**的方案。


二、 核心认知:什么是 HyperLogLog (HLL)?

HyperLogLog (简称 HLL) 是一种概率数据结构,专门用来做基数统计(即统计一个集合中不重复元素的个数)。

🌟 HLL 的“神仙”特性(面试必背):

  1. 内存占用极小且恒定: 无论你统计 1 万个 UV,还是 1 个亿的 UV,一个 HLL 结构在 Redis 中最多只会占用 12 KB 的内存!
  2. 标准误差率: 既然内存这么小,它肯定存不下具体的用户 ID。HLL 内部采用了极其复杂的概率算法(基于哈希和位运算),用极小的空间估算出基数。它的标准误差率大约在 0.81%
  3. 不可逆转: 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 命令也能极快地解决我们计算月活、周活的跨天去重聚合需求。”