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

213 lines
7.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [Redis, 缓存, 数据结构, HyperLogLog]
create time: 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:
```go
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。
```go
// 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 统计流程
```mermaid
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 汇总"]
```
## 关联笔记
- [[hhs/Redis/02-核心数据类型]]
- [[hhs/Redis/12-Bitmap]]
- [[hhs/Redis/README]]