1. 从零拆解积分排行榜的核心需求
做游戏后端这些年,排行榜这个功能我至少重构过五六次。每次项目类型不同、规模不同,排行榜的实现方案就得跟着变。很多刚入行的朋友一接到“做个排行榜”的需求,第一反应就是数据库ORDER BY score DESC LIMIT 100,数据量小的时候确实没问题,但一旦玩家数量上到十万、百万级别,查询就会开始拖慢整个系统。这篇笔记就把我在实际项目里踩过的坑、试过的方案、最终沉淀下来的实现思路完整梳理一遍。
积分排行榜本质上要解决三个问题:存什么、怎么排、怎么查。存什么决定了数据结构,怎么排决定了更新策略,怎么查决定了读取性能。这三个问题互相牵制,你不能只盯着一个点优化。比如你用 Redis 的有序集合来做排名,写入和查询都快,但持久化和复杂统计就弱一些;你用关系型数据库,事务和复杂查询强,但高并发下排序开销大。所以选型之前,先搞清楚你的游戏类型和排行榜的业务特征。
具体来说,需要先回答几个问题。排行榜是全局榜还是分区榜?是实时更新还是定时刷新?积分会不会频繁变动?需不需要展示“我的排名”和“与前一名的差距”?榜单长度是固定 Top 100 还是全量可翻页?这些问题的答案直接决定了技术方案。我见过一个项目,策划要求排行榜每秒刷新,但积分变动频率其实很低,结果白白浪费了大量计算资源。所以第一步永远是跟需求方对齐业务场景,而不是急着写代码。
提示:在动手之前,先把排行榜的更新频率、数据规模、查询模式这三个指标量化出来,后面所有技术选型都围绕这三个数字做决策。
2. 主流实现方案对比与选型逻辑
2.1 关系型数据库直接排序
最朴素的方案,积分存在玩家表里,查询时直接SELECT * FROM player ORDER BY score DESC LIMIT 100。这种方案的优势是实现简单、数据一致性强、支持复杂条件筛选。但问题也很明显:当玩家表有百万行数据时,即使score字段建了索引,深度分页和频繁查询依然会带来压力。而且每次查询都要走一次排序,在高并发场景下数据库 CPU 会迅速飙升。
这个方案适合什么样的场景?我总结下来是:玩家总数在十万以内、排行榜不需要秒级实时、查询频率不高的休闲类游戏。比如一些单机向的轻量游戏,或者后台管理端的统计榜单。如果你的项目符合这些特征,用数据库完全够用,没必要过度设计。
2.2 Redis 有序集合方案
Redis 的 ZSET 是专门为排行榜场景设计的数据结构,底层用跳表加哈希表实现,插入、删除、按分数排名的时间复杂度都是 O(log N)。核心命令就几个:ZADD添加或更新积分,ZREVRANGE按分数从高到低取排名区间,ZREVRANK查某个玩家的排名,ZSCORE查某个玩家的分数。
这套方案的优势是性能极高,单机 Redis 支撑几十万玩家的实时排行榜毫无压力。但要注意几个细节:第一,ZSET 的 member 必须唯一,通常用玩家 ID;第二,积分相同时 Redis 会按 member 的字典序排列,如果你需要同分按时间先后排,就得在分数上做文章,比如把时间戳编码进分数里;第三,Redis 是内存数据库,要评估好内存占用,一个百万成员的 ZSET 大约占用几十到上百 MB,具体取决于 member 的长度。
2.3 分桶与分段排序方案
当玩家规模到了千万级别,单个 ZSET 也会变得很重。这时候可以考虑分桶:按积分区间把玩家分到不同的桶里,每个桶一个 ZSET。查询 Top 100 时,先从高分桶开始取,取够了就停止。这种方案的好处是每个 ZSET 规模可控,坏处是跨桶查询和排名计算会复杂很多。我一般只在极端规模下才推荐这个方案,中小型项目用不上。
2.4 方案选型对照表
| 方案 | 适合数据规模 | 实时性 | 实现复杂度 | 内存/存储开销 |
|---|---|---|---|---|
| 关系型数据库 | 十万以内 | 准实时 | 低 | 磁盘为主 |
| Redis ZSET | 百万以内 | 实时 | 中 | 内存为主 |
| 分桶 ZSET | 千万级 | 实时 | 高 | 内存为主 |
| 定时快照+缓存 | 任意规模 | 分钟级 | 中 | 混合 |
选型的核心原则是:不要为了技术而技术。我见过不少团队,玩家总共就几万人,非要上分桶方案,结果维护成本极高,收益却微乎其微。反过来,也有项目玩家上百万了还在用数据库直接排序,每次活动开启排行榜就卡死。所以选型一定要基于实际数据规模来做判断。
3. Redis 有序集合排行榜的完整实现
3.1 基础数据结构设计
先确定 key 的命名规范。我通常用rank:global:score表示全局积分榜,rank:season:202401:score表示某赛季榜单。member 用玩家 ID,score 用积分值。如果同分需要按时间排序,可以把 score 设计成一个复合值:整数部分存积分,小数部分存时间的倒数。比如积分 1000,是第 5 个到达的,可以编码成1000 + (1 - timestamp/maxTimestamp)这样的小数。不过这种编码方式精度有限,积分很大时要小心浮点误差。
import redis import time r = redis.Redis(host='localhost', port=6379, db=0) def update_score(player_id, score): r.zadd('rank:global:score', {player_id: score}) def get_top_n(n=100): return r.zrevrange('rank:global:score', 0, n-1, withscores=True) def get_player_rank(player_id): rank = r.zrevrank('rank:global:score', player_id) return rank + 1 if rank is not None else None上面是最基础的三个操作。实际项目中还要考虑积分更新是增量还是全量。如果是增量更新,比如玩家完成一局加 50 分,那就用ZINCRBY;如果是全量覆盖,比如从数据库同步最新积分,那就用ZADD。这两个命令的语义不同,用错了会导致数据不一致。
3.2 同分排序的三种处理策略
同分排序是排行榜里最容易被忽略的细节。Redis 默认按 member 字典序排,这对玩家来说是不公平的,先达到该分数的玩家应该排在前面。我试过三种处理方式:
第一种是分数编码法,把时间戳编码进 score 的小数部分。优点是查询简单,一个 ZSET 搞定;缺点是浮点数精度有限,积分值很大时小数部分可能被截断。适合积分范围可控的场景。
第二种是辅助排序字段法,member 不用纯玩家 ID,而是用player_id:timestamp拼接。查询时解析出玩家 ID。优点是精度无损;缺点是 member 变长,内存占用增加,而且更新积分时需要先删旧 member 再加新 member。
第三种是查询后二次排序,从 Redis 取出同分区间后,在应用层按时间戳再排一次。优点是 Redis 层简单;缺点是如果同分人数很多,取出的数据量会很大。
注意:不管用哪种方式,都要在测试环境模拟大量同分场景验证排序结果,我踩过浮点精度导致排名错乱的坑,排查了大半天才发现是编码问题。
3.3 我的排名与前后差距计算
玩家最关心的往往不是榜首是谁,而是“我排第几”和“我离上一名差多少分”。ZREVRANK可以拿到排名,ZSCORE可以拿到自己的分数。要算与前一名的差距,需要再查一次前一名的分数:
def get_my_rank_info(player_id): pipe = r.pipeline() pipe.zrevrank('rank:global:score', player_id) pipe.zscore('rank:global:score', player_id) rank, score = pipe.execute() if rank is None: return None rank += 1 prev_score = None if rank > 1: prev = r.zrevrange('rank:global:score', rank-2, rank-2, withscores=True) if prev: prev_score = prev[0][1] return { 'rank': rank, 'score': score, 'gap': (prev_score - score) if prev_score else 0 }这里用 pipeline 把两次查询合并成一次网络往返,减少延迟。如果排行榜查询非常频繁,可以考虑把 Top N 的结果缓存到本地内存,每隔几秒刷新一次,这样大部分请求都不用打到 Redis。
3.4 榜单分页与区间查询
ZREVRANGE支持 start 和 stop 参数,天然支持分页。但要注意深度分页的性能问题:取第 10000 到 10100 名,Redis 需要跳过前 10000 个元素。虽然 ZSET 底层是跳表,跳过的代价比数据库小很多,但在极端情况下依然有开销。我的经验是,排行榜一般用户只会看前几页,深度分页的需求很少,如果真有,可以用ZREVRANGEBYSCORE按分数区间来取,性能更稳定。
另外,如果榜单需要支持“只看好友排名”,那就不能只用一个全局 ZSET 了。常见做法是为每个玩家的好友列表单独维护一个 ZSET,或者查询时取全局排名再过滤。前者写入放大严重,后者查询放大严重,需要根据好友数量和活跃度权衡。
4. 数据一致性与持久化策略
4.1 缓存与数据库的同步
Redis 做排行榜,数据库通常还是玩家积分的权威来源。这就带来一个经典问题:什么时候写 Redis,什么时候写数据库?我的做法是积分变动时先写数据库,再更新 Redis,并且用消息队列做异步补偿。如果 Redis 更新失败,消息队列会重试,保证最终一致。
为什么不先写 Redis?因为 Redis 可能丢数据(取决于持久化配置),如果先写 Redis 再写数据库,Redis 丢了数据但数据库没写成功,就会出现积分不一致。先写数据库虽然多一次 IO,但数据安全性更高。对于积分这种涉及玩家利益的数据,安全性优先于极致性能。
4.2 Redis 持久化配置选择
Redis 有两种持久化方式:RDB 和 AOF。RDB 是定时快照,恢复快但可能丢最近几分钟的数据;AOF 是追加日志,数据安全性高但文件大、恢复慢。对于排行榜场景,我一般建议开启 AOF 并设置appendfsync everysec,这样最多丢一秒的数据。如果排行榜数据可以从数据库重建,那 RDB 也够用,因为丢了重新从数据库灌一次就行。
# redis.conf 关键配置 appendonly yes appendfsync everysec save 900 1 save 300 10提示:如果排行榜数据完全可以从数据库重建,建议写一个重建脚本,在 Redis 故障恢复后自动执行,避免人工干预。
4.3 定时快照与赛季重置
很多游戏有赛季概念,赛季结束后排行榜要重置。我的做法是赛季结束时把当前 ZSET 重命名存档,比如RENAME rank:season:202401:score rank:season:202401:score:archive,然后新建一个空的 ZSET 给新赛季用。存档的 ZSET 可以设置过期时间,到期自动清理,也可以导出到数据库后删除。
定时快照则是另一种需求:每隔一段时间把排行榜状态存一份,用于回滚或数据分析。这个用定时任务读取 ZSET 全量数据写入数据库即可,注意全量读取大 ZSET 要用ZSCAN分批读,不要用ZRANGE一次拉完,否则会阻塞 Redis。
5. 高并发场景下的性能优化
5.1 热点 Key 问题与拆分
全局排行榜的 ZSET 是一个典型的热点 Key。所有玩家的积分更新和排名查询都打在这个 Key 上,单分片 Redis 可能扛不住。解决办法有两个:一是读写分离,用从节点承担查询流量;二是分片,把玩家按 ID 哈希到多个 ZSET,查询时合并结果。分片方案实现复杂,一般只有顶级规模的项目才需要。
更实用的优化是减少不必要的查询。比如玩家打开排行榜界面时才查一次,而不是每次积分变动都查排名。再比如 Top 100 的结果缓存 5 秒,这 5 秒内的请求都返回缓存,能挡掉绝大部分重复查询。
5.2 批量更新与 Pipeline
当大量玩家同时积分变动时,逐条ZADD的网络开销很大。用 pipeline 批量提交能显著提升吞吐:
def batch_update_scores(updates): pipe = r.pipeline() for player_id, score in updates: pipe.zadd('rank:global:score', {player_id: score}) pipe.execute()实测下来,批量 100 条提交比逐条提交快 5 到 10 倍。但要注意 pipeline 不是越大越好,一次提交太多命令会占用大量内存,一般控制在 100 到 500 条之间。
5.3 限流与降级预案
排行榜查询在活动期间可能暴涨,必须有限流和降级。我的做法是在网关层对排行榜接口做限流,超过阈值的请求直接返回缓存数据或友好提示。降级预案包括:关闭实时排名计算,只返回缓存的 Top N;暂停非核心的排名查询接口,保证积分更新不受影响。
这些预案要提前写好并测试,不要等出事了两手空空。我经历过一次活动,排行榜接口 QPS 涨了 20 倍,幸好提前做了限流和缓存,系统稳住了,但如果没有准备,数据库和 Redis 都会被拖垮。
6. 常见问题排查与避坑经验
6.1 排名错乱的排查思路
排名错乱通常有三个原因:同分排序不符合预期、积分更新丢失、Redis 数据与数据库不一致。排查时先确认 Redis 里的实际数据,用ZSCORE查几个已知玩家的分数,跟数据库对比。如果分数对但排名不对,那就是同分排序问题;如果分数不对,那就是同步链路出了问题。
我遇到过一次排名错乱,最后发现是积分更新用了ZADD但传了错误的 member,把两个玩家的数据搞混了。所以 member 的生成逻辑一定要统一,最好封装成一个函数,所有地方都调它。
6.2 内存暴涨的定位与处理
ZSET 内存暴涨一般是 member 太长或成员太多。用MEMORY USAGE rank:global:score可以查看单个 Key 的内存占用。如果 member 是拼接了时间戳的长字符串,可以考虑缩短编码,比如用 Base62 压缩。如果成员确实太多,就要考虑分桶或定期清理不活跃玩家。
6.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 排名与预期不符 | 同分排序策略 | 查同分玩家顺序 | 调整分数编码或二次排序 |
| 积分更新丢失 | 同步链路失败 | 对比 Redis 与数据库 | 加消息队列补偿 |
| 查询变慢 | 热点 Key 或深度分页 | 监控 Redis 慢查询 | 加缓存或分片 |
| 内存持续增长 | member 过长或成员过多 | MEMORY USAGE 查看 | 压缩编码或清理 |
| 赛季重置后数据还在 | 重置逻辑未执行 | 检查定时任务 | 补执行并加告警 |
6.4 我的实操心得
最后分享几个文档里不会写的经验。第一,永远不要相信“积分不会超过某个值”这种假设,我见过策划说积分上限 100 万,结果活动一开玩家刷到了上亿,浮点编码直接溢出。第二,排行榜的测试一定要用真实规模的数据,用 100 条数据测没问题,不代表 100 万条没问题。第三,给排行榜加监控,包括查询延迟、Redis 内存、更新失败率,出问题能第一时间发现。
这套方案我在几个项目里都用过,从几万玩家到上百万玩家都跑得稳。核心还是那句话:先搞清楚业务需求,再选技术方案,不要反过来。排行榜看着简单,细节却很多,希望这篇笔记能帮你少走点弯路。