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.
Inhaltsverzeichnis
- 1. Warum Sorted Sets fuer Leaderboards
- 2. ZADD Grundlagen und Score-Design
- 3. Ranking mit ZRANK und ZREVRANK
- 4. Top-N mit ZREVRANGE WITHSCORES
- 5. Gleichstaende und Tie-Breaking
- 6. Paginierung grosser Leaderboards
- 7. Nachbarschaftsabfragen um einen Spieler
- 8. Zeitbasierte Leaderboards mit Rotation
- 9. Skalierung und Speicherverbrauch
- 10. Zusammenfassung
- 11. FAQ
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.