排行榜难的不是排序,是分片之后名次还算不算数

排行榜是在持续写入中维护顺序,并对前 N 名、个人名次和附近的人提供低延迟查询的服务。数据量过千万、写入上到每秒数千次之后,排序本身反而不是难点,真正难的是同分怎么定序、分片后全局名次怎么算、重复消息会不会把分数加两遍。很多人以为用了 Redis 有序集合就万事大吉,却忽略它按成员字典序破同分、分数只有双精度浮点,以及它并不适合当唯一真相源。读完你能判断该不该分片、该选哪种分片,并把幂等、时间窗口和缓存边界一次定清楚。

是什么

排行榜(leaderboard)是把参与者按一个可比较的数值排序,并对外提供三类查询的服务:前 N 名、某个人的名次、以及“我附近的人”。一句话直觉:它不是在读的时候才排序,而是在写的时候就把顺序维护好,读只是切片。

小规模下用一个数组排序,或者数据库里 ORDER BY score DESC LIMIT 10 就够了。真正的问题从千万级用户、每秒数千次分数更新开始:每次查询都全量排序是 O(N log N),还要和写入抢资源;而“我的名次”要求知道有多少人排在我前面,靠缓存一条 top-N 列表答不了。

典型场景包括游戏天梯与赛季榜、营销活动的积分榜、内容热度榜、销售业绩榜。它们的共同点是写多读也多、名次要接近实时,而且榜单一变,用户立刻能感知。

接口通常只有四个:提交分数、取前 N 名、查我的名次、取我附近的人。难的不是接口数量,而是每个接口在数据规模和并发下的成本。

核心原理

先看 Redis 有序集合(ZSET)为什么几乎成了默认答案。逻辑上它是一张 member 到 score 的表,按分数和成员排序;实现上是哈希表加跳表:哈希表负责 O(1) 找到成员的分数,跳表按分数分层串联节点,每个节点记录跨度,因此“排第几”不用从头数,而是沿层累加跨度,代价 O(log N)。所以 ZADD、ZINCRBY、ZREVRANK 都是 O(log N),按名次取一段是 O(log N + M)。

类比:跳表像一本带多层目录的书,找第几页不用一页页翻,先跳大段再落小段;哈希表则像书末索引,直接定位某个词。两者各管一件事,所以既快查人,也快查名次。

import redis
r = redis.Redis(decode_responses=True)
KEY = "lb:daily:2026-11-07"
def add(uid, delta):
    r.zincrby(KEY, delta, uid)  # 原子自增,避免读改写丢更新
def top(n=10):
    rows = r.zrevrange(KEY, 0, n - 1, withscores=True)
    return [(i + 1, uid, int(s)) for i, (uid, s) in enumerate(rows)]

def my_rank(uid):
    rank = r.zrevrank(KEY, uid)  # 0-based
    return None if rank is None else rank + 1

def around(uid, k=2):
    rank = r.zrevrank(KEY, uid)
    if rank is None: return []
    start = max(0, rank - k)
    return r.zrevrange(KEY, start, rank + k, withscores=True)

这段代码只覆盖单机单榜。真正要讲清楚的是它没解决的三件事。

第一,同分怎么排。ZSET 在分数相同时按 member 的字节序排,倒序查询则是逆字典序,并不是“并列”或“先到先得”。如果产品要求先达成分数的人靠前,就得把时间编进分数,例如 composite = score * B + (B - 1 - elapsed_ticks),让时间只影响同分内部。但 Redis 的分数是 IEEE 754 双精度,整数只能精确到 2 的 53 次方,时间戳直接乘一个大系数很容易越界,出现“高一分反而排后面”的鬼故事。

第二,分片之后名次怎么算。单个 ZSET 很能装,但内存终有上限,写入也会先撞到单节点。按玩家 ID 哈希分片写入最均匀,代价是全局名次必须扇出:分数互不相同时,对每个分片数出严格高于我的人数,加上本片内的名次,就得到全局名次,代价是与分片数成正比的 O(log N) 调用;top-N 则要每片取 N 个再归并。按分数区间分片读得便宜,名次等于更高区间的总人数加本片名次,但高分区间会成为热点,边界迁移也麻烦。更隐蔽的是,跨分片的同分者无法靠各片局部序拼出一个全局一致的先后。

