使用 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 ────────────┘

有三个关键决定:

  1. PostgreSQL 是源头数据。 Redis 只是用于快速读取的索引。如果丢失 Redis,你可以从数据库重建。
  2. Kafka 处于中间 以吸收流量突增。当 Redis 忙时,游戏服务器不会变慢。
  3. 读 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>

更新分数有两个要求必须同时满足:

  1. 仅当新分数高于当前分数时才写入。
  2. 忽略已处理的比赛结果。

如果用两个单独的命令如 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 写测试。这里的位溢出错误会使整个排行榜出错,而不会给出任何错误消息。

redissystem-designspring-bootleaderboardkafka