Skip to content

排行榜 ​

Category ​

system / 玩法系统。节点定位:把「榜单」按写入频率、查询扇出与恢复要求分档,对四种实现(Redis ZSET / Lua 封装 / 纯内存 / SQL 宽表)算同一笔账。口径:数字与复杂度均可复算,不给拍脑袋结论。

Definition ​

排行榜的本质是一个带三类查询的有序集合:写入/更新单个玩家分数、取 Top-N、查任意玩家名次。三种查询的频率与规模决定了实现选哪条路。

Problem ​

榜单的坑大多来自按功能选型而不是按负载选型。写入频率、查询扇出、恢复要求是三笔独立的账:高频更新走 SQL 热路径会被索引抖动拖垮;低频结算型榜单却为它单独立一套 Redis,是成本错配;内存榜不演练快照与恢复,重启就是事故。先算清三笔账,再谈实现。

Algorithm ​

经典模式:Redis ZSET ​

Redis 的有序集合(ZSET)是排行榜的默认答案:跳表 + 哈希双结构,一条命令同时解决存分与排序。

text
ZADD board:week 1520 u1001          # 写分:O(log N)
ZREVRANGE board:week 0 19 WITHSCORES  # Top20:O(log N + 20)
ZREVRANK board:week u1001            # 名次:O(log N)

复杂度是 Redis 文档的标称值(redis.io/commands/zadd、zrevrange、zrevrank),N 为榜内人数。要点:

  • 分数即排序键:时间窗榜把时间戳拼进 score(高 48 位放业务分、低 16 位放时间序,同一分数先到者靠前),一次 ZREVRANGE 就是「分数高者优先、同分早到者优先」
  • 持久化与恢复:ZSET 随 RDB 快照或 AOF 落盘,重启回放即恢复;AOF everysec 口径下最多丢 1 秒写入,榜单可接受
  • 多榜:每榜一个 key,天然隔离;全服榜/好友榜/公会榜按维度拆 key,避免单 key 无限膨胀

ZSET 的软肋在更新与校验不是原子的:先 ZSCORE 判作弊、再 ZADD 写入,两条命令之间可被并发穿插。这就是变体登场的原因。

变体:Lua 脚本封装高级版 ​

Redis 单脚本执行期间不插入其他命令(redis.io/developers/learn-data-structure/lua-redis),把「校验 + 写入 + 清理」拼进一个脚本就拿到了原子性。三类典型封装:

lua
-- 报名次脚本:防刷校验、时间窗清理、写分一步完成
local now = tonumber(ARGV[1])
local inc = tonumber(ARGV[2])
-- 时间窗:只保留窗口内成员(keys[2] 是当日榜)
redis.call('ZREMRANGEBYSCORE', KEYS[2], 0, now - 86400)
local cur = redis.call('ZSCORE', KEYS[1], ARGV[3])
if cur and inc > 1000 then      -- 单次增量上限,防刷
  return 0
end
redis.call('ZINCRBY', KEYS[1], inc, ARGV[3])
return 1
  • 防刷:限频与单次增量上限在服务端判定(分数必须由服务端算出来,这是教程第 18 讲「结算权威」的排行版)
  • 时间窗:ZREMRANGEBYSCORE 按时间戳 score 裁掉过期成员,避免每日本地榜无界增长
  • 多榜聚合:一次脚本对多个 key 一起 ZINCRBY(活动分进三个子榜),管道化减少往返;N 个榜一次 Lua 调用是 O(k·log N) 而非 k 次网络往返
  • 代价:脚本错误会阻塞所有命令、调试不如普通命令直观;脚本内不做慢操作(扫全量是大忌)

纯内存实现:跳表、堆、分段桶 ​

不引 Redis、或对延迟/成本有极端要求时,服务端进程内自建。三种结构的账如下:

结构写入Top-N任意名次内存特征
跳表(skiplist)O(log N)O(log N + N 取前 N)(维护双端链可 O(N) 取前 N)O(log N)每节点带随机层高指针,期望层高 1/(1-p),p 常取 0.25
堆(heap)O(log K)(只保 K 个)O(K)(建堆后直接弹出)不支持(需另配哈希查分)O(K),只保前 K 时省内存
分段桶(bucket)均摊 O(1)(分数单调涨时只挪桶)O(桶数 + 桶内)O(1)(桶序累计)桶数固定,精度受桶粒度限制
  • 跳表是 ZSET 的同构自建版,全量名次与任意查询都要就用它;注意并发——单写者(榜单 actor 串行处理,见文末引擎结合点)比加锁省心得多
  • 堆只回答「前 100 名是谁」,改分时先与堆顶比较、超过才动堆,均摊 O(log K);不能回答「我排第几」,要配一份全量分数表
  • 分段桶适合分数连续、单调增长的活跃榜(活跃度按自然日归一):每桶存一个有序小链,更新只在相邻桶间搬移,读时从高桶往下扫——精度换 O(1) 写入,是「先到先得型排名」的高效近似

三者共同的账:持久化要自己补(每 N 秒快照到存储层 + 追加日志),进程重启的恢复路径必须演练;这是用内存换延迟的真实代价。

SQL / 宽表方案 ​

