Skip to content

第十八章:排行榜、点赞与关注关系 ​

Q56:用 ZSet 实现排行榜,如何处理实时更新、排名查询和 Top N 获取? ​

答:

排行榜是 ZSet(Sorted Set)最典型、最合适的应用场景。ZSet 的成员带有 score,Redis 内部用「跳表 + 字典」维护,既能按 score 排序,又能 O(log N) 地按 member 查询分数和排名,天生适合排行榜。

一、为什么 ZSet 适合排行榜 ​

需求ZSet 能力对应命令
存储成员和分数member + scoreZADD
实时增减分数直接给某个成员加/减分数ZINCRBY
查询某成员排名按 score 快排得出名次ZRANK(正序)/ ZREVRANK(倒序)
获取 Top N按 score 范围取前 N 个ZREVRANGE / ZRANGE ... REV
获取成员分数直接查 scoreZSCORE
按分数区间筛选取某分数范围内的成员ZRANGEBYSCORE / ZREVRANGEBYSCORE
移除超出榜单的元素裁剪榜单,只保留 Top NZREMRANGEBYRANK

二、实时更新分数:ZINCRBY ​

视频热度、积分、播放量这类「只增不减」或「频繁变动」的分数,用 ZINCRBY 增量更新:

bash
# 播放量 +1
redis-cli ZINCRBY rank:video:hot 1 video:1001
# 用户积分 +10
redis-cli ZINCRBY rank:user:score 10 user:2001
python
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:1001
python
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 WITHSCORES
python
top10 = redis_client.zrevrange("rank:video:hot", 0, 9, withscores=True)

返回的是「member + score」的一组数据,member 通常是业务实体 id(如视频 id),展示时需要再根据 id 去查详情。

五、实时排行榜的常见优化 ​

  1. 榜单裁剪:如果只关心 Top N,定期用 ZREMRANGEBYRANK 把 N 名之外的成员删掉,避免 ZSet 无限膨胀:

    bash
    # 只保留热度最高的前 1000 名
    redis-cli ZREMRANGEBYRANK rank:video:hot 0 -1001

    注意 ZREMRANGEBYRANK 的「0 到 -1001」含义是按正序(分数从小到大)删除,0 到 -1001 对应「分数最小的那 1000 个」,即删掉垫底的 1000 个,保留高分。

  2. 分时段榜单:对需要「日榜、周榜、月榜」的场景,用不同 key 区分,如 rank:video:hot:daily:2026-08-29、rank:video:hot:weekly:2026-35,配合过期时间自动清理历史榜单。

  3. 分数衰减:热度类榜单如果希望「老视频热度慢慢降下来」,可以在分数上加时间衰减因子,或定期对所有分数做衰减处理。

六、小结 ​

  • ZSet 是排行榜首选:ZINCRBY 实时更新、ZREVRANK 查排名、ZREVRANGE 取 Top N;
  • 注意正序/倒序口径(ZRANK vs ZREVRANK);
  • 控制榜单规模用 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()

关键点:

  1. key 里带时间维度,天然隔离不同时段;
  2. 每个时段 key 设置合理的过期时间(日榜 3 天、周榜 15 天、月榜 90 天等),过期自动清理,避免 key 无限堆积;
  3. 用 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 SUM

ZUNIONSTORE 支持对分数做 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:follows
python
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:follows
python
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:差集,从二度关系到排除「我已经关注的」,剩下的就是推荐候选。

四、集合运算的成本与注意事项 ​

运算命令复杂度注意
交集SINTERO(N*M),N 是最小集合大小,M 是集合数量集合很大时可考虑 SINTERCARD 只取数量
并集SUNIONO(N),N 是所有集合元素总数并集结果可能很大,注意内存
差集SDIFFO(N),N 是所有集合元素总数同上
判断是否关注SISMEMBERO(1)高频、轻量
关注/取关SADD/SREMO(1)天然幂等

要点:

  1. 大 V(粉丝百万级)的集合运算会非常昂贵,这类场景要避免实时做 SUNION/SINTER 全量运算,改为异步离线计算或抽样子集;
  2. 频繁判断「是否关注」用 SISMEMBER,O(1);
  3. 关注/取关用 SADD/SREM,天然去重和幂等;
  4. 数据量极大时,可配合 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/离线计算。