Files
cs-note/hhs/Redis/13-HyperLogLog.md
T
2026-05-24 21:18:14 +08:00

7.4 KiB
Raw Blame History

tags, create time
tags create time
Redis
缓存
数据结构
HyperLogLog
2026-05-24 10:00

Redis HyperLogLog 概率计数

概述

HyperLogLog(HLL)是 Redis 提供的概率型基数估算数据结构——用固定的 12KB 内存,就能估算高达 2^64 个不同元素的数量,标准误差仅 0.81%。它不存储元素本身,只回答"有多少个不同的值",是 UV 统计、去重计数等场景的利器。

[!question] 为什么不直接用 Set 做去重计数? 1 亿个用户 ID 存进 Set,每个 ID 至少占几十字节,轻松吃掉几 GB 内存。HyperLogLog 只需要 12KB,代价是允许 0.81% 的误差——对于"今天有多少独立访客"这类问题,这个精度完全够用。

一、核心原理

概率思想:用"前导零"推算基数

HyperLogLog 的直觉来源于伯努利试验:抛一枚均匀硬币,连续出现 k 次正面的概率是 1/2^k。如果我们在所有试验中观察到最长的连续正面次数为 k,就可以反推大约有 2^k 个不同的试验。

实际做法:

  1. 哈希映射:对每个元素做哈希,得到一个均匀分布的比特串
  2. 分桶:用前 14 位定位到 2^14 = 16384 个桶中的一个
  3. 记录前导零:对剩余比特,统计第一个 1 出现前连续 0 的个数(取最大值)
  4. 调和均值:所有桶的值通过调和均值公式合并,得到基数估算

[!tip] 关键洞察 HLL 不存储元素,只存储每个桶的"最大前导零位数"。每个桶只需 6 个比特(能表示 0~63),所以总内存 = 16384 × 6 bit ≈ 12KB,无论存 1 个元素还是 10 亿个元素。

Redis 中只有 3 个命令

命令 作用 时间复杂度
PFADD key element [element ...] 向 HLL 添加元素 O(N),N 为元素个数
PFCOUNT key [key ...] 返回基数估算值 O(N),N 为 HLL 个数
PFMERGE dest source [source ...] 合并多个 HLL O(N),N 为 HLL 个数

[!question] 为什么命令叫 PF 开头? PF 是 Philippe Flajolet 的名字缩写——他是 HyperLogLog 算法论文的主要作者。

二、UV 统计实战

用 Go 实现页面 UV 计数

以下是一个完整的 Web UV 统计示例,使用 go-redis v9:

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/redis/go-redis/v9"
)

var rdb *redis.Client
var ctx = context.Background()

func init() {
	rdb = redis.NewClient(&redis.Options{
		Addr: "localhost:6379",
		DB:   0,
	})
}

// RecordVisit 记录一次页面访问,visitorID 为用户唯一标识
func RecordVisit(page string, visitorID string) error {
	key := fmt.Sprintf("uv:%s:%s", page, time.Now().Format("2006-01-02"))
	// PFADD:如果 visitorID 已存在,不会重复计数(概率去重)
	return rdb.PFAdd(ctx, key, visitorID).Err()
}

// GetUV 获取某页面当日独立访客数
func GetUV(page string) (int64, error) {
	key := fmt.Sprintf("uv:%s:%s", page, time.Now().Format("2006-01-02"))
	// PFCOUNT 返回估算的基数,误差约 0.81%
	return rdb.PFCount(ctx, key).Result()
}

func main() {
	// 模拟 10000 个不同用户访问首页
	for i := 0; i < 10000; i++ {
		visitorID := fmt.Sprintf("user_%d", i)
		if err := RecordVisit("home", visitorID); err != nil {
			log.Fatal(err)
		}
	}

	uv, _ := GetUV("home")
	fmt.Printf("首页今日 UV: %d\n", uv) // 输出约 10000,误差极小
}

[!tip] 对比 Set 方案 如果用 SADD + SCARD 实现同样功能,10000 个用户 ID 至少消耗 数百 KB;而 HyperLogLog 的 uv:home:2026-05-24 这个 key 始终只占 12KB。用户量越大,HLL 的优势越明显。

三、合并多个统计源

