Redis 从 0 到 1(十六):1 亿 UV 只占 12KB?计数去重三件套的 Go 实测

我不会起名字322 · 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • [Redis 从 0 到 1(十六):1 亿 UV 只占 12KB?计数去重三件套的 Go 实测](#Redis 从 0 到 1(十六):1 亿 UV 只占 12KB?计数去重三件套的 Go 实测)
    • [一、现象:一个"简单"的 UV 统计把内存吃光了](#一、现象:一个"简单"的 UV 统计把内存吃光了)
    • [二、三件套对比(收藏点 1)](#二、三件套对比(收藏点 1))
    • [三、Go 实操:同一批数据,三种存法(收藏点 2)](#三、Go 实操:同一批数据,三种存法(收藏点 2))
    • [四、选型决策表(收藏点 3)](#四、选型决策表(收藏点 3))
    • 五、命令清单
    • [六、排查步骤:统计类 key 内存异常](#六、排查步骤:统计类 key 内存异常)
    • 七、总结

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
}

关键认知:

  1. PFAdd 的 value 无上限:HLL 内部结构固定,1 个 uid 和 100 亿 uid 都是 12KB------这正是它存在的意义;
  2. PFMerge 可以做"合并区间统计" :PFMERGE uv:week uv:day1 uv:day2 ... 算出 7 天总 UV(天然去重),这是 Set 方案做不到的轻量操作;
  3. 布隆和 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 内存异常

  1. MEMORY USAGE 找到内存大户 key;
  2. 看 key 的编码:OBJECT ENCODING------intset/ht 说明是真 Set,hylllist 是 HLL;
  3. 判断是否"用 Set 存了本该用 HLL 的数据":key 增长与 UV 增长 1:1 且占用线性放大,就是;
  4. 改造路径:新数据并行写 HLL,历史数据无法回填精确值,从切换日起生效。

七、总结

  1. 计数与去重是三件事:INCR 管总量、HLL 管基数、布隆管存在性;
  2. HLL 用 0.81% 误差换 12KB 恒定内存,UV 场景几乎总是正确选择;
  3. PFMerge 让"区间去重统计"变成一次命令;
  4. 需要明细的统计别用概率结构,反过来只要数字就别用 Set。

下一篇预告:统计做完了,下一篇做"到点执行"------下一篇《订单 30 分钟不支付自动关闭------ZSet 延迟队列 Go 实现》,我们用 ZSet 造一个带 ACK、重试和死信的延迟队列。

相关推荐
码剑客6 小时前
Gin+GORM+Redis 项目开发常见性能坑汇总(Go后端避坑指南)
redis·后端·golang·gin
FungLeo7 小时前
成为全栈·Go 后端篇·换后端不改前端:四个客户端如何做兼容回归
flutter·golang·go·成为全栈·兼容测试·taro 小程序
snow@li7 小时前
redis:RedisDesktopManager(RDM/RESP.app)Redis 数据可视化全景深度梳理
数据库·redis·缓存
海棠Flower未眠7 小时前
下单请求处理流程:商品维度分布式锁与 Redis 预扣库存
redis·分布式·junit
我不会起名字3227 小时前
Redis 从 0 到 1(二十一):P99 涨到 80ms,慢查询日志 5 步定位法 + Go 采集器
redis·go·慢查询·排查·slowlog
挖掘狂人7 小时前
Redis 为什么快?把 "存内存" 这句话拆开看
redis·后端·性能优化
codigger8 小时前
Redis 为什么快?把 "存内存" 这句话拆开看
redis·后端·性能优化·程序员
我不会起名字3228 小时前
Redis 从 0 到 1(十八):List、Pub/Sub、Stream 为什么生产只用 Stream?Go 实操
redis·消息队列·go·stream·pub/sub
你看我名字8 小时前
Redis 高可用、持久化与故障切换:从缓存组件到云上基础设施
数据库·redis·缓存