Echtzeitranking für Spiele mit Redis Sorted Set entwerfen

1. Überblick

Leaderboards (Ranglisten) erscheinen in den meisten Online-Spielen. Das Problem klingt einfach: Spieler nach Punkten sortieren. Mit Millionen von Spielern und Tausenden beendeter Spiele pro Sekunde ist ORDER BY score DESC auf einer relationalen Datenbank nicht mehr leistbar.

Dieser Artikel führt Sie durch das gesamte Design: Anforderungen, Datenmodell auf Redis Sorted Set, API, Verarbeitungsfluss und schwierige Punkte wie Punktegleichstände, doppelte Updates, Datenkonsistenz und Burst-Last. Nach dem Lesen können Sie ein funktionierendes Leaderboard mit Spring Boot 3 und Redis aufbauen und dessen Grenzen verstehen.

2. Anforderungen

2.1. Funktionale Anforderungen

  • Jede Season hat ein eigenes Leaderboard. Die Punkte eines Spielers sind die höchsten Punkte, die in einem Spiel erreicht wurden.
  • Top N anzeigen (normalerweise 100).
  • Seine eigene Rangfolge anzeigen, auch wenn er auf Platz 3.456.789 steht.
  • “Rund um mich anzeigen”: 5 Plätze oben und 5 Plätze unten.
  • Wenn zwei Spieler die gleiche Punktzahl haben, steht wer diese Punktzahl zuerst erreicht hat, höher.

2.2. Skalierung und Einschränkungen

  • 10 Millionen Spieler pro Season.
  • Durchschnittlich 2.000 Spielergebnisse pro Sekunde. Zu Stoßzeiten oder bei Ereignissen kann es auf 20.000/Sekunde ansteigen.
  • Lesevorgänge überwiegen Schreibvorgänge deutlich: Top-100-Bildschirm wird ständig aufgerufen.
  • Akzeptable Anzeige-Latenz: unter 1–2 Sekunden nach dem Ende eines Spiels.
  • Ein Spielergebnis kann aufgrund von Wiederholungen mehrmals gesendet werden. Doppelte Ergebnisse dürfen das Leaderboard nicht verfälschen.

3. Warum Redis Sorted Set wählen

Sorted Set (ZSET) speichert Paare von member → score und behält die Sortierung nach Score bei. Intern verwendet es eine Skip List in Kombination mit einer Hash Table, daher hat es die folgenden Merkmale:

Vorgang Befehl Komplexität
Punkte aktualisieren ZADD O(log N)
Rangfolge abrufen ZREVRANK O(log N)
Top N / Bereich abrufen ZREVRANGE O(log N + M)
Punkte einer Person abrufen ZSCORE O(1)

Mit N = 10 Millionen ist log N nur etwa 23 Schritte. Vergleich mit SQL: Um den Rang einer Person zu kennen, müssen Sie die Anzahl der Zeilen mit höherem Score zählen. Selbst mit einem Index ist diese Abfrage bei großem Maßstab teuer.

Was den Speicher angeht, braucht jeder Member etwa 80–120 Byte, je nach Länge der ID. 10 Millionen Spieler passen in etwa 1 GB, passt immer noch in einen einzelnen Redis-Node.

4. Gesamtarchitektur

Game Server ──► Kafka (topic: match-results) ──► Leaderboard Consumer ──► Redis ZSET
                         │                                                  ▲
                         └──► Persist Consumer ──► PostgreSQL (source of truth)   │
                                                                            │
Client ──► Leaderboard API (Spring Boot) ── cache top 100 in RAM ────────┘

Es gibt drei wesentliche Entscheidungen:

  1. PostgreSQL ist die Quelle der Wahrheit. Redis ist nur ein Index für schnelle Lesezugriffe. Falls Redis verloren geht, können Sie es aus der Datenbank neu aufbauen.
  2. Kafka sitzt dazwischen, um Burst-Last zu absorbieren. Der Game Server wird nicht verlangsamt, wenn Redis beschäftigt ist.
  3. Die Lese-API ist vom Schreib-Stream getrennt. Top 100 wird kurzfristig in RAM jeder Instanz zwischengespeichert.

Die Tabelle speichert Spielergebnisse in 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);

Der Primärschlüssel (match_id, player_id) macht wiederholte Schreibvorgänge harmlos: Sie brauchen nur INSERT ... ON CONFLICT DO NOTHING.

5. Datenmodell auf Redis

5.1. Keys und Member

lb:{s12}            → ZSET, member = playerId, score = codierter Score
lb:{s12}:dedup:<matchId>:<playerId> → STRING, markiert verarbeitetes Ergebnis (TTL 1 Tag)

Der Teil {s12} ist ein Hash Tag. In Redis Cluster befinden sich alle Keys mit demselben Hash Tag in demselben Slot. Dies ist erforderlich, wenn Sie sie zusammen in einem Lua-Skript verwenden möchten.

5.2. Handling von Ties mit zusammengesetztem Score

