排行榜是游戏、直播、电商、社区等产品中非常常见的基础能力,从战力榜、打赏榜到销量榜,都需要频繁写入积分并快速查询名次。Redis 提供的 Sorted Set 类型天然适合解决这个问题,它把成员和分值绑定在一起,内部通过跳表维护分值顺序,写入和范围查询都具备稳定的对数级复杂度。实时更新的关键在于不整体重算榜单,而是只更新发生变化的用户分数,再根据有序集合返回最新排名。本文围绕数据结构、更新策略、同分处理和大规模分页展开具体方案。

一、Sorted Set 如何承载排行榜数据
Redis 的 Sorted Set 与普通 Set 最大的区别在于每个成员都携带一个双精度浮点分值,Redis 会按分值从小到大排序。底层结构由哈希表和跳表共同组成:哈希表用于根据成员名快速定位分值,跳表用于按分值顺序访问。假设有100万用户,使用 ZADD 写入一条积分记录,时间复杂度为 O(log N),使用 ZREVRANGE 查询前10名的时间复杂度为 O(log N + 10)。这意味着即使榜单规模很大,单次更新和查询也不会明显变慢。
实时排行榜通常把用户 ID 作为 member,把积分作为 score。初始化榜单时可以使用 ZADD rank:total 100 user:1001,也可以直接使用 ZINCRBY 让不存在的成员从0开始累加。查询前十名使用 ZREVRANGE rank:total 0 9 WITHSCORES,查询某个用户的当前排名使用 ZREVRANK,查询某个用户分数使用 ZSCORE。下面是一个最小化的命令序列示例:
ZADD rank:total 100 user:1001 ZADD rank:total 98 user:1002 ZINCRBY rank:total 15 user:1001 ZREVRANGE rank:total 0 9 WITHSCORES ZREVRANK rank:total user:1001
从上面的命令可以看出,积分变化只需要一次 ZINCRBY,Redis 会自动维护成员在跳表中的位置。与数据库方案相比,不需要执行 UPDATE 后再用 ORDER BY 重新排序,也不需要额外维护一套缓存。正因为写操作本身就是排序结构的更新,所以排行榜可以被看作实时一致的结果,而不是异步计算后的快照。
二、实时更新与并发安全
排行榜实时更新的核心流程是:业务动作触发积分变化,调用 Redis 的 ZINCRBY 原子地累加分值,然后再按需要读取最新排名或榜单切片。由于 Redis 单线程处理命令,每个 ZINCRBY 都不会被其他命令打断,所以不会出现两个并发请求同时读取旧值再加回去导致丢失更新的问题。单个 ZINCRBY 足够安全,但如果业务需要在更新分数的同时判断是否超过上限、是否触发降级或记录历史,就需要用到 Lua 脚本或多条命令。
对于需要同时更新分数并返回最新名次的场景,可以用 Lua 脚本将 ZINCRBY 和 ZREVRANK 打包执行。Redis 保证 Lua 脚本执行期间其他命令不会插入,因此整个过程具备原子性。下面展示一个简单脚本:
local key = KEYS[1]
local member = ARGV[1]
local delta = tonumber(ARGV[2])
local newScore = redis.call('ZINCRBY', key, delta, member)
local rank = redis.call('ZREVRANK', key, member)
return {newScore, rank}
如果只关心分数增加,不关心更新后的排名,直接调用 ZINCRBY 性能最好。如果一次业务事件会更新多个榜单,例如总榜、日榜、月榜同时加分,可以使用 Pipeline 把多条命令批量发送,减少网络往返。Pipeline 不要求事务,但命令按顺序到达 Redis,仍然比逐条发送高效得多。必要时也可以使用 MULTI/EXEC 保证多个榜单一起成功或失败。
对于每日榜这类周期性数据,通常会为每一天创建不同的 key,例如 rank:daily:20240101,更新时写入当天 key,并设置 EXPIRE 让过期数据自动删除。切换日榜时业务代码根据日期生成新 key,无需手动清空旧榜。总榜则可以一直保留,但需要根据业务规则决定是否在月初或赛季初重置。
三、同分排序与多榜单拆分
默认情况下,Redis 有序集合在分数相同时会按照 member 的字典序升序排列。这通常不是业务期望的结果,例如两个用户都是100分,先达到100分的用户应该排得更靠前。如果只用积分作为 score,同分者的名次会因为用户 ID 的字典序而固定,无法体现时间先后。解决思路是把排序依据编码到 score 中,让 score 同时包含积分和时间信息。
常见做法是使用复合分:score = 积分 × 10¹³ +(一个足够大的时间基准 - 更新时间戳)。例如积分100,时间戳相差123456789,则 score 为 1000000000000 + 某个偏移量。积分越高数值越大,时间差越小数值越大,这样 Redis 的降序排列会优先比较积分,再比较时间。需要根据业务积分上限预留十进制位数,避免时间部分进位影响整数部分。下面的 Python 伪代码展示了构造过程:
BASE = 10_000_000_000
MAX_TIMESTAMP = 9_999_999_999
def build_score(points, update_time):
return points * BASE + (MAX_TIMESTAMP - update_time)
redis.zadd('rank:daily', {member_id: build_score(100, 1700000000)})
如果积分可能为小数,复合分需要更谨慎,因为小数会占用精度。也可以不用复合分,改在业务层返回分页后对同分用户按额外字段排序,但这只适合小范围分页,无法影响 Redis 底层完整排名。另一类方案是使用有序集合加关联哈希表,通过哈希表存储更新时间,查询前 N 名后再由业务层做二次排序,这适合对实时性要求稍低但同分规则复杂的场景。
当业务需要同时维护总榜、月榜、周榜和日榜时,不应当试图用一个 key 保存所有维度。通常每种榜单独立一个有序集合,积分更新时通过 Pipeline 同时写入多个 key。如果分区很多,例如按服务器、地区或赛道拆分,可以为每个分区生成独立 key,并在需要跨分区汇总时把多个分区的前几百名加载到业务层做归并排序。这样既避免单个 key 过大,也便于对不同分区执行不同权重策略。
四、分页查询与性能优化
ZREVRANGE 和 ZREVRANGEBYSCORE 是排行榜读取最常用的两个命令。查询第1页前10名非常高效,但如果用户翻到第1000页,Redis 仍需要从跳表头部遍历到 offset 位置,offset 越深耗时越高。在大规模榜单中,深分页既消耗 Redis 也消耗网络带宽,因此实际业务应当限制最大可访问页数,或通过分数区间查询来避免大 offset。
更好的策略是缓存前 N 名结果。例如只允许用户查看前100名,可以在业务服务中用一个本地缓存或独立 Redis key 保存前100名的快照,由定时任务每隔几秒或每当有大量更新时刷新。具体做法是使用 ZREVRANGE rank:total 0 99 WITHSCORES 拉取结果,序列化后写入 rank:total:top100,前端直接读取这个快照。这样高频访问不会反复扫描跳表,实时性损失通常只有几百毫秒到几秒,对大多数场景可以接受。
ZREVRANGE rank:total 0 99 WITHSCORES SET rank:total:top100 serialized_top100 EXPIRE rank:total:top100 5
上面的命令中 SET 的值是示意,实际一般由业务代码完成序列化。对于需要查看任意用户附近排名的场景,可以使用 ZREVRANK 获取该用户名次,再以该名次为中心向前后各取若干条,而不是从第1名开始翻页。这样无论用户排在什么位置,读取成本都只与窗口大小有关。
内存方面,Sorted Set 比 Hash 占用更多空间,因为需要维护跳表指针。成员数量达到百万级时,分值和成员本身的编码方式会直接影响内存。Redis 会根据配置使用压缩列表或紧凑编码,但成员名过长时建议做 ID 映射。例如把较长字符串用户 ID 映射为数字 ID 存储,再用 Hash 保存映射关系。这样既能降低内存,也能减少比较成本,提升跳表操作性能。
综合来看,Redis 排行榜实时更新方案的关键在于利用 Sorted Set 的原子更新和有序范围查询能力,并针对同分、多榜单、深分页等细节做好设计。只要合理拆分 key、控制分页深度并缓存热点榜单,就能在保持实时性的同时获得很高的读写吞吐。