Leaderboards mit Sorted Sets bauen
AI generated
SET
TTL
Redis · Gaming-Backend · Datenstrukturen · Backend
Leaderboards mit Sorted Sets bauen
von ZADD bis zur paginierten Rangliste

Eine Rangliste mit tausenden Spielern klingt nach einem Sortier- und Skalierungsproblem, ist mit Redis Sorted Sets aber eine Datenstruktur, die genau dafuer gebaut wurde. ZADD, ZRANK und ZREVRANGE liefern Ranking, Umgebungsabfragen und Top-N-Listen in logarithmischer Zeit, ohne dass die Anwendung selbst sortieren oder cachen muss.

17 Min. Lesezeit ZADD · ZRANK · ZREVRANGE · Paginierung Redis 6.x · 7.x

1. Warum Sorted Sets fuer Leaderboards

Ein Leaderboard muss drei Dinge gleichzeitig gut koennen: neue Punktestaende schnell einsortieren, den Rang eines beliebigen Spielers in konstanter oder logarithmischer Zeit ermitteln, und die Top-N-Spieler ohne vollstaendige Sortierung des gesamten Datensatzes ausgeben. Eine relationale Datenbank mit ORDER BY score DESC LIMIT n und einem Index auf der Score-Spalte funktioniert fuer kleine Datenmengen, wird aber bei haeufigen Schreibvorgaengen und Millionen Spielern schnell zum Flaschenhals, weil jede Rangabfrage effektiv eine Zaehlung ueber den Index erfordert.

Redis Sorted Sets loesen genau dieses Problem, weil sie intern als Skip List implementiert sind, eine Datenstruktur, die Einfuegen, Loeschen und Rangabfragen alle in O(log n) durchfuehrt. Jedes Mitglied eines Sorted Sets hat einen numerischen Score, und Redis haelt die Menge automatisch nach Score sortiert. Fuer ein Leaderboard bedeutet das: ZADD aktualisiert einen Punktestand, ZRANK liefert sofort den aktuellen Rang, ZREVRANGE liefert die Top-N-Spieler, alles ohne separate Sortierlogik in der Anwendung.

Dieser Artikel baut ein produktionsreifes Leaderboard-System mit Sorted Sets auf, von den Grundoperationen ueber Tie-Breaking und Paginierung bis zu zeitbasierten Ranglisten, die sich taeglich oder woechentlich zuruecksetzen.

2. ZADD Grundlagen und Score-Design

Die Grundoperation fuer jedes Leaderboard ist ZADD leaderboard:global score member. Wird derselbe Member erneut hinzugefuegt, aktualisiert Redis lediglich seinen Score, statt einen Duplikateintrag zu erzeugen, was ZADD zur natuerlichen Wahl fuer sich staendig aendernde Punktestaende macht. Fuer Punktezuwachs statt absoluter Neusetzung eignet sich ZINCRBY leaderboard:global 10 player:42, das den bestehenden Score um einen Wert erhoeht, ohne dass die Anwendung den aktuellen Stand vorher lesen muss.

Das Score-Design entscheidet ueber die Qualitaet des Leaderboards. Ein einfacher Integer-Score reicht fuer viele Faelle, aber bei Gleichstaenden sortiert Redis standardmaessig lexikografisch nach dem Member-Namen, was selten das gewuenschte Verhalten ist. Eine gaengige Technik kodiert Sekundaerkriterien direkt in den Score, etwa indem der Zeitstempel des Erreichens invers in die niederwertigen Bits eingerechnet wird, sodass bei gleichem Hauptscore der fruehere Spieler automatisch vorne liegt, ganz ohne zusaetzliche Anwendungslogik.


# Basic leaderboard operations
redis-cli> ZADD leaderboard:global 15420 "player:42"
(integer) 1
redis-cli> ZADD leaderboard:global 18990 "player:17"
(integer) 1

# Incrementing a score without reading it first
redis-cli> ZINCRBY leaderboard:global 250 "player:42"
"15670"

# Current member count and score lookup
redis-cli> ZCARD leaderboard:global
(integer) 2
redis-cli> ZSCORE leaderboard:global "player:42"
"15670"

3. Ranking mit ZRANK und ZREVRANK

ZRANK liefert den nullbasierten Rang eines Members in aufsteigender Score-Reihenfolge, ZREVRANK in absteigender Reihenfolge, was fuer die meisten Leaderboards die relevante Richtung ist, weil der hoechste Score Platz eins bedeuten soll. Beide Operationen laufen in O(log n), unabhaengig davon, ob das Sorted Set hundert oder zehn Millionen Mitglieder enthaelt, was den entscheidenden Vorteil gegenueber einer naiven Sortierung in der Anwendungsschicht ausmacht.

