实时榜单几乎是所有内容平台和游戏的标配功能。用户送礼、文章点赞、游戏击杀都会触发分数变化,如果每次都写入关系库再排序,高峰期的读放大和写入压力会迅速拖垮接口。Redis 提供的有序集合(Sorted Set,常简称 ZSet)正是为解决这类有序存储需求设计的。它把成员和分数绑定在一起,内部维护着一个按分数排序的索引,既能快速更新某个成员的分数,也能以极低延迟取出排名前 N 的成员列表。

相比用数据库的 ORDER BY 加 LIMIT 实现榜单,Sorted Set 的优势在于排序发生在写入阶段,查询阶段直接读取已经排好的结果。写入一个成员的时间复杂度为 O(log N),查询 TopN 的时间复杂度为 O(log N + M),其中 N 是集合大小,M 是返回条数。这个特性让它在几十万甚至百万成员规模下仍然可以保持毫秒级响应。
一、Sorted Set 的底层结构与核心命令
Sorted Set 在 Redis 内部并不是简单的一层有序数组,而是由跳表和哈希表共同维护。哈希表负责通过成员名快速定位节点,跳表负责按分数和成员字典序维护顺序。这种组合让单点更新和区间查询都具备对数级复杂度,避免了关系库中常见的全表排序操作。
排行榜开发中最常用的几个命令:ZADD 用于新增或覆盖成员分数;ZINCRBY 用于对已有成员的分数做原子增量;ZREVRANGE 按分数从高到低返回指定区间的成员;ZREVRANK 返回成员在降序榜单中的排名;ZSCORE 获取成员当前分数。下面是一组可直接在 redis-cli 中执行的命令示例。
# 初始化或覆盖用户分数 ZADD rank:total 100 user:1001 # 用户分数增加 5 ZINCRBY rank:total 5 user:1001 # 获取降序榜单前 10 名,并返回分数 ZREVRANGE rank:total 0 9 WITHSCORES # 查看某用户的排名(从 0 开始) ZREVRANK rank:total user:1001 # 查看某用户的分数 ZSCORE rank:total user:1001
ZADD 和 ZINCRBY 的写入操作都是原子的,Redis 单线程执行命令,天然避免了并发场景下分数被覆盖或多次累加导致的不一致问题。对于需要实时加减分的业务,比如直播送礼、点赞取消、道具消耗,直接使用 ZINCRBY 传入正数或负数即可完成更新,不必先把旧分数读出来再写回去。
二、存储设计:Key 规划与同分处理
排行榜的 Key 通常需要按业务维度和时间粒度拆分。例如总榜使用 rank:total,日榜使用 rank:daily:20250130,周榜使用 rank:week:202505。成员名建议使用稳定且可反向解析的 ID,比如 user:1001 或 article:8890,避免使用可能重复的昵称或者不可控的字符串。分数统一使用 double 类型,在业务层需要保证分数数值的大小不要超出 double 精度范围。
同分处理是排行榜实现中最容易踩坑的地方。Redis 在分数相同时,会按照成员名的字典序进行升序排列,这可能不符合业务上先达到该分数的成员排在前面的需求。解决思路是在原始分数上叠加一个时间分量,让先达到的成员拥有更高的小数部分。比如用毫秒时间戳构造复合分数:
import time
MAX_TS = 9999999999999
points = 100
ts = int(time.time() * 1000)
score = points * (10 ** 13) + (MAX_TS - ts)
print(score)
# 写入 Redis
# r.zadd('rank:total', {'user:1001': score})
上面的做法中,MAX_TS - ts 会让更早的时间戳对应更大的小数增量,从而在同分时排得更靠前。不过要注意 double 类型有效位数约为 15 到 17 位十进制数字,如果原始分数本身就很大,或者时间戳位数过多,小数部分可能被截断。更稳妥的方案是控制业务分数位数不超过 7 位,或者改用额外有序集合加哈希表存储达成时间,在查询时由应用层做二次排序,但后者只适合 TopN 较小的场景。
三、增量更新与分段榜单实践
ZINCRBY 是增量更新最核心的命令。在直播礼物榜中,用户每送出一份礼物,就执行一次 ZINCRBY rank:live:gift 礼物价值 user_id,命令会原子地累加分数并调整排序位置。同样的操作也适用于文章点赞、积分变动等场景。对于需要周期性重置的榜单,可以维护多个按时间粒度的 Key,并通过定时任务将子榜单合并到父榜单。
合并子榜单通常使用 ZUNIONSTORE。例如把 7 个日榜合并成周榜,默认对相同成员的分数求和,也可以指定 AGGREGATE MAX 取最大值。命令示例如下:
ZUNIONSTORE rank:week:202505 7 rank:day:20250527 rank:day:20250528 rank:day:20250529 rank:day:20250530 rank:day:20250531 rank:day:20250601 rank:day:20250602
需要注意的是,ZUNIONSTORE 在集合较大时会阻塞 Redis 主线程,如果日榜成员数量达到百万级,合并操作可能耗时数百毫秒甚至更久。此时可以把合并任务放到离线服务中,通过 ZRANGE 分批读取并根据业务规则逐条 ZADD 到总榜,或者使用管道批量写入,减轻单次命令阻塞时间。另外,对历史榜单 Key 设置合理的过期时间,可以自动清理不再使用的数据。
当单个排行榜规模过大时,还可以按用户 ID 进行哈希分片,例如 rank:shard:0 到 rank:shard:99。写入时根据用户 ID 取模决定写入哪个分片,查询总榜时并发拉取每个分片的前 100 名,再由应用层归并。这种方式能把单个大 Key 拆成多个小 Key,降低内存碎片和操作延迟,适合千万级用户以上的场景。
四、排名查询与缓存策略
获取用户排名和分数时,可以组合使用 ZREVRANK 和 ZSCORE。如果需要在一次请求中同时返回多个信息,建议使用 Pipeline 将多条命令打包发送,减少网络往返。拉取 TopN 榜单时用 ZREVRANGE rank:total 0 99 WITHSCORES 一次性取回前 100 名,不要在应用层循环调用 ZSCORE,否则会产生大量网络开销。
# 获取前 100 名和对应分数 ZREVRANGE rank:total 0 99 WITHSCORES # 获取单个用户排名 ZREVRANK rank:total user:1001 # 获取单个用户分数 ZSCORE rank:total user:1001
对排行榜页面的读请求做缓存时,通常会遇到一个矛盾:排行榜数据更新频繁,直接缓存会导致数据延迟;不缓存又会让 Redis 承受过高的读压力。实际项目中比较常见的做法是将 TopN 结果缓存到本地内存或 Redis 的 String Key 中,设置 1 到 5 秒的过期时间。用户看到的排名允许短暂延迟,对体验影响很小,却能挡住绝大多数重复请求。
缓存一致性方面,不建议在每次写操作后主动删除缓存,因为高频更新会导致缓存被频繁击穿。更合适的策略是依赖短过期时间自然失效。如果业务对排名实时性要求较高,可以缩短过期时间到 500 毫秒,或者对第 100 名附近的排名变动接受一定误差。对于需要通过分数区间查询的场景,可以使用 ZREVRANGEBYSCORE,但要注意分页时区间重叠和数据变动可能造成重复或遗漏。通常排行榜页面不会提供深分页,展示前 50 或前 100 名已能满足绝大多数需求。
综合来看,Redis Sorted Set 是构建实时排行榜最合适的原语之一。设计时需要重点考虑 Key 的粒度划分、同分位置的业务规则、增量更新的原子性以及缓存失效策略。把这些细节处理好,就能用很小的成本支撑起高并发、低延迟的榜单服务。
Redis Sorted Set排行榜有序集合修改时间:2026-09-22 04:55:53