第三,谁是真相源。Redis 是内存热榜,RDB/AOF 的落盘与主从切换都有窗口,故障时可能丢掉最近一批加分。正确链路是事件日志或数据库先落地,消费者幂等更新 Redis,必要时从日志重放重建榜单。ZINCRBY 原子只保证这一次自增不会被并发读改写覆盖,不保证“这条消息只处理一次”。

读路径也要分层。前 N 名变化不频繁,可以每隔几秒把 ZREVRANGE 0 N-1 的结果写进缓存,接口直接读;个人名次必须实时,ZREVRANK 一次 O(log N) 就够;百分位则用 ZCARD 拿总人数再算。别让每个用户都拉全量榜单到前端排序,那等于把服务端的排序成本复制给客户端。名次和分数最好一次带 WITHSCORES 取回,分两次调用可能读到两个瞬间的状态。

常见考点与误区

  • 误区一:同分就并列,或者会按到达时间排。 实际按成员字节序,倒序查询下是逆字典序。面试追问“两个人同分谁第一”,答“Redis 会处理”就是失分,必须说清业务定序规则以及它怎么编码进分数。
  • 误区二:ZINCRBY 原子,所以并发就安全。 原子只覆盖单条命令。消息至少一次投递、客户端超时重试、多消费者重复消费,都会让同一笔加分执行两次。累加型榜单要用事件 ID 去重,而 ZADD GT 只适合“取历史最高分”,替代不了累加。
  • 误区三:分片后还能一次拿到精确全局名次。 哈希分片要扇出,成本随分片数增长;分数区间分片要维护边界与热点。面试里先问“名次要不要精确、能忍几秒延迟”,再决定扇出、分区榜还是近似名次。
  • 误区四:把 Redis 当账本。 热榜可以丢、可以从日志重建,账本不能。两者混在一起,故障恢复时既对不上账,也无法解释名次跳变。
  • 考点:数据库的 ORDER BY score DESC LIMIT 10 行不行? 小规模行。数据量大时,(score, id) 联合索引能加速 top-N,但“我的名次”仍要数出比我高的人数,深翻页也随偏移变慢。它适合当账本和离线重算,不适合承接高频实时名次查询。
  • 考点:为什么用跳表不用红黑树? 跳表实现简单、范围查询天然友好、靠随机层高期望平衡,节点存跨度后按名次定位很直接;红黑树做 rank 要额外维护子树大小,范围查询也更绕。
  • 考点:ZREVRANK 返回什么? 0 基下标,不是“第几名”;不存在的成员返回 nil,要和“最后一名”区分开。

实践建议

  • 先把名次语义写进契约:是并列名次(1、2、2、4)还是唯一名次;同分按时间还是按 ID;日榜按哪个时区切;迟到的事件算当天还是次日。这些问题不定,后面所有实现都会返工。
  • 单 ZSET 起步,用数据决定要不要分片:按成员数乘单成员内存估容量,按峰值写 QPS 估单节点余量;写入先在应用侧聚合一秒再批量 ZINCRBY,top-N 用一到五秒缓存,个人名次尽量实时。多数业务到不了必须分片的量级。
  • 要分片就优先哈希分片:写入均匀、扩容简单,把精确名次的扇出成本显式写进接口预算;如果产品能接受分区榜或“大约前百分之一”,就别为最后几位数字付全局扇出的钱。
  • 可靠性按“可重放”设计:事件带唯一 ID 与发生时间,消费端幂等;Redis 只存热数据,数据库或日志存全量;定期用日志重算并与线上榜单对账,名次异常跳变要能告警。
  • 防作弊放在计分侧:分数由服务端根据对局或订单计算,客户端只上报行为;超出物理上限的分数直接拒绝,别等上榜后再清洗。

一句话总结:排序是排行榜明面上的问题,同分定序、分片名次与幂等重放才是决定它可不可信的三笔暗账。

相关阅读

← 返回文章志