Fuer die Anzeige eines lesbaren Rangs muss die Anwendung lediglich eins zum nullbasierten Ergebnis addieren. Ein haeufiger Fehler ist, ZREVRANK bei jedem Seitenaufruf fuer alle sichtbaren Spieler einzeln aufzurufen, statt die Rang-Information direkt aus einer ZREVRANGE-Abfrage mit WITHSCORES zu extrahieren, die Position und Score in einem einzigen Roundtrip liefert. Bei Leaderboards mit hoher Anfragefrequenz macht dieser Unterschied den Unterschied zwischen einem Redis-Aufruf und Dutzenden pro Seitenaufruf.

4. Top-N mit ZREVRANGE WITHSCORES

Die Top-N-Abfrage ist die haeufigste Operation auf einem Leaderboard und mit ZREVRANGE leaderboard:global 0 9 WITHSCORES in einem einzigen Redis-Aufruf erledigt: die zehn besten Spieler samt ihrer Scores, bereits korrekt sortiert. Da diese Abfrage in O(log n + m) laeuft, wobei m die Anzahl der zurueckgegebenen Elemente ist, bleibt sie auch bei Millionen Spielern im Sorted Set schnell, weil nur der angefragte Ausschnitt tatsaechlich durchlaufen wird, nicht die gesamte Struktur.

In neueren Redis-Versionen ist ZRANGE leaderboard:global 0 9 REV WITHSCORES die empfohlene, vereinheitlichte Syntax, die ZREVRANGE langfristig ablösen soll, aber funktional identisch ist. Fuer ein Leaderboard-Frontend, das die Top-10-Liste alle paar Sekunden aktualisiert, reicht diese einzelne Operation vollstaendig aus, ganz ohne zusaetzliches Caching der Ergebnisliste, weil Redis selbst bereits schneller antwortet als jeder externe Cache-Layer.


<?php
declare(strict_types=1);

final class Leaderboard
{
    public function __construct(private readonly \Redis $redis, private readonly string $key) {
    }

    /** Returns the top N players with rank, member and score. */
    public function topN(int $limit): array
    {
        $raw = $this->redis->zRevRange($this->key, 0, $limit - 1, true);

        $result = [];
        $rank = 1;
        foreach ($raw as $member => $score) {
            $result[] = ['rank' => $rank++, 'player' => $member, 'score' => (int) $score];
        }
        return $result;
    }

    /** Returns a single player's rank (1-based) and score, or null if absent. */
    public function playerRank(string $member): ?array
    {
        $rank = $this->redis->zRevRank($this->key, $member);
        if ($rank === false) {
            return null;
        }
        return ['rank' => $rank + 1, 'score' => (int) $this->redis->zScore($this->key, $member)];
    }
}

5. Gleichstaende und Tie-Breaking

Ohne explizite Behandlung sortiert Redis Gleichstaende in einem Leaderboard nach der lexikografischen Ordnung des Member-Strings, was fuer Spielernamen praktisch beliebig wirkt und Nutzer verwirrt, wenn sich die Reihenfolge zweier Spieler mit identischem Score von Aktualisierung zu Aktualisierung unvorhersehbar aendert. Eine robuste Loesung kodiert ein Sekundaerkriterium direkt in den Score, meist der Zeitpunkt des Erreichens, sodass Spieler mit gleichem Hauptscore nach Erreichungszeitpunkt sortiert werden, ueblicherweise der frueher erreichte Score vorne.

Die technische Umsetzung nutzt Fliesskomma-Scores: der ganzzahlige Hauptscore bildet die Vorkommastellen, ein invertierter, normalisierter Zeitstempel die Nachkommastellen. Ein Beispiel: Score 15670 erreicht um Zeitstempel 1732000000 wird zu 15670.0000001732000000Invers, sodass ein frueherer Zeitstempel einen kleineren Bruchteil und damit bei ZREVRANGE einen hoeheren effektiven Rang erhaelt. Diese Technik vermeidet komplett zusaetzliche Datenstrukturen fuer Tie-Breaking und haelt das Leaderboard auf einer einzigen Sorted-Set-Operation.

Ansatz fuer Tie-Breaking Komplexitaet Vorteil Nachteil
Keine Behandlung Trivial Kein Zusatzaufwand Unvorhersehbare Reihenfolge
Zeitstempel im Score kodiert Niedrig Ein einziger Sorted-Set-Zugriff Score-Praezision begrenzt
Separates Sorted Set fuer Tiebreak Hoch Volle Praezision beider Kriterien Zwei Strukturen synchron halten

6. Paginierung grosser Leaderboards

Fuer die Anzeige jenseits der Top-10 muss ein Leaderboard paginierbar sein, ohne bei jeder Seite alle vorherigen Eintraege erneut zu uebertragen. ZREVRANGE leaderboard:global 0 9 WITHSCORES liefert Seite eins, ZREVRANGE leaderboard:global 10 19 WITHSCORES liefert Seite zwei, wobei Start- und Endindex direkt aus der Seitennummer und der Seitengroesse berechnet werden. Diese Offset-basierte Paginierung ist fuer die meisten Leaderboards ausreichend, weil Nutzer selten ueber viele hundert Seiten navigieren.

