使用 Redis sorted set 设计游戏实时排行榜
1. 概述
排行榜 (leaderboard) 出现在几乎所有在线游戏中。这个问题听起来很简单:按分数对玩家进行排序。当有数百万玩家和每秒数千场比赛结束时,关系数据库上的 ORDER BY score DESC 无法再满足需求。
这篇文章介绍了完整的设计:需求、Redis sorted set 上的数据模型、API、处理流程,以及难点如平分、重复更新、数据一致性和流量突增。读完之后,你可以用 Spring Boot 3 和 Redis 构建一个可运行的排行榜,同时了解它的局限性。
2. 问题需求
2.1. 功能需求
- 每个赛季 (season) 有一个独立的排行榜。玩家的分数是在一场比赛中达到的最高分数。
- 查看前 N 名(通常是 100)。
- 查看自己的排名,即使排在第 3,456,789 位。
- 查看“我周围的人”:上面 5 个人和下面 5 个人。
- 当两个人分数相同时,先达到该分数的人排在上面。
2.2. 规模和约束
- 每个赛季 1000 万玩家。
- 平均每秒 2,000 场比赛结果。在高峰时或事件期间可能达到 20,000/秒。
- 读比写多得多:顶部 100 的屏幕被频繁调用。
- 可接受的显示延迟:比赛结束后 1-2 秒以内。
- 一个比赛结果可能由于重试而被发送多次。重复的结果不应该使排行榜出错。
3. 为什么选择 Redis sorted set
Sorted set (ZSET) 存储 member → score 对,并始终保持按 score 排序。内部使用跳跃表结合哈希表,因此具有以下特征:
| 操作 | 命令 | 时间复杂度 |
|---|---|---|
| 更新分数 | ZADD |
O(log N) |
| 获取排名 | ZREVRANK |
O(log N) |
| 获取前 N / 范围 | ZREVRANGE |
O(log N + M) |
| 获取一个人的分数 | ZSCORE |
O(1) |
对于 N = 1000 万,log N 仅约 23 步。与 SQL 相比:要了解一个人的排名,你必须计算分数高于他的行数。即使有索引,这个查询在大规模上仍然很昂贵。
在内存方面,每个 member 大约消耗 80-120 字节,取决于 ID 的长度。1000 万玩家大约 1 GB,仍然可以放在一个 Redis 节点中。
4. 整体架构
Game Server ──► Kafka (topic: match-results) ──► Leaderboard Consumer ──► Redis ZSET
│ ▲
└──► Persist Consumer ──► PostgreSQL (源头数据) │
│
Client ──► Leaderboard API (Spring Boot) ── 在 RAM 中缓存前 100 ────────────┘
有三个关键决定:
- PostgreSQL 是源头数据。 Redis 只是用于快速读取的索引。如果丢失 Redis,你可以从数据库重建。
- Kafka 处于中间 以吸收流量突增。当 Redis 忙时,游戏服务器不会变慢。
- 读 API 与写流程分开。 前 100 被短期缓存在每个实例的 RAM 中。
在 PostgreSQL 中存储比赛结果的表:
CREATE TABLE match_results (
match_id UUID NOT NULL,
player_id BIGINT NOT NULL,
season_id INT NOT NULL,
points INT NOT NULL CHECK (points >= 0),
achieved_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (match_id, player_id)
);
CREATE INDEX idx_season_player ON match_results (season_id, player_id);
主键 (match_id, player_id) 使得重复写入变得无害:只需要 INSERT ... ON CONFLICT DO NOTHING。
5. Redis 上的数据模型
5.1. 键和成员
lb:{s12} → ZSET,member = playerId,score = 编码的分数
lb:{s12}:dedup:<matchId>:<playerId> → STRING,标记已处理的结果 (TTL 1 天)
部分 {s12} 是 hash tag。在 Redis Cluster 上,具有相同 hash tag 的所有键将位于同一个 slot。如果你想在 Lua 脚本中一起使用它们,这是必需的。
5.2. 通过编码分数处理平分
Redis 将具有相同分数的成员按 member 的字典顺序排列。这种排序方式对游戏没有意义。我们需要“谁先达到就排在上面”,所以必须把时间编入分数中。
Redis 的分数是 double,可以精确表示所有整数到 2^53。我们将 53 位分配如下:
- 高 21 位:分数(最多约 200 万)。
- 低 32 位:
MAX_TIME - 自赛季开始以来的秒数。谁更早达到将有更大的值,所以在降序排列时排在上面。
public final class ScoreCodec {
private static final int TIME_BITS = 32;
private static final long MAX_TIME = (1L << TIME_BITS) - 1;
public static final long MAX_POINTS = (1L << 21) - 1;
private ScoreCodec() {}
public static long encode(long points, long secondsSinceSeasonStart) {
if (points < 0 || points > MAX_POINTS) {
throw new IllegalArgumentException("分数超出范围: " + points);
}
if (secondsSinceSeasonStart < 0 || secondsSinceSeasonStart > MAX_TIME) {
throw new IllegalArgumentException("时间超出范围");
}
return (points << TIME_BITS) | (MAX_TIME - secondsSinceSeasonStart);
}
public static long points(double encoded) {
return ((long) encoded) >>> TIME_BITS;
}
}
encode 函数返回 long。你会将此值作为整数字符串发送到 Redis,以避免将其格式化为 double 时的任何偏差。读回时,points() 右移 32 位以恢复原始分数。转换 (long) encoded 是精确的,因为该值不超过 2^53。
注意: 如果你的游戏分数超过 200 万,减少时间分辨率,例如按分钟而不是秒计算,以便为分数获得更多位。永远不要超过 53 位。超过此限制,
double会舍入,两个不同分数的人可能会获得相同的分数。
编码部分的测试,因为这是最容易出错的地方:
class ScoreCodecTest {
@Test
void higherPointsAlwaysRankAbove() {
long late = ScoreCodec.encode(1001, 3_000_000);
long early = ScoreCodec.encode(1000, 0);
assertThat(late).isGreaterThan(early);
}
@Test
void samePointsEarlierAchieverRanksAbove() {
long alice = ScoreCodec.encode(500, 100);
long bob = ScoreCodec.encode(500, 200);
assertThat(alice).isGreaterThan(bob);
}
@Test
void decodeSurvivesDoubleRoundTrip() {
long encoded = ScoreCodec.encode(ScoreCodec.MAX_POINTS, 12_345);
double fromRedis = (double) encoded;
assertThat(ScoreCodec.points(fromRedis)).isEqualTo(ScoreCodec.MAX_POINTS);
}
}
第三个测试模拟 Redis 以 double 形式返回分数。此测试验证在最大值处,我们仍然可以正确解码。
6. 更新分数:原子性和幂等性
需要的依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
更新分数有两个要求必须同时满足:
- 仅当新分数高于当前分数时才写入。
- 忽略已处理的比赛结果。
如果用两个单独的命令如 ZSCORE 然后 ZADD,两个 consumer 可能会相互插入。这样,较低的分数可能会覆盖较高的分数。我们将所有内容放入一个 Lua 脚本中。Redis 单线程运行脚本并以原子方式执行。
@Service
public class LeaderboardWriter {
private static final RedisScript<Long> SUBMIT = RedisScript.of("""
if not redis.call('SET', KEYS[2], '1', 'NX', 'EX', ARGV[3]) then
return -1
end
local cur = redis.call('ZSCORE', KEYS[1], ARGV[2])
if cur and tonumber(cur) >= tonumber(ARGV[1]) then
return 0
end
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])
return 1
""", Long.class);
private final StringRedisTemplate redis;
public LeaderboardWriter(StringRedisTemplate redis) {
this.redis = redis;
}
public long submit(MatchResult r) {
String board = "lb:{s" + r.seasonId() + "}";
String dedup = board + ":dedup:" + r.matchId() + ":" + r.playerId();
long score = ScoreCodec.encode(r.points(), r.secondsSinceSeasonStart());
return redis.execute(SUBMIT, List.of(board, dedup),
Long.toString(score), Long.toString(r.playerId()), "86400");
}
}
public record MatchResult(UUID matchId, long playerId, int seasonId,
int points, long secondsSinceSeasonStart) {}
脚本返回三个值:
-1:重复。SET NX失败,因为已经处理过此比赛结果。0:不是新纪录,排行榜不变。1:已更新纪录。
你可以使用返回代码来计算指标或发送“你刚升级了!“之类的通知。
注意: Redis 6.2+ 有
ZADD key GT score member来仅在分数更大时写入。如果不需要去重,你可以使用该命令代替脚本。这里仍然需要脚本,因为必须将去重检查与原子操作合并。
如果游戏规则是累加分数 (ZINCRBY) 而不是取最高分,幂等性变得必需。原因:加两次是错误的。在这种情况下,分数中的时间部分也必须在每次添加时更新,所以脚本会更复杂。
7. 读取 API
@RestController
@RequestMapping("/seasons/{seasonId}/leaderboard")
public class LeaderboardController {
private final StringRedisTemplate redis;
public LeaderboardController(StringRedisTemplate redis) {
this.redis = redis;
}
public record Entry(long rank, long playerId, long points) {}
@GetMapping("/players/{playerId}/around")
public List<Entry> around(@PathVariable int seasonId, @PathVariable long playerId,
@RequestParam(defaultValue = "5") int radius) {
String key = "lb:{s" + seasonId + "}";
Long rank = redis.opsForZSet().reverseRank(key, Long.toString(playerId));
if (rank == null) {
return List.of();
}
long start = Math.max(0, rank - radius);
return range(key, start, rank + radius);
}
List<Entry> range(String key, long start, long end) {
var tuples = redis.opsForZSet().reverseRangeWithScores(key, start, end);
List<Entry> result = new ArrayList<>();
long rank = start + 1;
for (var t : tuples) {
result.add(new Entry(rank++, Long.parseLong(t.getValue()),
ScoreCodec.points(t.getScore())));
}
return result;
}
}
reverseRank 返回按降序计数从 0 开始的排名,所以显示排名等于 rank + 1。如果玩家在该赛季还没有分数,函数返回 null 并且 API 返回空列表。调用 /seasons/12/leaderboard/players/42/around 会得到如下结果:
[{"rank":3456784,"playerId":901,"points":1520},
...
{"rank":3456789,"playerId":42,"points":1518},
...
{"rank":3456794,"playerId":77,"points":1515}]
reverseRank 和 reverseRangeWithScores 两个命令之间不是原子的。在两个命令之间,排行榜可能会有轻微变化。对于排名屏幕,几百毫秒的偏差是可以接受的。如果需要绝对准确,请将它们合并到一个 Lua 脚本中。
8. 难点和解决方案
8.1. 前 100 的热键
每个客户端都调用前 100,所有这些都落到一个键上的一个节点 Redis。添加副本会有所帮助。不过,最有效的方法是在每个 API 实例的 RAM 中缓存并每秒刷新一次:
@Component
public class TopCache {
private final LeaderboardController reader;
private volatile List<LeaderboardController.Entry> top100 = List.of();
public TopCache(LeaderboardController reader) {
this.reader = reader;
}
@Scheduled(fixedRate = 1000)
void refresh() {
top100 = reader.range("lb:{s12}", 0, 99);
}
public List<LeaderboardController.Entry> get() {
return top100;
}
}
记得启用 @EnableScheduling。对于 50 个 API 实例,无论有多少百万客户端,Redis 每秒只收到 50 个前 100 的请求。在示例中,season 被硬编码以简洁。实际上,你应该从配置中获取当前赛季。
8.2. 写入的流量突增
当一个事件结束时,数以万计的比赛可能同时关闭。Kafka 充当缓冲。Consumer 以批处理方式处理并通过 pipeline 发送命令以减少网络往返次数:
@KafkaListener(topics = "match-results", batch = "true", groupId = "leaderboard")
public void onBatch(List<MatchResult> results) {
results.forEach(writer::submit);
}
如果需要更快,你可以将 execute 调用分组到 redis.executePipelined(...)。我们也按 playerId 对主题进行分区。这样,一个玩家的所有结果在同一分区上顺序处理,减少竞争。即使有竞争,Lua 脚本仍然保持正确。
当 Redis 缓慢时,consumer 延迟增加,但游戏服务器不会受到影响。玩家只是看到排行榜更新延迟几秒。
8.3. Redis 和数据库之间的一致性
如果节点在 fsync 之前死亡,Redis 可能会丢失数据。由于 PostgreSQL 是源头数据,你需要一个重建排行榜的作业:
SELECT player_id,
MAX(points) AS best,
MIN(achieved_at) FILTER (WHERE points = best_points) AS first_at
FROM (
SELECT player_id, points, achieved_at,
MAX(points) OVER (PARTITION BY player_id) AS best_points
FROM match_results
WHERE season_id = 12
) t
GROUP BY player_id;
作业将结果写入临时键,如 lb:{s12}:rebuild,然后使用 RENAME 以原子方式替换真实键。用户永远不会看到中间的空排行榜。
此作业也定期用于对账。如果 Redis 中的成员数与数据库中的成员数不一致,你会发现。
8.4. 按天、周排行榜和清理
日排行榜使用键 lb:{s12}:d:2026-10-12 加上 EXPIRE 3 天。周排行榜可以使用 ZUNIONSTORE ... AGGREGATE MAX 从日排行榜聚合。赛季结束时,将前 1000 个快照保存到数据库以分配奖励,然后使用 UNLINK 而不是 DEL 删除大键,以避免阻止 Redis。
8.5. 好友排行榜
对于约 200 个好友的列表,使用 ZMSCORE lb:{s12} id1 id2 ... 在一个命令中获取所有人的分数,然后在 Java 中排序。无需为每个人创建单独的 ZSET。
9. 何时不应使用此设计
- 超过一个节点的规模。 一个 ZSET 无法在 Cluster 的多个节点上分区。如果同一排行榜中有数亿玩家,你必须按分数范围或按地区进行分区,并接受全局排名仅是近似值。
- 复杂的多维排名标准。 例如分数、然后胜率、然后比赛数。在这种情况下,53 位不足以编码所有内容。
- 需要精确排名用于财务目的。 分配真实金钱的奖励应该在截止时刻从数据库重新计算。不要直接从 Redis 获取。
- 玩家很少。 数万行那么少的数据,带
(season_id, points DESC)索引的 PostgreSQL 就足够了,你还会减少一个系统要运维。
10. 结论
Redis sorted set 允许以 O(log N) 的成本进行更新、获取排名和获取前 N。这是实时排行榜的自然基础。
难点在于周围的细节:
- 将平分编码到分数中而不超过 53 位。
- 使用 Lua 脚本既只写纪录又是幂等的。
- 在 Kafka 前面放置以吸收流量突增。
- 在 RAM 中缓存前 100 以避免热键。
- 将 PostgreSQL 保持为源头数据以便随时重建 Redis。
在应用时,你应该先为 ScoreCodec 写测试。这里的位溢出错误会使整个排行榜出错,而不会给出任何错误消息。