Redis sortiert Member mit demselben Score lexikografisch nach Member. Diese Sortierart hat für ein Spiel keine Bedeutung. Wir brauchen “wer zuerst erreicht hat, wird höher sortiert”, also müssen wir Zeit in den Score einbetten.

Scores in Redis sind double, die alle ganzen Zahlen bis 2^53 exakt darstellen. Wir teilen 53 Bits wie folgt auf:

  • 21 höherwertige Bits: Score (maximal etwa 2 Millionen).
  • 32 niederwertige Bits: MAX_TIME - Sekunden seit Saisonbeginn. Wer früher erreicht hat, bekommt einen größeren Wert und steht oben bei absteigender Sortierung.
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;
    }
}

Die Funktion encode gibt long zurück. Sie senden diesen Wert als Ganzzahl-String zu Redis, um Abweichungen beim Formatieren von double zu vermeiden. Beim Auslesen verschiebt points() um 32 Bit nach rechts, um den ursprünglichen Score zurückzubekommen. Die Umwandlung (long) encoded ist korrekt, da der Wert 2^53 nicht übersteigt.

Hinweis: Wenn Ihr Spiel Scores größer als 2 Millionen hat, reduzieren Sie die Zeitauflösung, z. B. berechnen Sie in Minuten statt Sekunden, um mehr Bits für den Score zu haben. Gehen Sie nie über 53 Bits hinaus. Wenn Sie das überschreiten, rundet double und zwei Spieler mit unterschiedlichen Scores können denselben Wert erhalten.

Test für den Codierungsteil, da dies die fehleranfälligste Stelle ist:

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

Der dritte Test simuliert, dass Redis den Score als double zurückgibt. Dieser Test überprüft, dass wir auch bei der Höchstgrenze korrekt dekodieren.

6. Score aktualisieren: Atomic und Idempotent

Erforderliche Abhängigkeiten:

<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>

Die Score-Aktualisierung muss zwei Anforderungen gleichzeitig erfüllen:

  1. Nur schreiben, wenn der neue Score höher als der aktuelle ist.
  2. Bereits verarbeitete Spielergebnisse ignorieren.

Wenn Sie zwei separate Befehle wie ZSCORE und dann ZADD verwenden, können zwei Consumer sich gegenseitig unterbrechen. Dann kann ein niedrigerer Score einen höheren Score überschreiben. Wir kombinieren alles in ein Lua-Skript. Redis führt Skripte einfach-threaded und atomar aus.

@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) {}

Das Skript gibt drei Werte zurück:

  • -1: doppelt. SET NX schlägt fehl, weil dieses Spielergebnis bereits verarbeitet wurde.
  • 0: kein neuer Record, Leaderboard unverändert.
  • 1: Record aktualisiert.

Sie können diesen Rückgabecode verwenden, um Metriken zu zählen oder eine Benachrichtigung wie “Sie haben gerade aufgestiegen!” zu senden.

Hinweis: Redis 6.2+ hat ZADD key GT score member, um nur zu schreiben, wenn der Score höher ist. Wenn Sie keine Dedup benötigen, können Sie diesen Befehl anstelle des Skripts verwenden. Hier benötigen wir das Skript immer noch, da wir den Duplikat-Check in eine einzige atomare Operation einbinden müssen.

Wenn die Spielregel Punkte addieren ist (ZINCRBY) statt den höchsten Score zu nehmen, wird Idempotenz obligatorisch. Grund: zweimal zu addieren ist sofort falsch. Dann muss der Zeitteil des zusammengesetzten Scores bei jeder Addition aktualisiert werden, sodass das Skript komplexer wird.

7. Read 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 gibt den Rang von 0 in absteigender Reihenfolge zurück, daher ist der angezeigte Rang gleich rank + 1. Wenn der Spieler in dieser Season noch keine Punkte hat, gibt die Funktion null zurück und die API gibt eine leere Liste zurück. Das Aufrufen von /seasons/12/leaderboard/players/42/around ergibt folgendes Ergebnis:

[{"rank":3456784,"playerId":901,"points":1520},
 ...
 {"rank":3456789,"playerId":42,"points":1518},
 ...
 {"rank":3456794,"playerId":77,"points":1515}]

Die beiden Befehle reverseRank und reverseRangeWithScores sind nicht atomar zueinander. Zwischen den beiden Befehlen kann sich das Leaderboard leicht verändern. Bei einem Leaderboard-Bildschirm ist eine Abweichung von einigen hundert Millisekunden akzeptabel. Wenn Sie absolute Genauigkeit benötigen, kombinieren Sie sie in einem Lua-Skript.

8. Schwierigkeiten und Lösungen

8.1. Hot Key in Top 100

Jeder Client ruft Top 100 auf, und alle konzentrieren sich auf einen Key auf einem einzigen Redis-Node. Das Hinzufügen von Replicas hilft teilweise. Die effektivste Methode besteht jedoch darin, die Top 100 im RAM jeder API-Instanz zwischenzuspeichern und jede Sekunde zu aktualisieren:

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

