在数据统计场景中,有一类问题看似简单却暗藏玄机:一个电商平台想知道今天有多少独立用户访问了首页,一个内容社区想统计某篇文章被多少不同的人阅读过,一个广告系统要计算某次投放触达了多少独立设备。这类问题的本质都是基数统计,也就是去重计数。如果用最朴素的思路,把每个用户ID都存进Set里再取长度,一千万用户可能就要消耗几百MB内存,数据量再往上翻,单机内存直接告急。Redis提供的HyperLogLog就是为解决这类问题而生的,它用固定且极小的内存空间完成了海量数据的基数估算,下面我们详细展开。

一、什么是基数问题,为什么传统方案不够用
基数(Cardinality)指的是一个集合中不重复元素的个数。比如数据集{1, 3, 5, 3, 7, 1}的基数是4,因为去重后只有1、3、5、7这四个元素。基数统计在互联网业务中无处不在:日活用户数(DAU)、UV、PV去重、搜索关键词去重数量等等,本质上都是基数问题。
传统的精确统计方案主要有两种。第一种是使用Redis的Set结构,每来一个用户ID就执行一次SADD,最后用SCARD获取基数。这种方案是精确的,但内存开销与元素数量成正比,一个Set存储一亿个用户ID,按每个ID占用几十字节计算,内存消耗可能达到数GB,对于只需要一个数字的业务来说代价太高。第二种是数据库层面的SELECT COUNT(DISTINCT user_id),这种查询在大表上性能极差,容易把数据库拖垮。
还有一点值得注意:很多业务场景其实并不要求绝对精确。运营报表上显示今日UV是1,002,384还是1,010,000,对业务决策几乎没有影响。正是这种对误差的容忍度,催生了概率型数据结构的用武之地,HyperLogLog就是其中最经典的代表。
二、HyperLogLog的底层原理通俗解读
HyperLogLog这个名字听起来唬人,但核心思想并不难理解。它最早由Flajolet等人在2007年的论文中提出,Redis从2.8.9版本开始支持。它的基本思路是:通过对元素做哈希,把任意输入映射为一串均匀分布的二进制比特串,然后利用比特串中出现的规律性特征来估算基数。
具体来说,HyperLogLog会关注哈希值二进制表示中从最低位开始连续出现0的个数(也叫低位连续零的长度,记作rank)。直观理解是:如果一个集合里有N个不同的元素,它们的哈希值会均匀散布在值域空间中。元素越多,越有可能出现某个哈希值的低位有很长的连续0。反过来,观测到的最大rank值与基数N之间存在数学关系,大致满足N约等于2的rank次方。这就像抛硬币:抛的次数越多,越容易出现连续很多次正面朝上的情况。
为了降低单次观测带来的误差,HyperLogLog把哈希空间分成多个桶(Redis中是16384个桶),每个桶独立记录最大rank值,最后通过调和平均数把所有桶的结果综合起来。Redis的实现中,无论输入多少个元素,HyperLogLog占用的内存最多约12KB,标准误差约为0.81%。换句话说,用12KB的固定内存就能估算上亿甚至更多元素的基数,这是精确方案完全无法企及的性价比。
三、三个核心命令的用法详解
Redis为HyperLogLog提供了三个命令,命令前缀统一是PF,是为了纪念算法提出者Philippe Flajolet。
第一个是PFADD,用于添加元素。语法为PFADD key element [element ...]。可以一次添加一个元素,也可以批量添加。当HyperLogLog的内部状态因此发生变化时返回1,否则返回0。实际应用中通常不需要关心返回值,直接添加即可。
第二个是PFCOUNT,用于获取基数估算值。语法为PFCOUNT key [key ...]。如果传入多个key,返回的是这些HyperLogLog并集的基数估算值,Redis会在内部自动完成合并计算而不修改原始key,这一点在做多天数据汇总时非常方便。
第三个是PFMERGE,用于合并多个HyperLogLog。语法为PFMERGE destkey sourcekey [sourcekey ...],把多个源key合并到目标key中。比如把每天的用户访问记录合并成一周的记录,就用得上它。
redis> PFADD uv:2024-01-01 "user:1001" "user:1002" "user:1003" (integer) 1 redis> PFADD uv:2024-01-01 "user:1001" (integer) 0 redis> PFCOUNT uv:2024-01-01 (integer) 3 redis> PFADD uv:2024-01-02 "user:1002" "user:1004" (integer) 1 # 直接统计两天的独立用户总数,无需真正合并 redis> PFCOUNT uv:2024-01-01 uv:2024-01-02 (integer) 4 # 也可以显式合并成一个新key redis> PFMERGE uv:two-days uv:2024-01-01 uv:2024-01-02 OK redis> PFCOUNT uv:two-days (integer) 4
需要注意,HyperLogLog中只存的是估算结构,无法反推出具体有哪些元素,也不能删除单个元素。如果业务需要回溯成员明细,HyperLogLog就无能为力了,这是设计上的取舍而非性能缺陷。
四、在Spring Boot中实战UV统计
在Java项目中,通过Spring Data Redis操作HyperLogLog非常简单,HyperLogLogOperations接口封装了完整的操作。下面是一段可直接运行的示例代码。
@Service
public class UvStatService {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 记录一次页面访问,userId可以是用户ID或设备指纹
*/
public void recordVisit(String date, String userId) {
String key = "uv:" + date;
stringRedisTemplate.opsForHyperLogLog()
.add(key, userId);
// 给key设置过期时间,避免历史数据无限堆积
stringRedisTemplate.expire(key, Duration.ofDays(30));
}
/**
* 查询某天的独立访客数
*/
public long getUv(String date) {
return stringRedisTemplate.opsForHyperLogLog()
.size("uv:" + date);
}
/**
* 统计某几天的总独立访客数
*/
public long getUvRange(List<String> dates) {
List<String> keys = dates.stream()
.map(d -> "uv:" + d)
.collect(Collectors.toList());
return stringRedisTemplate.opsForHyperLogLog()
.size(keys.toArray(new String[0]));
}
}
实际落地时还有几个细节建议。第一,key的命名要带上时间维度,比如uv:2024-01-01,方便按天统计和设置过期。第二,一定要给key设置TTL,统计完的历史数据留着没有意义。第三,如果对精度要求较高,可以在写入时同时落一份明细日志到数据仓库,HyperLogLog负责实时展示,离线链路负责精确对账,两者互补。
五、Set、Bitmap与HyperLogLog如何选型
同样是去重统计,Redis其实提供了三种常用方案,它们的特性差异很大,选型前要想清楚业务需求。三者的核心对比见下表。
| 方案 | 内存占用 | 是否精确 | 能否回溯元素 | 适用场景 |
|---|---|---|---|---|
| Set | 随元素数增长 | 精确 | 可以 | 元素数量少,需要成员明细 |
| Bitmap | 与ID范围相关 | 精确 | 可以(按位判断) | 用户ID为连续整数且范围可控 |
| HyperLogLog | 固定约12KB | 约0.81%误差 | 不可以 | 海量数据、只需一个数字 |
RedisHyperLogLog基数统计修改时间:2026-09-12 12:12:44