实际业务中经常需要合并数据——比如从每日 UV 汇总出每周 UV。

// MergeWeeklyUV 将 7 天的日 UV 合并为周 UV
func MergeWeeklyUV(page string, weekStart time.Time) error {
	destKey := fmt.Sprintf("uv:%s:week:%s", page, weekStart.Format("2006-01-02"))

	// 构造 7 天的源 key
	var sourceKeys []string
	for i := 0; i < 7; i++ {
		day := weekStart.AddDate(0, 0, i)
		sourceKeys = append(sourceKeys, fmt.Sprintf("uv:%s:%s", page, day.Format("2006-01-02")))
	}

	// PFMERGE:将多个 HLL 合并到 dest,结果仍是 HLL(12KB)
	// 合并后的基数 ≈ 所有源集合的并集大小
	return rdb.PFMerge(ctx, destKey, sourceKeys...).Err()
}

// GetWeeklyUV 获取周 UV
func GetWeeklyUV(page string, weekStart time.Time) (int64, error) {
	key := fmt.Sprintf("uv:%s:week:%s", page, weekStart.Format("2006-01-02"))
	return rdb.PFCount(ctx, key).Result()
}

[!question] PFMERGE 是精确合并吗? 是的。PFMERGE 会取每个桶在所有源 HLL 中的最大值,数学上等价于对并集重新计算 HLL。合并后再 PFCOUNT,得到的就是并集的估算基数,误差不会因为合并而放大。

四、误差分析

HyperLogLog 的标准误差为 1.04 / sqrt(m),其中 m 是桶数。Redis 使用 16384 个桶:

标准误差 = 1.04 / sqrt(16384) ≈ 0.81%
实际基数 估算误差范围(±0.81%)
1,000 ±8
100,000 ±810
10,000,000 ±81,000

[!question] 0.81% 的误差在实际中能接受吗? 绝大多数场景完全可以。UV 统计本身就有缓存穿透、机器人流量等噪声,0.81% 的误差远小于这些业务噪声。但如果你需要精确计数(比如库存扣减),请用 Set 或数据库。

五、内存计算

HyperLogLog 的内存占用是固定的,与元素数量无关:

桶数 = 2^14 = 16384
每个桶 = 6 bit
总内存 = 16384 × 6 bit = 98304 bit ≈ 12KB

[!tip] Redis 的内存优化 当 HLL 计数的基数较小时(稀疏表示),Redis 会用更紧凑的编码存储,可能只占几百字节。只有当基数增长到一定程度,才会扩展到完整的 12KB。这个过程是自动的,对用户透明。

场景 Set 内存 HyperLogLog 内存
1 万个 ID ~500KB 12KB
100 万个 ID ~50MB 12KB
1 亿个 ID ~5GB 12KB

六、适用场景 vs 不适用场景

适合使用 HyperLogLog

  • 页面/接口 UV 统计
  • 搜索关键词去重数
  • 广告曝光独立用户数
  • 任何"只需要知道有多少个不同值"的场景

不适合使用 HyperLogLog

  • 需要知道具体有哪些元素:HLL 不存储元素本身,无法枚举
  • 需要精确计数:0.81% 误差不可接受的场景(如库存、余额)
  • 数据量极小(< 1000):此时 Set 占用内存也很小,且精确无误

[!question] 三种方案怎么选?

维度 HyperLogLog Set Bitmap
功能 基数估算 精确去重 + 列举 位标记 + 计数
内存 固定 12KB 随元素线性增长 约 max_id / 8 字节
精度 ~99.19% 100% 100%
典型场景 UV 统计 标签、好友列表 签到、在线状态

[!tip] 一句话决策 只需要"有多少个"→ HyperLogLog;需要"有哪些"→ Set;需要"某个 ID 在不在"→ Bitmap。

七、UV 统计流程

graph LR
    A["用户请求页面"] --> B["PFADD uv:page:date visitor_id"]
    B --> C["Redis HyperLogLog"]
    C --> D["PFCOUNT uv:page:date"]
    D --> E["返回估算 UV 数"]
    E --> F["展示在监控面板"]
    G["每日凌晨"] --> H["PFMERGE uv:page:week daily_keys"]
    H --> I["周报 UV 汇总"]

关联笔记