Redisソート済みセットを使ったゲーム向けリアルタイムランキング設計

1. 概要

ランキング(leaderboard)はほぼすべてのオンラインゲームに搭載されている機能です。課題はシンプルに聞こえます:プレイヤーをスコアでソートすること。しかし、数百万のプレイヤーと毎秒数千のマッチ終了がある場合、リレーショナルデータベースのORDER BY score DESCでは対応できません。

この記事では、設計全体を解説します:要件、Redisソート済みセット上のデータモデル、API、処理フロー、そしてタイスコア処理、重複更新、データ整合性、バースト負荷といった難しい点。読み終わったら、Spring Boot 3とRedisで動作するランキングシステムを構築でき、かつその制限を理解することができます。

2. 要件の詳細

2.1. 機能要件

  • 各シーズン(season)は独立したランキングを持ちます。プレイヤーのスコアは、1つのマッチで達成した最高スコアです。
  • トップ N(通常は100)を表示。
  • 自分のランクを表示(順位が3,456,789位の場合も含む)。
  • “周辺プレイヤー”表示:上位5名と下位5名。
  • 2人のスコアが同じ場合、先にそのスコアに到達した人が上位になる。

2.2. スケールと制約

  • シーズンあたり1000万プレイヤー。
  • 平均毎秒2,000マッチ結果。ピーク時またはイベント時は20,000/秒に達することも。
  • 読み取りが書き込みより大幅に多い:トップ100画面が頻繁に呼び出される。
  • 許容表示遅延:マッチ終了後1~2秒以内。
  • マッチ結果はリトライにより複数回送信される可能性がある。重複結果はランキングに誤りをもたらしてはいけない。

3. Redis Sorted Set を選ぶ理由

Sorted set(ZSET)はmember → 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はIDの長さによって約80~120バイトを消費します。1000万プレイヤーの場合、約1GBで、単一のRedisノード内に収まります。

4. 全体的なアーキテクチャ

Game Server ──► Kafka (topic: match-results) ──► Leaderboard Consumer ──► Redis ZSET
                         │                                                  ▲
                         └──► Persist Consumer ──► PostgreSQL (ソース)      │
                                                                            │
Client ──► Leaderboard API (Spring Boot) ── RAM内のトップ100キャッシュ ────┘

3つの主要な設計決定があります:

  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. KeyとMember

lb:{s12}            → ZSET, member = playerId, score = エンコード済みスコア
lb:{s12}:dedup:<matchId>:<playerId> → STRING, 処理済み結果マーク (TTL 1日)

{s12}部分はハッシュタグです。Redis Clusterでは、同じハッシュタグを持つすべてのキーが同じスロットに配置されます。これは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 out of range: " + points);
        }
        if (secondsSinceSeasonStart < 0 || secondsSinceSeasonStart > MAX_TIME) {
            throw new IllegalArgumentException("time out of range");
        }
        return (points << TIME_BITS) | (MAX_TIME - secondsSinceSeasonStart);
    }

    public static long points(double encoded) {
        return ((long) encoded) >>> TIME_BITS;
    }
}

関数encodeはlongを返します。double形式の誤差を避けるため、この値を文字列として整数形式でRedisに送信します。読み込み時、points()は32ビット右シフトして元のスコアを取得します。値が2^53を超えないため、(long) encodedへの変換は正確です。

注意: ゲームのスコアが200万を超える場合、時間の解像度を下げてください(例:秒ではなく分)。53ビットを絶対に超えないでください。超えるとdoubleが丸めを行い、異なるスコアを持つ2人が同じスコアになる可能性があります。

エンコーディング部分のテスト。ここが最もエラーが起こりやすいところです:

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);
    }
}

3番目のテストは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>

スコア更新には2つの要件を同時に満たす必要があります:

  1. 新しいスコアが現在のスコアより高い場合のみ書き込み。
  2. 既に処理済みのマッチ結果をスキップ。

ZSCOREその後ZADDのような2つの個別のコマンドを使用すると、2つのコンシューマが相互に割り込む可能性があります。その場合、より低いスコアがより高いスコアを上書きする可能性があります。すべてを1つの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) {}

スクリプトは3つの値を返します:

  • -1:重複。この結果は既に処理済みで、SET NXに失敗。
  • 0:新記録でない、ランキングに変更なし。
  • 1:記録を更新。

これらの戻り値を使ってメトリクスをカウントしたり、「新記録達成!」のような通知を送信できます。

