1. 排行榜背后的积分模型到底怎么选
做游戏后端的人,迟早都会撞上排行榜这个需求。表面上看,它无非就是"谁分高谁排前面",但真动手做的时候,你会发现坑一个接一个:积分怎么存、怎么排、怎么保证实时性、怎么防止刷分、怎么处理并列名次、怎么在数据量涨到千万级之后还能在几十毫秒内返回结果。这些问题不解决,排行榜就会从"一个小功能"变成"一个拖垮整个服务的老大难"。
我前后在几个不同类型的项目里做过排行榜,有休闲小游戏、有带赛季的竞技类、也有偏单机的离线榜单。每次重做都会推翻上一次的部分设计,因为业务形态一变,积分的语义就变了,排行榜的实现方式也必须跟着变。这篇笔记就把我踩过的坑和最后沉淀下来的几套方案完整梳理一遍,从积分模型、存储选型、排序算法到并发控制和防刷策略,尽量讲透。不管你是刚接触排行榜的新手,还是想给现有系统做一次重构,应该都能从里面找到能直接抄的部分。
先说清楚一个前提:排行榜的核心矛盾永远是实时性、准确性、成本这三者的平衡。想要绝对实时又绝对准确,成本一定高;想要便宜,就得接受一定的延迟或者近似。所有的方案选型,本质上都是在回答"你的业务能接受哪种妥协"。所以第一步不是选 Redis 还是选数据库,而是先把积分的语义定义清楚。
1.1 积分不是分数,先分清三种语义
很多人一上来就把"积分"当成一个整数存起来,这是最容易埋雷的地方。积分在不同游戏里至少有三层不同的含义,处理方式完全不一样。
第一种是累计积分,比如玩家每局游戏得分累加,只增不减。这种积分的特点是单调递增,排行榜本质上是"历史总贡献榜"。它的好处是排序稳定,坏处是新人永远追不上老人,容易劝退。处理这类积分时,通常要配合赛季重置或者分段榜单。
第二种是当前积分,比如天梯分、段位分,会随着胜负上下浮动。这种积分才是真正需要实时排序的,因为它直接决定匹配和段位展示。它的难点在于更新频繁,而且往往要求强一致,玩家赢了一局刷新页面必须立刻看到分数变化。
第三种是可消耗积分,比如金币、钻石这类既是货币又可能参与排行的资源。这种积分最麻烦,因为它同时承担交易和排行两个职责,一旦涉及扣减,就必须考虑事务和幂等。
我的经验是:在数据库设计阶段就把这三类积分拆成不同的字段甚至不同的表,千万不要用一个
score字段打天下。我见过一个项目把天梯分和金币塞进同一个字段,结果做活动发金币直接把排行榜冲乱了,回滚数据花了两天。
1.2 排序键的选择决定了整个方案的复杂度
积分确定之后,下一个关键决策是排序键。最直觉的做法是"按积分降序",但真实业务里几乎不会这么简单。常见的复合排序需求包括:积分相同时按达成时间升序(先到先得)、按玩家等级降序、按最近活跃时间降序等等。
这里有个非常实用的技巧:把复合排序键编码成一个可比较的单一数值或字符串。比如积分是主键、时间是次键,可以构造一个score * 10^10 + (MAX_TIME - timestamp)的整数,这样单字段排序就能表达复合语义。Redis 的 ZSET 就是靠这个思路工作的,它的 score 是 double,能塞进 53 位有效整数,足够表达大多数复合键。
但要注意 double 的精度陷阱。53 位有效位意味着安全整数范围大约是 9×10^15,如果你的积分本身就能到万亿级,再乘个时间戳就会溢出精度,导致排序错乱。这种情况就得改用字符串拼接加字典序,或者干脆用支持多字段排序的存储。
1.3 实时榜、准实时榜、离线榜的分层设计
我最后稳定下来的架构基本都是分层的,因为不同场景对实时性的要求差太多了。
| 榜单类型 | 更新延迟 | 典型场景 | 推荐存储 |
|---|---|---|---|
| 实时榜 | 毫秒级 | 天梯、赛季榜 | Redis ZSET |
| 准实时榜 | 秒到分钟级 | 日榜、周榜 | Redis + 定时落库 |
| 离线榜 | 小时到天级 | 历史总榜、成就榜 | 关系库 + 预计算 |
分层的好处是成本可控。实时榜只保留 Top N 和玩家自己的排名,全量数据落在关系库或者列式存储里做离线分析。玩家查询时先查实时榜,查不到再回源,这样既保证了体验又控制了内存占用。
2. 基于 Redis ZSET 的实时排行榜实现细节
Redis 的 ZSET 几乎是实时排行榜的标配,但"会用"和"用好"之间差着十万八千里。这一章把 ZSET 方案的完整实现拆开讲,包括数据结构设计、更新逻辑、分页查询和几个必须知道的性能陷阱。
2.1 ZSET 的核心操作与时间复杂度
ZSET 底层是跳表加哈希表,插入、删除、按 score 查询都是 O(log N),按排名范围查询是 O(log N + M),M 是返回的元素个数。这个复杂度在千万级数据下依然能保持毫秒响应,这是它比关系库ORDER BY强的地方。
核心命令就几个:
# 更新积分,存在则覆盖 ZADD leaderboard 1500 player:1001 # 积分增加(用于累加场景) ZINCRBY leaderboard 50 player:1001 # 获取排名(从高到低,0 开始) ZREVRANK leaderboard player:1001 # 获取 Top 100 ZREVRANGE leaderboard 0 99 WITHSCORES # 获取玩家分数 ZSCORE leaderboard player:1001看起来很简单,但实际用的时候有几个细节必须注意。ZADD默认是覆盖,如果业务是累加语义,必须用ZINCRBY,否则并发下会丢分。而ZINCRBY本身是原子操作,不需要额外加锁,这点比"先读再写"安全得多。
2.2 复合排序键的编码实战
前面提到复合排序键,这里给一个具体的编码方案。假设需求是:积分降序,积分相同时先达成者排前面。
import time MAX_TS = 4102444800 # 2100-01-01 的时间戳,作为基准 def encode_score(points, timestamp): # points 是主排序键,timestamp 越小排名越靠前 # 用 MAX_TS - timestamp 让早达成的时间戳更大 return points * 10**10 + (MAX_TS - int(timestamp))这样编码后,直接ZADD这个复合值,ZREVRANGE出来的顺序就自动满足"积分降序 + 时间升序"。解码的时候取整和取余即可还原。
注意:这个方案要求 points 不能太大,否则乘 10^10 之后会超过 double 的安全整数范围。如果积分可能到百万级,10^10 的乘数会让结果逼近 10^16,已经接近精度边界了。稳妥做法是把乘数降到 10^7 左右,或者改用字符串编码。
2.3 分页查询的深分页问题
排行榜分页有个经典陷阱:ZREVRANGE leaderboard 10000 10019这种深分页,虽然复杂度是 O(log N + M),M 只有 20,看起来很快,但如果你要查的是"第 100 万名附近",跳表定位本身还是要走一遍。更麻烦的是,很多业务需要"我附近的排名",比如显示玩家自己前后各 5 名。
正确做法是先拿到玩家排名,再基于排名做范围查询:
def get_nearby_rank(player_id, span=5): rank = redis.zrevrank("leaderboard", player_id) if rank is None: return [] start = max(0, rank - span) end = rank + span return redis.zrevrange("leaderboard", start, end, withscores=True)这样每次只查 11 个元素,无论玩家排在第几名都是 O(log N) 的定位加 O(span) 的读取,非常稳定。
2.4 内存占用估算与分片策略
ZSET 每个元素大约占 100 字节左右(跳表节点加哈希表开销),一千万玩家就是 1GB 左右。这个量级单机还能扛,但再往上就得考虑分片。
分片有两种思路。一种是按榜单分片,比如日榜、周榜、总榜各一个 key,天然隔离。另一种是按玩家哈希分片,把玩家分散到多个 ZSET,查询 Top N 时从每个分片取 Top N 再归并。第二种适合玩家数量极大但只需要 Top N 的场景,缺点是查单个玩家的全局排名很麻烦,需要汇总所有分片。
我一般优先用第一种,因为大多数游戏的榜单本身就是按维度切分的,实现简单且没有归并开销。只有当单个榜单的玩家数超过五百万时,才会考虑第二种。
3. 关系数据库方案的适用场景与优化
Redis 不是万能的。有些场景下,关系库反而更合适,比如需要复杂筛选条件、需要事务保证、或者数据量不大但一致性要求极高。这一章讲讲关系库方案怎么做,以及怎么把它优化到能用的程度。
3.1 什么情况下应该放弃 Redis
我总结了几条判断标准,只要命中其中一条,就该认真考虑关系库:
- 排行榜需要按多个维度筛选,比如"只统计某区服、某职业、某时间段的玩家"
- 积分更新必须和订单、交易在同一个事务里
- 榜单数据量在百万以内,且允许秒级延迟
- 团队没有 Redis 运维能力,不想引入额外组件
这些场景下硬上 Redis 会带来数据同步的复杂度,反而得不偿失。
3.2 索引设计与查询优化
关系库做排行榜,核心是索引。最基本的表结构:
CREATE TABLE player_score ( player_id BIGINT PRIMARY KEY, score BIGINT NOT NULL, updated_at TIMESTAMP NOT NULL, INDEX idx_score_time (score DESC, updated_at ASC) );查询 Top 100:
SELECT player_id, score FROM player_score ORDER BY score DESC, updated_at ASC LIMIT 100;这个查询能走idx_score_time索引,百万级数据下响应在几十毫秒。但深分页依然是问题,LIMIT 1000000, 20会扫描大量行。解决办法是用游标分页,记住上一页最后的 score 和 updated_at,下一页从那里继续:
SELECT player_id, score FROM player_score WHERE (score, updated_at) < (?, ?) ORDER BY score DESC, updated_at ASC LIMIT 20;这种写法能稳定走索引,无论翻到第几页性能都一样。
3.3 物化视图与预计算表
如果查询条件很复杂,实时ORDER BY扛不住,就得用预计算。常见做法是定时任务把榜单算好写进一张结果表,查询直接读结果表。
CREATE TABLE leaderboard_snapshot ( rank INT, player_id BIGINT, score BIGINT, snapshot_time TIMESTAMP );定时任务每 5 分钟跑一次,全量重算或者增量更新。查询时直接SELECT * FROM leaderboard_snapshot WHERE rank <= 100,快得飞起。代价是延迟,玩家看到的可能是几分钟前的排名。
实操心得:预计算表一定要加
snapshot_time字段,并且保留最近几个版本。这样出问题时能快速回滚到上一个正确快照,也能做"排名变化"这类功能。
3.4 分区表应对海量数据
当单表超过千万行,即使有索引,维护成本也会上升。这时候可以用分区表,按时间或者按区服分区。比如按月分区,每个月一个分区,历史分区可以归档到冷存储。
CREATE TABLE player_score ( player_id BIGINT, score BIGINT, updated_at TIMESTAMP, PARTITION BY RANGE (YEAR(updated_at) * 100 + MONTH(updated_at)) );分区的好处是查询能裁剪到特定分区,维护时也能单独操作某个分区。但要注意,跨分区的全局排序依然需要归并,所以分区方案更适合"按分区维度查询"的场景,比如查某个月的榜单。
4. 并发更新与防刷分的工程实践
排行榜做出来容易,做稳难。真正让线上出事故的,往往不是排序算法,而是并发和作弊。这一章讲讲怎么保证积分更新的正确性,以及怎么识别和拦截刷分行为。
4.1 并发更新的原子性保证
积分更新最常见的并发问题是"读改写"竞态。两个请求同时读到 100 分,各自加 10 再写回,结果只加了 10 而不是 20。解决办法有三种:
第一种是用原子命令,Redis 的ZINCRBY、数据库的UPDATE ... SET score = score + ?都是原子的,优先用它们。
第二种是乐观锁,加版本号字段,更新时校验版本:
UPDATE player_score SET score = score + ?, version = version + 1 WHERE player_id = ? AND version = ?;影响行数为 0 说明版本冲突,重试即可。
第三种是分布式锁,适合跨多个资源的复杂更新。但锁的粒度要控制好,能不用就不用,因为锁会显著降低吞吐。
4.2 幂等设计防止重复加分
网络重试、消息重复投递都会导致同一笔加分被执行多次。幂等设计的关键是给每次加分一个唯一标识,比如订单号或者请求 ID。
CREATE TABLE score_log ( request_id VARCHAR(64) PRIMARY KEY, player_id BIGINT, delta INT, created_at TIMESTAMP );加分前先插入score_log,主键冲突说明已经处理过,直接返回成功。这样即使请求重复到达,也只会生效一次。
4.3 刷分行为的识别与拦截
刷分是排行榜的顽疾。常见的刷分手段包括:脚本批量注册小号、利用漏洞重复领奖、篡改客户端上报的分数。防御要分层做。
客户端上报的分数永远不可信,关键校验必须在服务端。比如射击游戏,服务端应该根据命中判定自己算分,而不是信客户端传上来的数字。如果实在需要客户端上报,至少要做合理性校验:单局得分不能超过理论上限、单位时间内的得分速率不能异常、同一设备多账号要关联分析。
行为层面,可以统计几个指标做风控:
| 指标 | 异常特征 | 处理建议 |
|---|---|---|
| 单局得分 | 远超理论上限 | 直接拒绝并标记 |
| 得分速率 | 单位时间得分过高 | 进入人工审核队列 |
| 设备关联 | 同设备多账号上榜 | 限制上榜资格 |
| 登录间隔 | 账号创建后立即上榜 | 新号观察期 |
踩过的坑:早期我们只做了单局上限校验,结果有人用脚本把每局分数卡在上限内,靠高频对局刷总榜。后来加了"单位时间得分速率"才拦住。风控规则一定要多维度组合,单条规则很容易被绕过。
4.4 榜单数据的最终一致性
实时榜和离线库之间必然存在同步延迟。玩家可能遇到"实时榜显示我第 10,离线榜显示我第 12"的情况。处理这种不一致,要么统一以实时榜为准,要么在展示时明确标注数据时间。
我的做法是:实时榜只用于展示 Top N 和玩家自己的排名,所有涉及奖励发放、资格判定的逻辑一律以离线库为准。这样即使实时榜有短暂偏差,也不会影响实际利益,玩家感知也还好。
5. 常见问题排查与性能调优实录
这一章把我在实际运维中遇到的高频问题整理成速查表,每个问题都附上排查思路和解决方案。这些都是文档里不会写、但线上一定会遇到的东西。
5.1 排行榜不更新或更新延迟
这是最常见的问题,排查顺序如下:
- 先确认写入是否成功,直接查存储里的原始值
- 检查是否有缓存层,缓存过期时间是否合理
- 确认更新逻辑是否走了异步队列,队列是否积压
- 检查是否有多个写入源互相覆盖
我遇到过一次诡异的不更新,最后发现是两个服务实例用了不同的 key 前缀,一个写rank:1,一个写rank:01,查的时候只查了其中一个。这种问题只能靠统一的 key 生成工具类来避免。
5.2 排名跳变或并列名次错乱
排名跳变通常是复合排序键编码出错,或者并发更新导致 score 被覆盖。并列名次错乱则多半是次排序键没有正确参与编码。
排查时先把出问题的玩家 score 和排名拉出来,手工按编码规则算一遍,看是否一致。如果不一致,就是编码逻辑有 bug;如果一致但业务上不对,就是排序键设计没覆盖需求。
5.3 内存暴涨与 key 泄漏
Redis 内存暴涨,八成是 key 没有设置过期时间。排行榜的日榜、周榜这类周期性榜单,一定要设置 TTL,到期自动清理。总榜这种长期存在的,也要定期归档冷数据。
# 给日榜设置 2 天过期 EXPIRE leaderboard:daily:20240101 172800另外要监控INFO memory里的used_memory和mem_fragmentation_ratio,碎片率超过 1.5 就要考虑重启或者做内存整理。
5.4 高频问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 排名不更新 | 写入失败/缓存未失效 | 查原始值、查缓存 TTL |
| 排名跳变 | 复合键编码错误 | 手工验算编码 |
| 内存暴涨 | key 无过期/数据泄漏 | 查 TTL、查 key 数量 |
| 查询变慢 | 深分页/大 key | 改游标分页、拆分 key |
| 分数丢失 | 并发覆盖 | 改用原子命令 |
| 重复加分 | 无幂等 | 加请求 ID 去重 |
5.5 压测与容量规划
上线前一定要压测。我的经验值是:单机 Redis 在 ZSET 元素百万级时,ZREVRANGE的 QPS 能到几万,ZINCRBY能到十万级。但这是理想情况,实际还要看网络和序列化开销。
容量规划按"峰值 QPS × 平均耗时 × 冗余系数"估算。冗余系数我一般取 3,也就是按峰值的三倍准备资源。排行榜这种功能,宁可多留点余量,也不要在大促或者活动期间掉链子。
最后分享一个我一直在用的小技巧:给排行榜加一个"影子榜",把线上真实流量复制一份到影子榜上跑新逻辑,对比两边的结果。这样可以在不影响线上的前提下验证重构或者新算法,等两边数据完全一致再切换。这个做法帮我避免了好几次可能的事故,尤其是在改排序逻辑的时候特别有用。