sql
CREATE TABLE leaderboard (
  server_id INT NOT NULL,
  season_id INT NOT NULL,
  player_id BIGINT NOT NULL,
  score INT NOT NULL,
  updated_at DATETIME NOT NULL,
  PRIMARY KEY (season_id, player_id),
  KEY rank_idx (season_id, server_id, score DESC)
);
  • 写入:INSERT ... ON DUPLICATE KEY UPDATE 主键点写 O(log N),每次写同时维护二级索引——分数频繁变动就是频繁的索引抖动与 binlog 放大
  • Top-N:ORDER BY score DESC LIMIT 20 走 rank_idx,O(log N + 20);任意名次是 SQL 的软肋——COUNT(*) WHERE score > mine 是 O(名次),千万级靠分位数缓存或异步回填名次列
  • 持久化与恢复:天然全项满分——落库即持久,回档对账、离线分析(与教程第 12 讲数据分析直接同源)都是顺手的
  • 千万级做法:分区表按赛季切、读写分离(写主库读从库)、榜内分页游标化;单表千万行可行,但高频 UPDATE 的写入模型不适合做热路径

对比表 ​

维度Redis ZSETZSET + Lua 封装纯内存自建SQL 宽表
写入复杂度O(log N)O(log N),校验原子O(log N) ~ 均摊 O(1)O(log N) + 索引维护
Top-N 查询O(log N + K)同左同结构而定O(log N + K)(走索引)
任意名次O(log N)O(log N)跳表 O(log N)/桶近似O(名次) 或缓存回填
持久化RDB/AOF,秒级同左自建快照 + 日志原生
重启恢复回放即恢复同左需演练恢复路径即恢复
原子防刷不足(两条命令可穿插)脚本内原子完成单写者串行即原子事务/单语句原子
时间窗/多榜key 拆分 + score 裁剪脚本内一次完成结构内自带字段 + 索引
千万级单实例可行(内存按百字节/条量级估算,1000 万条约 GB 量级,需实测)同左同左且自担成本可行,写热路径弱
实现成本低中中高低(但重查询设计)

取舍 ​

默认答案是 ZSET + Lua 封装:原子性、持久化、名次查询一次到位,前提是团队已经在用 Redis——为榜单单独立一套 Redis 不划算。纯内存自建只在三种情况下成立:延迟敏感到不能过网络、分数更新频率高到 Redis 成本不划算、需要分段桶这类定制精度策略;走这条路就必须补齐快照与恢复。选 SQL 的信号有三个:榜单要与离线分析/对账同源、写入是低频结算型而非高频更新型、名次查询接受缓存回填。

大榜的行业默认是热冷组合:Redis 当热读层、SQL/宽表当权威落盘,服务端算分→写热层→异步刷冷层。任何一层丢数据,都能从权威层对账回来。

适用规模 ​

万级以下的活跃榜,ZSET 单实例就够了,Lua 封装都可以简化成服务端判定。十万到千万级上 ZSET + Lua + 热冷组合,单 key 到千万级时按赛季/分区拆 key,压住单实例内存与快照窗口。亿级以上或跨服总榜,走分服分榜 + 总榜聚合(定时从各分区取 Top-K 合并)——一个 ZSET 装不下天下。

Used By ​

  • skynet:榜单适合做成独立服务——服务内单线程语义天然消灭并发写竞争(串行处理消息,见 Skynet 源码解析 第 6 节);Redis 访问用内置 lualib/skynet/db/redis.lua 客户端(同篇第 8 节);每日榜的时间窗裁剪挂 skynet.timeout 时间轮(同篇第 7 节)
  • Pitaya:榜单作为独立 service 注册 handler、经消息分发接入,落库走 storage 组件
  • KBEngine / BigWorld:实体属性高频变化、榜单属于跨实体聚合查询,惯例是不进引擎内核、以周边服务承接(KBEngine 的实体持久化走 dbmgr、BigWorld 的 cell/base 分工都把跨实体聚合划在对局进程之外)
  • 无论落哪家:分数必须服务端算,客户端上报的分数只能当触发信号——这是教程第 18 讲结算权威口径在排行榜上的直接应用

关系链:玩法需求(榜单)→ 负载分档(写入/查询/恢复)→ 实现选型 → 服务与引擎落地 → 热冷组合与对账。逐段回答「为什么需要下一个」:

  • 分档之后要选实现:三类负载的档位直接排除不适配的方案(高频更新排除 SQL 热路径、要任意名次排除纯堆)。
  • 选型之后要落服务:榜单的并发写竞争靠单写者服务形态解决,见 Skynet 源码解析。
  • 落地之后要对账:热层只做读加速,权威分在 SQL/宽表,任何一层丢数据都能从权威层对账回来。

同口径姊妹节点:队伍匹配、PVP 匹配(同为玩法系统,按负载分档再选型的同一套方法)。

Reference ​

  • Redis 命令复杂度与 Lua 脚本原子性以官方文档为准(redis.io/commands/zadd、zrevrange、zrevrank、redis.io/developers/learn-data-structure/lua-redis)
  • 本页复杂度为标称值,内存与延迟量级需按实际数据实测
  • 术语对照:Leaderboard(排行榜)、Season Ladder(赛季梯)、Hot/Cold Split(热冷分层)

游戏知识体系