排行榜
Category
system / 玩法系统。节点定位:把「榜单」按写入频率、查询扇出与恢复要求分档,对四种实现(Redis ZSET / Lua 封装 / 纯内存 / SQL 宽表)算同一笔账。口径:数字与复杂度均可复算,不给拍脑袋结论。
Definition
排行榜的本质是一个带三类查询的有序集合:写入/更新单个玩家分数、取 Top-N、查任意玩家名次。三种查询的频率与规模决定了实现选哪条路。
Problem
榜单的坑大多来自按功能选型而不是按负载选型。写入频率、查询扇出、恢复要求是三笔独立的账:高频更新走 SQL 热路径会被索引抖动拖垮;低频结算型榜单却为它单独立一套 Redis,是成本错配;内存榜不演练快照与恢复,重启就是事故。先算清三笔账,再谈实现。
Algorithm
经典模式:Redis ZSET
Redis 的有序集合(ZSET)是排行榜的默认答案:跳表 + 哈希双结构,一条命令同时解决存分与排序。
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),把「校验 + 写入 + 清理」拼进一个脚本就拿到了原子性。三类典型封装:
-- 报名次脚本:防刷校验、时间窗清理、写分一步完成
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 / 宽表方案
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 ZSET | ZSET + 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 讲结算权威口径在排行榜上的直接应用
Related
关系链:玩法需求(榜单)→ 负载分档(写入/查询/恢复)→ 实现选型 → 服务与引擎落地 → 热冷组合与对账。逐段回答「为什么需要下一个」:
- 分档之后要选实现:三类负载的档位直接排除不适配的方案(高频更新排除 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(热冷分层)