主题
第十八章:排行榜、点赞与关注关系
Q56:用 ZSet 实现排行榜,如何处理实时更新、排名查询和 Top N 获取?
答:
排行榜是 ZSet(Sorted Set)最典型、最合适的应用场景。ZSet 的成员带有 score,Redis 内部用「跳表 + 字典」维护,既能按 score 排序,又能 O(log N) 地按 member 查询分数和排名,天生适合排行榜。
一、为什么 ZSet 适合排行榜
| 需求 | ZSet 能力 | 对应命令 |
|---|---|---|
| 存储成员和分数 | member + score | ZADD |
| 实时增减分数 | 直接给某个成员加/减分数 | ZINCRBY |
| 查询某成员排名 | 按 score 快排得出名次 | ZRANK(正序)/ ZREVRANK(倒序) |
| 获取 Top N | 按 score 范围取前 N 个 | ZREVRANGE / ZRANGE ... REV |
| 获取成员分数 | 直接查 score | ZSCORE |
| 按分数区间筛选 | 取某分数范围内的成员 | ZRANGEBYSCORE / ZREVRANGEBYSCORE |
| 移除超出榜单的元素 | 裁剪榜单,只保留 Top N | ZREMRANGEBYRANK |
二、实时更新分数:ZINCRBY
视频热度、积分、播放量这类「只增不减」或「频繁变动」的分数,用 ZINCRBY 增量更新:
bash
# 播放量 +1
redis-cli ZINCRBY rank:video:hot 1 video:1001
# 用户积分 +10
redis-cli ZINCRBY rank:user:score 10 user:2001python
def add_score(board_key: str, member: str, delta):
redis_client.zincrby(board_key, delta, member)ZINCRBY 天然处理了「成员不存在则新建、存在则累加」的逻辑,无需先读再加,原子且高效。
三、查询排名与分数
bash
# video:1001 的倒序排名(第 1 名返回 0)
redis-cli ZREVRANK rank:video:hot video:1001
# video:1001 的分数
redis-cli ZSCORE rank:video:hot video:1001python
rank = redis_client.zrevrank("rank:video:hot", "video:1001") # 排名(从 0 开始)
score = redis_client.zscore("rank:video:hot", "video:1001") # 分数注意排名的两种口径:
ZRANK:按 score 从小到大排名(第 1 名 score 最小);ZREVRANK:按 score 从大到小排名(第 1 名 score 最大)。
排行榜通常用 ZREVRANK(分数高的排前面);如果第 1 名返回 0,业务展示时需要 +1。
四、获取 Top N
bash
# 热度 Top 10(带分数)
redis-cli ZREVRANGE rank:video:hot 0 9 WITHSCORES
# 第 11 到 20 名
redis-cli ZREVRANGE rank:video:hot 10 19 WITHSCORESpython
top10 = redis_client.zrevrange("rank:video:hot", 0, 9, withscores=True)返回的是「member + score」的一组数据,member 通常是业务实体 id(如视频 id),展示时需要再根据 id 去查详情。
五、实时排行榜的常见优化
榜单裁剪:如果只关心 Top N,定期用
ZREMRANGEBYRANK把 N 名之外的成员删掉,避免 ZSet 无限膨胀:bash# 只保留热度最高的前 1000 名 redis-cli ZREMRANGEBYRANK rank:video:hot 0 -1001注意
ZREMRANGEBYRANK的「0 到 -1001」含义是按正序(分数从小到大)删除,0到-1001对应「分数最小的那 1000 个」,即删掉垫底的 1000 个,保留高分。分时段榜单:对需要「日榜、周榜、月榜」的场景,用不同 key 区分,如
rank:video:hot:daily:2026-08-29、rank:video:hot:weekly:2026-35,配合过期时间自动清理历史榜单。分数衰减:热度类榜单如果希望「老视频热度慢慢降下来」,可以在分数上加时间衰减因子,或定期对所有分数做衰减处理。
六、小结
- ZSet 是排行榜首选:
ZINCRBY实时更新、ZREVRANK查排名、ZREVRANGE取 Top N; - 注意正序/倒序口径(
ZRANKvsZREVRANK); - 控制榜单规模用
ZREMRANGEBYRANK裁剪,多时段榜单用不同 key + TTL。
Q57:排行榜除了全量 Top N,还有哪些进阶形态?如何实现日/周/月榜?
答:
实际业务里,排行榜很少只是「全量 Top N」这么简单,常见进阶形态有:分段榜单、多维度榜单、实时合并榜单、分时榜单。这里重点讲分时榜单和实时榜单的实现。
一、分时榜单(日榜 / 周榜 / 月榜)
核心思想:用不同 key 区分不同时间段,每个榜单独立累计,落到对应时段的 key 里。
text
日榜:rank:video:hot:day:2026-08-29
周榜:rank:video:hot:week:2026-W35
月榜:rank:video:hot:month:2026-08
总榜:rank:video:hot:all写入时要同时更新多个榜单:
python
import datetime
def record_hit(video_id: str):
today = datetime.date.today()
week = today.isocalendar() # 返回 (year, week, weekday)
month = today.strftime("%Y-%m")
day_key = f"rank:video:hot:day:{today}"
week_key = f"rank:video:hot:week:{week[0]}-W{week[1]}"
month_key = f"rank:video:hot:month:{month}"
all_key = "rank:video:hot:all"
pipe = redis_client.pipeline()
for key in (day_key, week_key, month_key, all_key):
pipe.zincrby(key, 1, video_id)
pipe.expire(key, 90 * 24 * 3600) # 榜单设置过期时间,自动清理
pipe.execute()关键点:
- key 里带时间维度,天然隔离不同时段;
- 每个时段 key 设置合理的过期时间(日榜 3 天、周榜 15 天、月榜 90 天等),过期自动清理,避免 key 无限堆积;
- 用 pipeline 一次性写入多个榜单,减少网络往返。
二、实时总榜(ZUnionStore 合并)
如果需要「实时把多个子榜合并成总榜」,比如把各子业务的榜单合并成全局榜,可以用 ZUNIONSTORE(并集)对多个 ZSet 的分数做聚合:
bash
# 把 day1、day2、day3 三个榜单合并成 total 榜,分数相加(SUM)
redis-cli ZUNIONSTORE rank:video:hot:total 3 rank:video:hot:day1 rank:video:hot:day2 rank:video:hot:day3 AGGREGATE SUMZUNIONSTORE 支持对分数做 SUM、MIN、MAX 聚合,可以把多个榜单合并成一个。注意它是「另存为新 key」,且数据量大时是 O(N) 操作,需谨慎控制频率和规模。
三、带时间衰减的热榜
对「热度」类榜单,如果希望历史得分随时间衰减,可以在更新分数时引入衰减,或定期对榜单分数做衰减:
text
原始热度分 = 基础分 + 时间加权
时间越久,加权越低,老内容自然下沉简化实现可以用分时榜单 + 滑动窗口:只统计最近 N 天的分数作为「当前热度」,旧时段的榜单过期后自然不再计入,实现「热度随时间衰减」。
四、分布式 / 高并发下的实时榜单
高并发场景下,实时榜单会面临「写入压力大」「榜单规模大」两个问题:
| 问题 | 应对手段 |
|---|---|
| 写入 QPS 高 | 先在本地/异步聚合,再批量 ZINCRBY 落榜 |
| 榜单成员过多 | ZREMRANGEBYRANK 裁剪,只留 Top N |
| 读多写少 / 热点 | 加本地缓存或只读副本,榜单结果允许短时间延迟 |
| 多维榜单 | 每个维度独立 key,或按需合并(ZUNIONSTORE) |
五、小结
- 日/周/月榜靠不同 key 区分时段 + 设过期时间;
- 实时合并榜单用
ZUNIONSTORE(注意 O(N) 成本); - 热度衰减用分时榜单 + 滑动窗口或分数衰减;
- 高并发下用异步聚合 + 榜单裁剪 + 本地缓存。
Q58:共同关注、共同好友、可能认识的人,这些「关系类」需求用什么结构实现?
答:
关注、粉丝、好友这类「关系」是典型的集合关系运算场景,Redis 的 Set 及其交并差运算(SINTER / SUNION / SDIFF)正好适配。
一、用 Set 存储关系
每个人维护两个集合:我关注了谁、谁关注了我(粉丝)。
python
def follow(user_id: str, target_id: str):
redis_client.sadd(f"user:{user_id}:follows", target_id) # 我关注了 target
redis_client.sadd(f"user:{target_id}:fans", user_id) # target 的粉丝里加我
def unfollow(user_id: str, target_id: str):
redis_client.srem(f"user:{user_id}:follows", target_id)
redis_client.srem(f"user:{target_id}:fans", user_id)user:{id}:follows:这个人关注的人集合;user:{id}:fans:这个人的粉丝集合。
二、共同关注 / 共同好友:SINTER 交集
bash
# 我和 user:2002 的共同关注
redis-cli SINTER user:1001:follows user:2002:follows
# 共同好友数
redis-cli SINTERCARD 2 user:1001:follows user:2002:followspython
def common_follows(a: str, b: str):
return redis_client.sinter(f"user:{a}:follows", f"user:{b}:follows")SINTER 返回多个集合的交集,即「A 和 B 都关注的人 / 共同好友」。SINTERCARD(Redis 7.0+)只返回交集数量,不返回具体成员,性能更好,适合「只显示共同好友数」的场景。
三、可能认识的人:差集 + 交集
「可能认识的人」通常可以定义为「我关注的人的关注的人,但我还没关注」——即二度关系。可以用集合运算拼出来:
text
可能认识的人 = (我关注的人各自关注的人 的并集)- (我已关注的人)- (我自己)bash
# 1. 我关注的人 的关注并集(二度关系)
# (先取我关注的人,再对每个人的 follows 做 SUNION)
# 2. 减去我已关注的人
redis-cli SDIFF <二度关系并集> user:1001:followspython
def suggest_friends(user_id: str):
# 1. 我关注的人
follows = redis_client.smembers(f"user:{user_id}:follows")
# 2. 这些人各自的关注集合做并集(二度关系)
second_degree = redis_client.sunion(*[f"user:{f}:follows" for f in follows])
# 3. 减去我已关注的人和自己
return redis_client.sdiff(second_degree, f"user:{user_id}:follows")其中:
SUNION:并集,把「我关注的人各自关注的人」合并;SDIFF:差集,从二度关系到排除「我已经关注的」,剩下的就是推荐候选。
四、集合运算的成本与注意事项
| 运算 | 命令 | 复杂度 | 注意 |
|---|---|---|---|
| 交集 | SINTER | O(N*M),N 是最小集合大小,M 是集合数量 | 集合很大时可考虑 SINTERCARD 只取数量 |
| 并集 | SUNION | O(N),N 是所有集合元素总数 | 并集结果可能很大,注意内存 |
| 差集 | SDIFF | O(N),N 是所有集合元素总数 | 同上 |
| 判断是否关注 | SISMEMBER | O(1) | 高频、轻量 |
| 关注/取关 | SADD/SREM | O(1) | 天然幂等 |
要点:
- 大 V(粉丝百万级)的集合运算会非常昂贵,这类场景要避免实时做
SUNION/SINTER全量运算,改为异步离线计算或抽样子集; - 频繁判断「是否关注」用
SISMEMBER,O(1); - 关注/取关用
SADD/SREM,天然去重和幂等; - 数据量极大时,可配合 Bitmap 存储关系(内存更省),但牺牲了「列出具体成员」的便利。
五、关系类场景选型小结
| 需求 | 结构/命令 |
|---|---|
| 关注 / 粉丝列表 | Set:SADD / SREM |
| 判断是否关注 | Set:SISMEMBER |
| 共同关注 / 共同好友 | Set:SINTER / SINTERCARD |
| 我关注的人是否关注某人 | Set:SISMEMBER + 逻辑判断 |
| 可能认识的人(二度关系) | Set:SUNION + SDIFF |
| 关注数 / 粉丝数 | Set:SCARD(或 String INCR 缓存计数) |
六、小结
- 关注关系用 Set 存储(follows / fans 两个集合);
- 共同关注/好友用
SINTER交集; - 可能认识的人用
SUNION(二度关系并集)+SDIFF(排除已关注); - 大集合运算成本高,大数据量需异步化或改 Bitmap/离线计算。