做数据统计时,基数去重是最常见的需求之一。比如统计某网站一天的独立访客数、某活动页面的UV,或者统计某个接口被多少不同用户调用过。如果直接用Set存储每个用户ID,当用户量达到千万级别时,内存占用会变得非常可观。Redis从2.8.9版本开始提供HyperLogLog数据结构,它用极小的固定内存就能估算出集合的基数,成为海量去重统计场景下的首选方案。本文将结合Node.js实际代码,详细讲解HyperLogLog的原理、用法和注意事项。

一、HyperLogLog的底层原理
HyperLogLog是一种概率型数据结构,它的核心思想基于伯努利试验和概率统计。简单来说,它对每个元素计算哈希值,然后观察哈希值的二进制表示中从低位开始连续0的最大个数。根据概率论,一个随机哈希值末尾出现k个连续0的概率是2的负k次方,也就是说平均每2的k次方个不同元素中,才会出现一个末尾有k个连续0的值。
通过记录观测到的最大连续0的个数k,就可以粗略估计基数大约是2的k次方。但单次观测的误差太大,所以HyperLogLog引入了分桶的概念:将哈希值拆成两部分,低位用于确定桶的编号,高位用于计算连续0的个数。Redis中默认使用16384个桶,每个桶记录各自观测到的最大值,最后通过调和平均数汇总所有桶的数据,得到一个更精确的估计值。
这种设计带来的结果是:无论存入1万个还是1亿个元素,HyperLogLog占用的内存始终约为12KB左右(16384个桶,每桶6bit)。代价是结果是一个近似值,Redis实现的标准误差约为0.81%。也就是说,真实值是100万时,返回的结果可能在98万到102万之间浮动。对于UV统计这类场景,这个精度完全够用。
二、核心命令与Node.js实践
HyperLogLog只有三个核心命令。PFADD用于向HyperLogLog中添加元素,PFCOUNT返回基数估算值,PFMERGE用于合并多个HyperLogLog。命令前缀PF是为了致敬该算法的发明者Philippe Flajolet。下面通过Node.js的node-redis客户端来演示具体用法。
首先是安装依赖并建立连接:
const redis = require('redis');
const client = redis.createClient({
url: 'redis://127.0.0.1:6379'
});
client.on('error', (err) => console.error('Redis连接错误:', err));
(async () => {
await client.connect();
})();
接着实现一个简单的UV统计示例。每当有用户访问页面时,调用pfAdd记录该用户的ID:
// 记录用户访问,userId可以是任意字符串
async function recordVisit(page, userId) {
const key = `uv:${page}:${getDateStr()}`;
await client.pfAdd(key, userId);
}
// 获取某页面当日UV
async function getUV(page) {
const key = `uv:${page}:${getDateStr()}`;
return await client.pfCount(key);
}
// 辅助函数:返回YYYY-MM-DD格式的日期字符串
function getDateStr() {
return new Date().toISOString().slice(0, 10);
}
// 使用示例
await recordVisit('home', 'user:10001');
await recordVisit('home', 'user:10002');
await recordVisit('home', 'user:10001'); // 重复添加不会影响计数
console.log(await getUV('home')); // 输出 2
重复添加相同元素不会导致计数增加,这正是去重的体现。PFMERGE则常用于跨周期统计,比如把一周七天的UV合并得到周UV:
async function getWeeklyUV(page, dates) {
const sourceKeys = dates.map(d => `uv:${page}:${d}`);
const destKey = `uv:week:${page}`;
// 将多个key合并到destKey
await client.pfMerge(destKey, sourceKeys);
const count = await client.pfCount(destKey);
// 用完及时清理,避免残留
await client.del(destKey);
return count;
}
需要注意,合并操作会覆盖目标key中原有的数据。如果目标key本身也有数据且需要保留,应该先合并到临时key再查询。另外,node-redis v4以上版本采用connect连接、命令返回Promise的写法,与旧版回调风格不同,使用时要注意版本差异。
三、误差验证与方案对比
在实际项目中,建议先做一次误差验证,确认精度满足业务要求。可以写入一批已知数量的随机ID,然后对比估算值和真实值:
async function verifyAccuracy(n) {
const key = 'hll:test';
for (let i = 0; i < n; i++) {
await client.pfAdd(key, 'id_' + i);
}
const estimated = await client.pfCount(key);
const err = Math.abs(estimated - n) / n * 100;
console.log(`真实值: ${n}, 估算值: ${estimated}, 误差: ${err.toFixed(2)}%`);
await client.del(key);
}
await verifyAccuracy(1000000);
// 典型输出: 真实值: 1000000, 估算值: 1006284, 误差: 0.63%
将HyperLogLog与Set、Bitmap做对比,可以更清楚地看到它的定位:
| 方案 | 内存占用 | 精确度 | 是否支持判断元素存在 |
|---|---|---|---|
| Set | 随元素数线性增长,千万级可达数百MB | 精确 | 支持 |
| Bitmap | 取决于ID最大值,亿级ID约占12MB | 精确 | 支持 |
| HyperLogLog | 固定约12KB | 误差约0.81% | 不支持 |
从表中可以看出三者的取舍:Set适合需要精确统计且要判断某个元素是否存在的场景;Bitmap在用户ID为连续整数时性价比极高,还能做交并集运算;而HyperLogLog在只需要一个近似计数值、对内存极度敏感的场景下无可替代。一个真实案例是:某产品要统计每天全站的独立设备数,设备量过亿,用Set需要几GB内存,换成HyperLogLog后每天只占12KB,一年365个key加起来也不过几MB。
四、使用中的注意事项
第一,HyperLogLog只能计数,不能取出原始元素,也无法判断某个元素是否已经存在。如果业务上需要查询具体某个用户是否访问过,就只能用Set或Bitmap。
第二,单个key的元素数量没有上限,但要注意key的过期策略。像UV统计按天建key的场景,建议给key设置过期时间,避免历史数据无限累积:
async function recordVisit(page, userId) {
const key = `uv:${page}:${getDateStr()}`;
await client.pfAdd(key, userId);
// 设置30天过期,HyperLogLog被删除后无法恢复数据
await client.expire(key, 30 * 24 * 3600);
}
第三,PFADD在元素已存在时返回0,新元素加入时返回1,可以据此做一些逻辑判断,但不要用返回值来累计计数,计数必须以PFCOUNT为准。第四,稀疏存储的HyperLogLog在数据量小时只占几百字节,Redis会自动在稀疏和稠密两种编码间切换,无需人工干预,这也是它对小数据量同样友好的原因。
总结来说,HyperLogLog以固定的极小内存换来了亿级基数估算能力,配合Node.js的异步客户端使用非常简洁。凡是只需要一个大概的去重数量、不要求精确到个位的统计场景,比如UV、PV去重、搜索词多样性统计等,都可以放心使用;而需要精确计数或元素级查询时,再考虑Set和Bitmap方案。
RedisHyperLogLogNode.js修改时间:2026-09-13 07:32:28