我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- [Redis 从 0 到 1(十六):1 亿 UV 只占 12KB?计数去重三件套的 Go 实测](#Redis 从 0 到 1(十六):1 亿 UV 只占 12KB?计数去重三件套的 Go 实测)
Redis 从 0 到 1(十六):1 亿 UV 只占 12KB?计数去重三件套的 Go 实测
上一篇《Redis 从 0 到 1(十五):千万玩家积分榜------ZSet 排行榜的 Go 实现 5 细节》我们解决了排行榜,链接:https://blog.csdn.net/2501_92769340/article/details/167399728 。
本篇处理排行榜的"亲戚需求":计数与去重------每天多少 UV?这个用户出现过吗?
一、现象:一个"简单"的 UV 统计把内存吃光了
运营要"每日 UV",第一版实现:SADD uv:20261010 <uid>,每天一个 Set。上线一周后 Redis 内存告警------Set 里每个 uid 占 60~80 字节(dictEntry + sds 开销),1000 万 UV 就是 700MB,双十一冲 1 亿就是 7GB。而真正的信息量只有一句话:"今天大概有一亿个人来过"。
精确存储每个元素,是为"只要一个数字"的需求付了 1000 倍的内存。 Redis 给了三个不同精度的答案。
二、三件套对比(收藏点 1)
| 方案 | 回答的问题 | 误差 | 1 亿级内存 | 适用 |
|---|---|---|---|---|
| INCR | 总量是多少(PV、订单数) | 0 | 一个 key | 不需要去重 |
| HyperLogLog | 有多少个不同元素(UV、去重 IP) | 标准误差 0.81% | 固定 12KB | 只要数量、允许小误差 |
| 布隆过滤器 | 这个元素出现过吗 | 有误判、无漏判 | 百 KB ~ MB 级 | 只要判断"可能存在吗" |
核心思想:三个方案都用"用精度换空间"的概率数据结构。HLL 用哈希位模式估算基数;布隆用位图 + 多哈希。
三、Go 实操:同一批数据,三种存法(收藏点 2)
go
package main
import (
"context"
"fmt"
"math/rand"
"time"
"redis-0to1/redisx"
)
func main() {
rdb, err := redisx.NewClient(redisx.Config{Addr: "127.0.0.1:6379", Password: "redis123", PoolSize: 20})
if err != nil {
panic(err)
}
ctx := context.Background()
today := time.Now().Format("20060102")
// 模拟 100 万"用户访问",其中只有 20 万个不同 uid(同一用户多次访问)
uids := make([]string, 0, 200000)
for i := 0; i < 200000; i++ {
uids = append(uids, fmt.Sprintf("u%07d", i))
}
pipe := rdb.Pipeline()
for i := 0; i < 1000000; i++ {
uid := uids[rand.Intn(200000)]
// 方案 A:PV 计数(不需要去重)
pipe.Incr(ctx, "pv:"+today)
// 方案 B:HyperLogLog 记录 UV
pipe.PFAdd(ctx, "uv:hll:"+today, uid)
// 方案 C:布隆过滤器(第 11 篇的 MightExist 同款)
// bloom.Add(ctx, rdb, uid) // 略
if i%10000 == 0 {
if _, err := pipe.Exec(ctx); err != nil {
panic(err)
}
pipe = rdb.Pipeline()
}
}
pipe.Exec(ctx)
pv, _ := rdb.Get(ctx, "pv:"+today).Result()
uv, _ := rdb.PFCount(ctx, "uv:hll:"+today).Result()
fmt.Println("PV(总访问):", pv) // 1000000
fmt.Println("UV(HLL 估算):", uv) // ≈201000(真实 200000,误差 ~0.5%)
// 内存对比:MEMORY USAGE 三个 key
for _, k := range []string{"uv:hll:" + today} {
u, _ := rdb.MemoryUsage(ctx, k, 0).Result()
fmt.Printf("%s 占用: %d 字节\n", k, u) // HLL 固定 12304 字节(12KB),与元素数无关
}
// 对照:同样 20 万 uid 放进 Set 约 16MB
}
关键认知:
- PFAdd 的 value 无上限:HLL 内部结构固定,1 个 uid 和 100 亿 uid 都是 12KB------这正是它存在的意义;
- PFMerge 可以做"合并区间统计" :
PFMERGE uv:week uv:day1 uv:day2 ...算出 7 天总 UV(天然去重),这是 Set 方案做不到的轻量操作; - 布隆和 HLL 别混用场景:布隆回答"这个 uid 来过吗",HLL 回答"今天来了多少人"。
四、选型决策表(收藏点 3)
| 需求原话 | 正确方案 | 反例 |
|---|---|---|
| "今天访问了多少次" | INCR | 用 Set 存每次访问 = 内存爆炸 |
| "今天有多少不同的人" | PFAdd + PFCount | 用 Set 存 uid(内存 ×1000) |
| "这个用户今天来过了吗" | 布隆 / Set(量小) | 用 HLL 判断不了单个元素 |
| "7 天一共有多少不同用户" | PFMerge 7 个日 HLL | 7 个 Set 做并集 = 全量加载 |
再补一刀:如果业务就是需要"精确 UV 且能列出具体用户"(比如发奖名单),那只能 Set/Hash,别无选择------概率结构省的内存,是用"拿不到明细"换来的。
五、命令清单
| 命令 | 作用 |
|---|---|
INCR / INCRBY / DECR |
原子计数 |
PFADD key [elem...] |
HLL 添加元素 |
PFCOUNT key [key...] |
估算基数;多 key 自动先合并再计数 |
PFMERGE dest src... |
合并多个 HLL(去重并集) |
SETBIT / GETBIT / BITCOUNT |
布隆过滤器的位操作底座 |
MEMORY USAGE key |
验证内存占用(本文的实测工具) |
六、排查步骤:统计类 key 内存异常
MEMORY USAGE找到内存大户 key;- 看 key 的编码:
OBJECT ENCODING------intset/ht 说明是真 Set,hylllist 是 HLL; - 判断是否"用 Set 存了本该用 HLL 的数据":key 增长与 UV 增长 1:1 且占用线性放大,就是;
- 改造路径:新数据并行写 HLL,历史数据无法回填精确值,从切换日起生效。
七、总结
- 计数与去重是三件事:INCR 管总量、HLL 管基数、布隆管存在性;
- HLL 用 0.81% 误差换 12KB 恒定内存,UV 场景几乎总是正确选择;
- PFMerge 让"区间去重统计"变成一次命令;
- 需要明细的统计别用概率结构,反过来只要数字就别用 Set。
下一篇预告:统计做完了,下一篇做"到点执行"------下一篇《订单 30 分钟不支付自动关闭------ZSet 延迟队列 Go 实现》,我们用 ZSet 造一个带 ACK、重试和死信的延迟队列。