Bei sehr grossen Ranglisten mit haeufigen Score-Aenderungen kann Offset-Paginierung zu leicht inkonsistenten Ergebnissen fuehren, wenn sich zwischen zwei Seitenaufrufen die Rangfolge verschiebt und ein Spieler doppelt erscheint oder uebersprungen wird. Fuer die allermeisten Leaderboard-Anwendungen ist diese seltene Inkonsistenz akzeptabel, da eine Rangliste ohnehin ein Snapshot eines sich staendig aendernden Zustands ist und absolute Konsistenz zwischen zwei Requests kein realistisches Ziel darstellt.


# Pagination: page_size = 10, page = 3 (zero-indexed)
# start = page * page_size, stop = start + page_size - 1
redis-cli> ZREVRANGE leaderboard:global 20 29 WITHSCORES

# Total pages for UI pagination controls
redis-cli> ZCARD leaderboard:global
(integer) 48213
# total_pages = ceil(48213 / 10) = 4822

7. Nachbarschaftsabfragen um einen Spieler

Nutzer interessieren sich meist weniger fuer die absolute Weltspitze als fuer ihre eigene Position und die Spieler direkt darueber und darunter. Diese Nachbarschaftsabfrage kombiniert ZREVRANK, um den eigenen Rang zu ermitteln, mit ZREVRANGE um einen berechneten Bereich um diesen Rang herum. Fuer einen Spieler auf Rang 4523 mit einer gewuenschten Umgebung von je fuenf Plaetzen wird ZREVRANGE leaderboard:global 4517 4527 WITHSCORES aufgerufen, was den Spieler mittig in seiner lokalen Umgebung zeigt.

Dieses Muster macht ein Leaderboard fuer Endnutzer erst wirklich relevant, weil "Platz 4523 von 48213" allein wenig Motivation erzeugt, waehrend "noch drei Plaetze bis zum naechsten Rang" konkrete, handlungsleitende Information liefert. Die Kombination aus zwei Redis-Aufruefen, ZREVRANK gefolgt von ZREVRANGE, bleibt dabei bei jeder Groesse des Leaderboards in konstanter praktischer Antwortzeit, weil beide Operationen logarithmisch beziehungsweise linear in der kleinen Ergebnismenge skalieren.


# Neighborhood query: rank of player:4523, then 5 places above and below
redis-cli> ZREVRANK leaderboard:global "player:4523"
(integer) 4522

redis-cli> ZREVRANGE leaderboard:global 4517 4527 WITHSCORES
 1) "player:4501"
 2) "89210"
 3) "player:4502"
 4) "89180"
 ...
11) "player:4523"
12) "88760"

8. Zeitbasierte Leaderboards mit Rotation

Viele Anwendungen brauchen nicht nur ein globales Leaderboard, sondern auch taegliche und woechentliche Ranglisten, die regelmaessig zuruecksetzen. Der einfachste Ansatz erzeugt pro Zeitraum einen eigenen Schluessel, etwa leaderboard:daily:2026-07-23, und schreibt bei jedem Score-Ereignis parallel in das globale und das zeitraumspezifische Sorted Set. Fuer automatisches Aufraeumen erhaelt der zeitraumspezifische Schluessel eine TTL, die deutlich laenger ist als der Zeitraum selbst, damit auch verspaetete Auswertungen noch funktionieren, aber alte Ranglisten irgendwann automatisch verschwinden.

Fuer woechentliche Ranglisten bietet sich der ISO-Wochenschluessel an, etwa leaderboard:weekly:2026-W30, was Kalenderberechnung in der Anwendung vermeidet und gleichzeitig eindeutig und sortierbar bleibt. Ein Leaderboard-System mit mehreren Zeitraeumen parallel zu pflegen erhoeht die Schreiblast pro Ereignis um die Anzahl der aktiven Zeitraeume, bleibt aber selbst bei drei oder vier parallelen Ranglisten, etwa taeglich, woechentlich, monatlich und global, in der Praxis unproblematisch, weil jede ZADD-Operation selbst bei Millionen Mitgliedern nur wenige Mikrosekunden benoetigt.


-- update_score.lua
-- Writes into both the global and the daily rotating leaderboard atomically
-- KEYS[1] = global key, KEYS[2] = daily key, ARGV[1] = member, ARGV[2] = points
redis.call('ZINCRBY', KEYS[1], ARGV[2], ARGV[1])
redis.call('ZINCRBY', KEYS[2], ARGV[2], ARGV[1])
redis.call('EXPIRE', KEYS[2], 172800) -- keep daily board for 2 days
return redis.call('ZSCORE', KEYS[1], ARGV[1])

