2026-05-24 21:18:14 +08:00
|
|
|
|
---
|
2026-06-08 23:08:57 +08:00
|
|
|
|
tags: [Redis, HyperLogLog]
|
2026-05-24 21:18:14 +08:00
|
|
|
|
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 个不同的试验。
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
> [!tip] 生活化理解
|
|
|
|
|
|
> 想象你在一个黑暗的房间里掷骰子,每次掷完只告诉你"前几个骰子是 1"。如果有人掷了 20 次才看到第一个不是 1 的骰子,你大概能猜出他掷了很多次——因为连续掷 20 个 1 的概率极低。HyperLogLog 就是用这个"前导零"的思想来估算数量。
|
|
|
|
|
|
|
2026-05-24 21:18:14 +08:00
|
|
|
|
实际做法:
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
1. **哈希映射**:对每个元素做哈希,得到一个均匀分布的比特串(比如 `00010110...`)
|
2026-05-24 21:18:14 +08:00
|
|
|
|
2. **分桶**:用前 14 位定位到 2^14 = 16384 个桶中的一个
|
|
|
|
|
|
3. **记录前导零**:对剩余比特,统计第一个 1 出现前连续 0 的个数(取最大值)
|
2026-06-08 23:08:57 +08:00
|
|
|
|
- 例如 `00010110...` 的前导零是 3
|
|
|
|
|
|
4. **调和均值**:所有桶的值通过调和均值公式合并,得到基数估算(避免极端值影响整体精度)
|
2026-05-24 21:18:14 +08:00
|
|
|
|
|
|
|
|
|
|
> [!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 算法论文的主要作者。
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
> [!warning] PFCOUNT 多 key vs PFMERGE 的区别
|
|
|
|
|
|
> - `PFCOUNT key1 key2 ...`:临时计算多个 HLL 的并集基数,**不持久化结果**,每次都要重新计算
|
|
|
|
|
|
> - `PFMERGE dest src1 src2 ...`:将多个 HLL 合并到 `dest`,**持久化结果**,之后 `PFCOUNT dest` 只需读单个 key
|
|
|
|
|
|
>
|
|
|
|
|
|
> 如果同一组 HLL 需要反复查询,应先 `PFMERGE` 再 `PFCOUNT`,避免重复计算开销。
|
|
|
|
|
|
|
2026-05-24 21:18:14 +08:00
|
|
|
|
## 二、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"))
|
2026-06-08 23:08:57 +08:00
|
|
|
|
// PFADD:相同元素多次添加不会影响基数估算(HLL 不做去重,而是相同哈希值不产生新的前导零模式)
|
2026-05-24 21:18:14 +08:00
|
|
|
|
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 的优势越明显。
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
> [!warning] 别忘了给日 UV key 设过期时间
|
|
|
|
|
|
> 上面的代码按日期生成 key(如 `uv:home:2026-05-24`),如果不设置 TTL,历史 key 会永远堆积。建议在 `PFADD` 后追加 `Expire(ctx, key, 7*24*time.Hour)`,保留 7 天即可满足周报表合并需求,之后自动清理。
|
|
|
|
|
|
|
2026-05-24 21:18:14 +08:00
|
|
|
|
## 三、合并多个统计源
|
|
|
|
|
|
|
|
|
|
|
|
实际业务中经常需要合并数据——比如从每日 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,得到的就是**并集的估算基数**,误差不会因为合并而放大。
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
> [!warning] 无法用 HLL 计算交集
|
|
|
|
|
|
> PFMERGE 计算的是并集基数。很多人误以为 `|A ∩ B| = |A| + |B| - |A ∪ B|` 可以推导交集——但这是**错误的**,因为 |A|、|B|、|A ∪ B| 都是估算值,三者相减的误差会被放大到完全不可用。如果需要交集计数,请用 Set 或 Bloom Filter。
|
|
|
|
|
|
|
2026-05-24 21:18:14 +08:00
|
|
|
|
## 四、误差分析
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
> [!tip] Redis 的内存优化:稀疏 → 稠密
|
|
|
|
|
|
> 当 HLL 计数的基数较小时,Redis 使用**稀疏表示**(sparse)——只记录有变化的桶,内存可能只占几百字节。随着基数增长,当稀疏编码的大小超过阈值时,Redis 会**一次性展开为 12KB 的稠密表示**(dense),之后不会再回退。
|
|
|
|
|
|
>
|
|
|
|
|
|
> 这个转换是自动的,但要注意:转换发生在 PFADD 写入时,可能造成一次**瞬间内存跳变**(几百字节 → 12KB)。在高并发场景下,大量 HLL key 同时触发转换会带来内存尖峰,建议提前预热或评估 key 数量。
|
2026-05-24 21:18:14 +08:00
|
|
|
|
|
|
|
|
|
|
| 场景 | Set 内存 | HyperLogLog 内存 |
|
|
|
|
|
|
|------|---------|-----------------|
|
|
|
|
|
|
| 1 万个 ID | ~500KB | 12KB |
|
|
|
|
|
|
| 100 万个 ID | ~50MB | 12KB |
|
|
|
|
|
|
| 1 亿个 ID | ~5GB | 12KB |
|
|
|
|
|
|
|
|
|
|
|
|
## 六、适用场景 vs 不适用场景
|
|
|
|
|
|
|
|
|
|
|
|
### 适合使用 HyperLogLog
|
|
|
|
|
|
|
|
|
|
|
|
- 页面/接口 UV 统计
|
|
|
|
|
|
- 搜索关键词去重数
|
|
|
|
|
|
- 广告曝光独立用户数
|
|
|
|
|
|
- 任何"只需要知道有多少个不同值"的场景
|
|
|
|
|
|
|
|
|
|
|
|
### 不适合使用 HyperLogLog
|
|
|
|
|
|
|
|
|
|
|
|
- **需要知道具体有哪些元素**:HLL 不存储元素本身,无法枚举
|
|
|
|
|
|
- **需要精确计数**:0.81% 误差不可接受的场景(如库存、余额)
|
|
|
|
|
|
- **数据量极小(< 1000)**:此时 Set 占用内存也很小,且精确无误
|
|
|
|
|
|
|
|
|
|
|
|
> [!question] 三种方案怎么选?
|
2026-06-08 23:08:57 +08:00
|
|
|
|
> 以下是三种数据结构的对比,帮你快速决策:
|
2026-05-24 21:18:14 +08:00
|
|
|
|
|
|
|
|
|
|
| 维度 | HyperLogLog | Set | Bitmap |
|
|
|
|
|
|
|------|------------|-----|--------|
|
|
|
|
|
|
| 功能 | 基数估算 | 精确去重 + 列举 | 位标记 + 计数 |
|
|
|
|
|
|
| 内存 | 固定 12KB | 随元素线性增长 | 约 max_id / 8 字节 |
|
|
|
|
|
|
| 精度 | ~99.19% | 100% | 100% |
|
|
|
|
|
|
| 典型场景 | UV 统计 | 标签、好友列表 | 签到、在线状态 |
|
|
|
|
|
|
|
|
|
|
|
|
> [!tip] 一句话决策
|
|
|
|
|
|
> 只需要"有多少个"→ HyperLogLog;需要"有哪些"→ Set;需要"某个 ID 在不在"→ Bitmap。
|
|
|
|
|
|
|
|
|
|
|
|
## 七、UV 统计流程
|
|
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
graph LR
|
2026-06-08 23:08:57 +08:00
|
|
|
|
A["用户请求页面"] --> B["PFADD 写入元素"]
|
|
|
|
|
|
B --> C["EXPIRE 设置 TTL"]
|
|
|
|
|
|
C --> D["Redis HyperLogLog"]
|
|
|
|
|
|
D --> E["PFCOUNT 获取基数"]
|
2026-05-24 21:18:14 +08:00
|
|
|
|
E --> F["展示在监控面板"]
|
2026-06-08 23:08:57 +08:00
|
|
|
|
G["每日凌晨"] --> H["PFMERGE 合并日 UV"]
|
2026-05-24 21:18:14 +08:00
|
|
|
|
H --> I["周报 UV 汇总"]
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 关联笔记
|
|
|
|
|
|
|
|
|
|
|
|
- [[hhs/Redis/02-核心数据类型]]
|
|
|
|
|
|
- [[hhs/Redis/12-Bitmap]]
|