注意: Redis 6.2+にはZADD key GT score memberがありスコアが大きい場合のみ書き込みます。重複排除が不要な場合、このコマンドをスクリプトの代わりに使用できます。ここではスクリプトが必要です。なぜなら重複チェックをアトミック操作に統合する必要があるからです。

ゲームルールが最高スコアではなくスコアの累積(ZINCRBY)の場合、冪等性は必須になります。理由:2回加算すると誤りになるからです。その場合、複合スコアの時間部分も各加算時に更新する必要があり、スクリプトはより複雑になります。

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の2つのコマンドはアトミックではありません。2つのコマンド間でランキングが若干変更される可能性があります。ランキング画面の場合、数百ミリ秒の誤差は許容範囲です。完全な正確性が必要な場合は、Luaスクリプトに統合してください。

8. 難しい点とその対処方法

8.1. トップ100のホットキー

すべてのクライアントがトップ100を呼び出し、すべて1つのkeyが1つの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リクエストのみ受け取ります。例ではシーズンがハードコードされています。実際には、現在のシーズンを設定から取得する必要があります。

8.2. 書き込みバースト負荷

イベントが終了すると、数万のマッチが同時に終了する可能性があります。Kafkaはバッファの役割を果たします。Consumerはバッチで処理し、パイプラインを使用してコマンドを送信し、ネットワークラウンドトリップ数を削減します:

@KafkaListener(topics = "match-results", batch = "true", groupId = "leaderboard")
public void onBatch(List<MatchResult> results) {
    results.forEach(writer::submit);
}

さらに速く実行する必要がある場合は、execute呼び出しをredis.executePipelined(...)にまとめることができます。またトピックをplayerIdでパーティション分割しています。これにより、1人のプレイヤーのすべての結果が同じパーティションで順序良く処理され、競合が減ります。Luaスクリプトは競合がある場合でも正確性を保ちます。

Redisが遅い場合、コンシューマーラグが増加しますが、ゲームサーバーは影響を受けません。プレイヤーはランキングの更新が数秒遅れるだけです。

8.3. RedisとデータベースのConsistency

Redisはfsyncする前にノードが死ぬとデータを失う可能性があります。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のmember数がデータベースと異なる場合、検出できます。

8.4. 日別・週別ランキングとクリーンアップ

日別ランキングはlb:{s12}:d:2026-10-12キーを使用し、EXPIRE 3日を設定します。週別ランキングはZUNIONSTORE ... AGGREGATE MAXを使用して日別ランキングから計算できます。シーズン終了時、報酬配布用にトップ1000をデータベースにキャプチャーし、DELではなくUNLINKを使用してキーを削除しRedisをブロックしません。

8.5. フレンドランキング

友人リストが約200人の場合、ZMSCORE lb:{s12} id1 id2 ...を使用して1つのコマンドですべてのスコアを取得し、Javaでソートします。各ユーザー用に個別のZSETを作成する必要はありません。

9. この設計を使用すべき場合ではないとき

  • スケールが単一ノードを超える場合。 1つのZSETはCluster内の複数ノードに分割できません。同じランキングに数億プレイヤーがいる場合、スコア範囲または地域で分割し、グローバルランクが概算であることを受け入れる必要があります。

  • 複雑な多次元ランキング基準。 例:スコア、その後勝率、その後マッチ数。この場合、53ビットはすべてをエンコードするのに不十分です。

  • 財務目的で正確なランキングが必要。 リアルマネー報酬は確定時にデータベースから再計算する必要があります。Redisから直接取得しないでください。

  • プレイヤー数が少ない。 数万行であれば、インデックス(season_id, points DESC)を持つPostgreSQLで十分で、運用するシステムが1つ減ります。

10. 結論

Redis Sorted Setは、O(log N)のコストでスコア更新、ランク取得、トップNの取得を可能にします。これはリアルタイムランキングの自然な基盤です。

難しい部分は周囲の詳細にあります:

  • スコアにタイスコアをエンコードし、53ビットを超えない。

  • Luaスクリプトを使用して記録更新と冪等性の両方を実現。

  • バースト負荷を吸収するためにKafkaを先頭に配置。

  • ホットキーを避けるためにRAM内にトップ100をキャッシュ。

  • PostgreSQLをソース・オブ・トゥルースとして保ち、いつでもRedisを再構築できるようにする。

実装時、最初にScoreCodecのテストを書いてください。ここでのビットオーバーフロー誤差は、エラー通知なしにランキング全体を破損させます。

redisシステム設計spring-bootランキングkafka