9. Skalierung und Speicherverbrauch

Ein Sorted Set mit einer Million Mitgliedern belegt in Redis, abhaengig von der Laenge der Member-Strings, typischerweise mehrere zehn bis hundert Megabyte, was fuer die meisten Leaderboards problemlos in den Arbeitsspeicher eines einzelnen Redis-Knotens passt. Bei extrem grossen Ranglisten mit zig Millionen Spielern, etwa bei einem globalen Mobile-Game, lohnt es sich, den Member-String auf eine kompakte numerische Spieler-ID statt eines langen Klarnamens zu reduzieren und den Klarnamen separat in einem Hash oder einer Datenbank zu halten, um den Speicherverbrauch des Sorted Sets selbst zu minimieren.

Fuer horizontale Skalierung ueber einen einzelnen Redis-Knoten hinaus laesst sich ein Leaderboard nach Region oder Spielmodus in mehrere Sorted Sets aufteilen, die auf verschiedenen Shards eines Redis Clusters liegen koennen. Ein global aggregiertes Leaderboard ueber alle Regionen hinweg erfordert dann entweder ein periodisches Merge-Verfahren mit ZUNIONSTORE in ein separates Sorted Set, oder den bewussten Verzicht auf ein einzelnes globales Leaderboard zugunsten mehrerer regionaler Ranglisten, was bei sehr grossen Spielerzahlen ohnehin oft die nutzerfreundlichere Wahl ist.

10. Zusammenfassung

Leaderboards mit Sorted Sets nutzen eine Datenstruktur, die fuer genau dieses Problem entworfen wurde: ZADD und ZINCRBY fuer Score-Updates, ZRANK und ZREVRANK fuer Ranganfragen, ZREVRANGE mit WITHSCORES fuer Top-N-Listen und Paginierung, alles in logarithmischer Zeit unabhaengig von der Groesse der Rangliste. Gleichstaende lassen sich elegant durch Kodierung eines Sekundaerkriteriums direkt im Score aufloesen, ohne zusaetzliche Datenstrukturen zu benoetigen.

Nachbarschaftsabfragen um die eigene Position machen ein Leaderboard fuer Endnutzer motivierend, waehrend zeitbasierte Varianten mit eigenen Schluesseln pro Zeitraum taegliche und woechentliche Ranglisten parallel zum globalen Leaderboard ermoeglichen. Bei sehr grossen Spielerzahlen zahlt sich kompaktes Member-Design und die Aufteilung nach Region oder Spielmodus fuer horizontale Skalierung aus.

Leaderboards mit Sorted Sets, das Wichtigste auf einen Blick

Grundoperationen

ZADD und ZINCRBY fuer Scores, ZRANK/ZREVRANK und ZREVRANGE fuer Ranking, alles in O(log n).

Tie-Breaking

Sekundaerkriterium wie Zeitstempel direkt in den Score kodieren, kein separates Datenmodell noetig.

Paginierung

Offset-basiert mit ZREVRANGE, Nachbarschaftsabfragen ueber ZREVRANK plus umliegenden Bereich.

Zeitbasierte Ranglisten

Eigener Schluessel pro Zeitraum mit TTL, parallele Schreibvorgaenge in globales und periodisches Set.

11. FAQ: Leaderboards mit Sorted Sets

1Warum Sorted Sets statt SQL?
Skip-List-Implementierung ermoeglicht O(log n) fuer Einfuegen, Loeschen und Rangabfragen.
2Wie aktualisiere ich einen Score effizient?
ZINCRBY erhoeht den Score atomar, ohne vorheriges Lesen des aktuellen Wertes.
3Wie loese ich Gleichstaende auf?
Sekundaerkriterium wie Zeitstempel direkt in den Score kodieren.
4Wie paginiere ich effizient?
ZREVRANGE mit berechnetem Start- und Endindex je Seite.
5Wie zeige ich die Nachbarschaft eines Spielers?
ZREVRANK ermittelt den Rang, ZREVRANGE liefert den umliegenden Bereich.
6Wie funktionieren zeitbasierte Ranglisten?
Eigener Schluessel pro Zeitraum mit TTL fuer automatisches Aufraeumen.
7Wie viel Speicher braucht eine Million Spieler?
Typischerweise mehrere zehn bis hundert Megabyte je nach Member-Laenge.
8Wie skaliert das ueber mehrere Knoten?
Aufteilung nach Region oder Modus, optional Merge via ZUNIONSTORE.
9ZREVRANGE oder ZRANGE REV?
Funktional identisch, ZRANGE REV ist die neuere, vereinheitlichte Syntax.
10Namen oder IDs als Member speichern?
Kompakte IDs sparen Speicher, Klarnamen gehoeren in einen separaten Hash.