Redis - 海量 Key 统计方案:四种统计模式下的集合类型选型
文章目录

海量 Key 统计方案:四种统计模式下的集合类型选型
在 Web 与移动端业务里,"一个 key 对应一组数据"是再常见不过的形态:每天登录的用户 ID、商品下挂的评论列表、用户的签到记录、网页的访问明细,本质上都是 key 到集合的映射。Redis 的集合类型天然契合这种形态,但当数据量级跃迁到千万乃至亿级,仅仅"能存"已经不够,能否高效统计、还能控制内存,才是真正的考验。
这篇文章把集合统计归纳为四种典型模式:聚合统计、排序统计、二值状态统计、基数统计,每种模式各自有最合适的数据结构。理解了这套对应关系,再面对类似问题就不需要每次重新推演。
聚合统计:Set 的交集、并集与差集
聚合统计指的是对多个集合做交、并、差运算。最经典的场景是统计 App 的新增用户和留存用户。
设计思路是用两个 Set:一个累计用户 Set(key 设为 user:id),保存所有登录过的用户 ID;

另一个每日用户 Set(key 设为 user:id:20200803 这种带日期的形式),只保存当天的登录用户。

计算每日新增用户用差集。比如 8 月 4 日,把当天用户与累计用户做差集,结果就是当天的新增:
SDIFFSTORE user:new user:id:20200804 user:id
计算次日留存用户用交集。把今天和昨天的每日用户 Set 求交集,留下来的就是连续两天都活跃的用户:
SINTERSTORE user:id:rem user:id:20200803 user:id:20200804
累计用户的更新用并集。每天的每日用户 Set 与累计 Set 求并集后回写到累计 Set:
SUNIONSTORE user:id user:id user:id:20200803
Set 的聚合操作功能强大,但有一个隐患不能忽视:交集、并集、差集的复杂度都和集合元素数量正相关,数据量上去之后单条命令就可能阻塞主线程。生产环境里有两种常见的规避手段:
- 一种是在主从集群里挑一个从库专门跑聚合,但要注意从库默认 readonly,
SUNIONSTORE这类生成新 key 的命令在从库无法执行,只能用SUNION、SDIFF、SINTER这种返回结果但不写新 key 的版本; - 另一种是把数据拉到客户端做聚合,把 CPU 压力转移出 Redis。如果用的是切片集群,还要额外考虑参与运算的多个 key 是否落在同一个实例上,跨节点聚合会直接报错。
排序统计:List 的分页陷阱与 Sorted Set 的优雅
电商场景里的"最新评论列表"看起来简单,实现起来却容易踩坑。直觉上 List 完全够用:每条新评论用 LPUSH 推到队头,分页时 LRANGE 0 2 取第一页,LRANGE 3 5 取第二页。
问题出在分页期间有新评论插入。假设评论列表初始是 {A, B, C, D, E, F},用户翻完第一页 A、B、C 之后,恰好有一条新评论 G 进来,列表变成了 {G, A, B, C, D, E, F}。这时候用户翻第二页,LRANGE 3 5 拿到的是 C、D、E。C 已经在第一页出现过了,等于翻页"看重了"。
根因在于 List 是按位置排序的,新元素插入会导致原有元素的下标整体后移。要解决这个问题,得让排序依据本身是稳定的,这正好是 Sorted Set 的强项。把评论时间戳作为权重 score 写入 Sorted Set,再通过 ZRANGEBYSCORE 按权重区间取数:
ZRANGEBYSCORE comments N-9 N
不管中间插入了多少新元素,只要权重区间不变,取出的结果就是稳定的。需要展示最新列表、排行榜,特别是涉及分页的场景,优先选 Sorted Set。
二值状态统计:Bitmap 的极致空间效率
签到、是否在线、商品是否售罄这类只有 0/1 两态的数据,用 Set 或 Hash 来存太奢侈了。一个用户一天的签到状态,理论上 1 个 bit 就够了,一年也只需要 365 个 bit,约 46 字节。