Vergessen Sie nicht, @EnableScheduling zu aktivieren. Mit 50 API-Instanzen erhält Redis nur 50 Top-100-Anfragen pro Sekunde, unabhängig von der Anzahl der Millionen Clients. Im Beispiel wird die Season aus Gründen der Klarheit hartcodiert. In der Praxis sollten Sie die aktuelle Season aus der Konfiguration abrufen.

8.2. Burst-Last beim Schreiben

Wenn ein Ereignis endet, können zehntausende Spiele auf einmal enden. Kafka fungiert als Buffer. Der Consumer verarbeitet in Batches und sendet Befehle über Pipeline, um die Anzahl der Netzwerk-Roundtrips zu verringern:

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

Wenn Sie noch schneller sein müssen, können Sie die execute-Aufrufe in redis.executePipelined(...) zusammenfassen. Wir partitionieren das Thema auch nach playerId. Dadurch werden alle Ergebnisse eines Spielers sequenziell auf derselben Partition verarbeitet, was den Contention reduziert. Das Lua-Skript bleibt auch bei Contention korrekt.

Wenn Redis langsam ist, nimmt die Consumer-Verzögerung zu, aber der Game Server wird nicht beeinträchtigt. Spieler sehen nur, dass das Leaderboard um einige Sekunden verzögert wird.

8.3. Konsistenz zwischen Redis und Datenbank

Redis kann Daten verlieren, wenn ein Node stirbt, bevor es fsync durchführen kann. Da PostgreSQL die Quelle der Wahrheit ist, benötigen Sie einen Job, um das Leaderboard neu zu erstellen:

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;

Der Job schreibt die Ergebnisse in einen temporären Key wie lb:{s12}:rebuild und verwendet dann RENAME, um den echten Key atomar zu ersetzen. Benutzer werden nie ein leeres Leaderboard in der Mitte sehen. Dieser Job wird auch regelmäßig zur Abstimmung verwendet. Wenn die Anzahl der Member in Redis von der Datenbank abweicht, werden Sie es erkennen.

8.4. Leaderboards pro Tag, Woche und Bereinigung

Das tägliche Leaderboard verwendet den Key lb:{s12}:d:2026-10-12 mit EXPIRE von 3 Tagen. Das wöchentliche Leaderboard kann aus den täglichen Leaderboards mit ZUNIONSTORE ... AGGREGATE MAX zusammengefasst werden. Am Ende der Saison speichern Sie die Top 1000 in der Datenbank zur Belohnung, löschen dann die großen Keys mit UNLINK statt DEL, um Redis nicht zu blockieren.

8.5. Freundesleaderboard

Bei einer Liste von etwa 200 Freunden verwenden Sie ZMSCORE lb:{s12} id1 id2 ..., um die Punkte aller in einem Befehl zu erhalten, und sortieren dann in Java. Sie müssen kein separates ZSET für jede Person erstellen.

9. Wann Sie dieses Design NICHT verwenden sollten

  • Skalierung über einen Node hinaus. Ein ZSET kann nicht über mehrere Cluster-Nodes verteilt werden. Wenn Sie hunderte Millionen von Spielern im gleichen Leaderboard haben, müssen Sie nach Punktbereichen oder Regionen aufteilen und akzeptieren, dass der globale Rang nur eine Annäherung ist.
  • Komplexe mehrdimensionale Ranking-Kriterien. Zum Beispiel Score, dann Win-Rate, dann Anzahl der Spiele. 53 Bits reichen dann nicht aus, um alles zu codieren.
  • Genaue Rangfolge für finanzielle Zwecke erforderlich. Belohnungen mit echtem Geld sollten zum Zeitpunkt der Abwicklung aus der Datenbank neu berechnet werden. Nehmen Sie nicht direkt aus Redis.
  • Wenige Spieler. Mit einigen zehn tausend Zeilen ist PostgreSQL mit Index (season_id, points DESC) ausreichend, und Sie sparen sich ein System zum Betreiben.

10. Fazit

Redis Sorted Set ermöglicht Aktualisierungen, Rangabrufe und Top-N-Abrufe mit O(log N) Kosten. Das ist die natürliche Grundlage für Echtzeit-Leaderboards.

Die Schwierigkeit liegt in den umgebenden Details:

  • Tie-Scores in den Score-Wert codieren, ohne 53 Bits zu überschreiten.
  • Lua-Skripte verwenden, um sowohl Records als auch Idempotenz zu schreiben.
  • Kafka vorgelagert platzieren, um Burst-Last zu absorbieren.
  • Top 100 in RAM zwischenspeichern, um Hot Keys zu vermeiden.
  • PostgreSQL als Quelle der Wahrheit beibehalten, um Redis jederzeit neu zu erstellen.

Bei der Implementierung sollten Sie zuerst Tests für ScoreCodec schreiben. Ein Bit-Overflow-Fehler hier würde das gesamte Leaderboard verzerren, ohne Fehlermeldungen zu geben.

redissystem-designspring-bootleaderboardkafka