Bitmap 是 Redis 基于 String 实现的扩展类型,把字符串底层的字节数组当作 bit 数组用。核心命令有三个:
# 标记 ID 3000 的用户在 8 月 3 号已签到(offset 从 0 开始,所以 8 月 3 日对应 offset 2)
SETBIT uid:sign:3000:202008 2 1
# 检查 8 月 3 号是否签到
GETBIT uid:sign:3000:202008 2
# 统计当月签到次数
BITCOUNT uid:sign:3000:202008
Bitmap 还提供 BITOP 命令支持多个 Bitmap 之间按位的与、或、异或运算。一个常用的扩展场景:统计 1 亿用户连续 10 天签到的人数。每天用一个 1 亿位的 Bitmap,每个 bit 对应一个用户当天的签到。10 个 Bitmap 做按位与,结果 Bitmap 中 bit 为 1 的位置就是连续 10 天都签到的用户,最后用 BITCOUNT 数一下即可。
内存账也好算:1 亿位约 12MB(10^8 / 8 / 1024 / 1024),10 天约 120MB,对于亿级用户的连续行为分析来说完全可控。生产环境里建议给 Bitmap 设 TTL,过期数据自动清理,避免历史数据无限堆积。
实践中需要注意一个坑:Bitmap 的 offset 通常是用户 ID。如果用户 ID 起步就是几百万、几千万这种连续递增的大整数,前面几百万 bit 都是浪费的"空洞"。如果业务用户基数不大但 ID 值偏大,Bitmap 反而比 Hash 更费内存。这种情况下要么对 ID 做映射、要么直接换数据结构。
基数统计:HyperLogLog 用 12KB 估算亿级 UV
UV(独立访客数)是基数统计的代表,关键诉求是去重。直觉的实现是用 Set:
SADD page1:uv user1
SCARD page1:uv
Set 的 SCARD 是 O(1),统计很快,但当 UV 飙到千万级,单个页面要存千万个用户 ID,对一个有几万个热门页面的网站来说,内存压力会非常恐怖。
Hash 也能做,但同样存在内存问题,本质上没解决基数统计的核心矛盾:要去重就得记住所有元素。
如果业务能容忍少量误差,HyperLogLog 是天降甘霖。它的最大优势是无论统计多少元素,单个 HLL 的内存占用始终固定为 12KB,理论上可以估算接近 2^64 个元素的基数。代价是统计结果有约 0.81% 的标准误差。
# 添加访客
PFADD page1:uv user1 user2 user3 user4 user5
# 获取 UV 估算值
PFCOUNT page1:uv
# 多个页面合并统计
PFCOUNT page1:uv page2:uv page3:uv
PFMERGE page_union:uv page1:uv page2:uv page3:uv
HyperLogLog 的实现基于伯努利试验和概率估计,应用层不用关心细节。要记住的红线是:误差对业务有影响的场景(比如计费、风控)不能用,老老实实用 Set 或 Hash。
选型对照表

把四种模式和对应的数据结构总结成一张表:
| 统计模式 | 推荐数据结构 | 关键命令 | 备选 |
|---|---|---|---|
| 聚合统计(交并差) | Set | SINTERSTORE / SUNIONSTORE / SDIFFSTORE | Sorted Set 支持交并不支持差;Bitmap 支持位运算 |
| 排序统计(分页有序) | Sorted Set | ZRANGEBYSCORE / ZADD | List 仅在不分页时可用 |
| 二值状态统计 | Bitmap | SETBIT / GETBIT / BITCOUNT / BITOP | Set/Hash 也能做但内存成本高 |
| 基数统计(去重计数) | HyperLogLog | PFADD / PFCOUNT / PFMERGE | Set/Hash 精确但费内存 |
这张表覆盖了大多数日常场景,但实际工作里总会遇到组合场景。比如一段时间内的在线用户数,可以用 Sorted Set 把上线时间戳作为 score:ZADD online_users $timestamp $user_id,再用 ZCOUNT online_users $start_ts $end_ts 拿到区间内的人数。再比如布隆过滤器(缓存穿透防护),底层也是基于 String 的位运算扩展。
工程实践的几条经验
第一,聚合操作的成本意识。SINTERSTORE、SUNIONSTORE 这种命令的复杂度都不低,元素量大时会阻塞主线程。把统计实例与在线业务实例物理隔离,是高并发场景下的常规做法。
第二,集群模式下的多 key 命令。Redis Cluster 里多 key 命令要求所有 key 落在同一个 slot,否则直接报错。可以通过 hashtag(如 user:{date}:id)把相关 key 路由到同一节点。
第三,精确度与成本的权衡。HyperLogLog、Bitmap 这类近似/紧凑结构,本质都是用一定代价(误差或限定值域)换内存。能容忍误差就用 HLL,能压缩成位状态就上 Bitmap,需要精确就回到 Set/Hash。
第四,实时统计 vs 离线统计。不是所有统计都该放 Redis。如果是 T+1 的运营报表,从 MySQL 从库甚至 ODS 层算就行,没必要给 Redis 加压。Redis 适合的是高并发实时场景下的去重、Top N、最新列表等。
集合类型的选型不是死记硬背命令,而是对应到底层的访问模式。读多写少还是写多读少、需不需要保序、要不要范围查询、能不能容忍误差,这几个维度想清楚,对应的数据结构也就跳出来了。

更多推荐


所有评论(0)