Design a game leaderboard on Redis Sorted Sets: how do you record a score change, fetch the top ten, and show one player their own position and the players around them?
answer
- ZINCRBY = accumulate, ZADD GT = personal best
- top N: ZRANGE 0 9 REV WITHSCORES
- ZREVRANK 0-based → +1 to display
- nil rank = unranked player, not rank 0
- per-period keys + TTL, ZUNIONSTORE to roll up
basics
~20 sStore one Sorted Set of player→score. Record with ZINCRBY for deltas or ZADD GT for high-water marks. Top ten is ZRANGE key 0 9 REV WITHSCORES. A player's position is ZREVRANK (0-based, add 1 for display), and neighbours are a small ZRANGE REV around that rank.
solid answer
~60 s**One key, one Sorted Set**: member = player id, score = points. - **Record**: `ZINCRBY lb <delta> <player>` when scores accumulate; `ZADD lb GT <score> <player>` when the leaderboard is a personal best, since `GT` only raises the score and makes retried or out-of-order writes harmless. - **Top ten**: `ZRANGE lb 0 9 REV WITHSCORES` — O(log N + 10), cheap at any size. - **My position**: `ZREVRANK lb <player>` (0-based → add 1 for display) plus `ZSCORE lb <player>`; both O(log N), so exact global rank stays fast even with millions of players. - **Around me**: take `r = ZREVRANK`, then `ZRANGE lb max(0, r-2) r+2 REV WITHSCORES`. **Ties**: equal scores are ordered by member bytes, so two players with the same points get different ranks, deterministically but arbitrarily. If ties must be broken meaningfully, encode a tiebreaker into the score (for example `points` plus a small inverted-timestamp fraction) while staying inside double precision. **Time windows**: use per-period keys (`lb:2026-08`) with a TTL, and build all-time or weekly boards with `ZUNIONSTORE`.
code
text · 22 lines# accumulate points
> ZINCRBY lb:2026-08 25 player:42
"325"
# personal-best board: never lowers an existing score, retry-safe
> ZADD lb:alltime GT CH 980 player:42
(integer) 1 # CH => 1 means it actually changed
# top 10 with scores
> ZRANGE lb:2026-08 0 9 REV WITHSCORES
# my rank (0-based) and score
> ZREVRANK lb:2026-08 player:42
(integer) 40 # display as 41st
> ZSCORE lb:2026-08 player:42
"325"
# neighbours: ranks 38..42 around me
> ZRANGE lb:2026-08 38 42 REV WITHSCORES
# expire the monthly board
> EXPIRE lb:2026-08 5184000go deeper
Name the key layout and the three queries — ZINCRBY or ZADD to write, ZRANGE with REV for the top ten, ZREVRANK for a player's position — and remember ranks are 0-based.
Explain when ZINCRBY versus ZADD GT is correct, give the complexities, handle the unranked-player nil case, and describe per-period keys with TTLs.
Add tie semantics and composite-score packing within double precision, the neighbours query in a single round trip, replica-served reads for stale-tolerant views, and the cost of ZUNIONSTORE rollups.
Discuss the shape of the system: write amplification per scoring event, a global board as an unsplittable hot key, sharded or cohort boards merged client-side, and which ranking guarantees the product actually needs versus what the structure gives for free.
## The core design A leaderboard is the textbook Sorted Set: `member = playerId`, `score = points`. One key holds the entire board and Redis keeps it ordered at all times, so there is no "rebuild the ranking" step. ### Recording a score Two different semantics, two different commands: - **Accumulating points** — `ZINCRBY lb 25 player:42`. Atomic, creates the member at 25 if absent, returns the new total. No read-modify-write, so concurrent awards cannot lose updates. - **Personal best / high-water mark** — `ZADD lb GT 980 player:42`. `GT` (Redis 6.2+) updates only if the new score is *greater* than the stored one, and still inserts a new member. This makes the write **idempotent and order-independent**: a retried request, or an out-of-order delivery of an older match result, cannot lower someone's best score. Doing this with a read-then-compare in the client is a race; `GT` removes it entirely. Add `CH` if you need to know whether anything changed (by default `ZADD` returns only the count of *newly added* members, so an update returns 0). ### Top N `ZRANGE lb 0 9 REV WITHSCORES` returns the top ten with their scores. On Redis before 6.2 this is `ZREVRANGE lb 0 9 WITHSCORES`. Cost is O(log N + M) where M is the ten elements you asked for — the size of the board barely matters. This is the query that makes Sorted Sets feel magical: a top-ten over fifty million players is microseconds. ### One player's rank `ZREVRANK lb player:42` gives the 0-based position from the top. It is O(log N) because the ordered structure keeps subtree sizes, so Redis can count how many members precede a position without walking them. Two practical points: - **Add one for display.** Rank 0 is "1st". This off-by-one is the most common bug in leaderboard code. - **Absent player returns nil**, not 0. Unranked players must be handled explicitly, or you will show "rank 1" to someone who has never played. Fetch the score in the same round trip with `ZSCORE`, or pipeline the two calls. ### Neighbours ("players around me") Two commands: `r = ZREVRANK lb player`, then `ZRANGE lb (r-2) (r+2) REV WITHSCORES`, clamping the lower bound at 0. This is the standard "you are 41st, here are 39th–43rd" widget, and it is cheap because the window is tiny. Pipelining or a two-line Lua script makes it a single round trip and removes the (harmless, but visible) window where the rank shifts between the two calls. ## Ties Equal scores are ordered by the member string's byte order. That is deterministic — every replica agrees — but it is arbitrary from a product perspective: `player:1000` beats `player:999` because `1` sorts before `9`, and "1000" as a string sorts before "999". Also note Redis produces **competition-free unique ranks**: there is no notion of "joint 3rd". If the product wants dense or joint ranking, you compute it yourself from scores. The usual fix is a **composite score**: combine the real score with a tiebreaker in one double. For example `score * 1e6 + (maxTs - eventTs)/1000` so that, among equal points, the earlier achiever ranks higher. The hard constraint is that a double holds integers exactly only up to 2^53 (~9×10^15); pack more than that and low bits vanish silently, producing ties you did not intend. Budget the bits before choosing a multiplier. ## Time-windowed boards Product almost always wants "this week" and "all time". Use one key per period — `lb:daily:2026-08-14`, `lb:weekly:2026-W33` — written at the same time as the all-time key, each with an `EXPIRE` sized to how long the period stays visible. Rolling aggregates (last 7 days) are `ZUNIONSTORE weekly 7 lb:daily:...` with optional `WEIGHTS`; run it off-peak because a union over large boards is a single long command that stalls the server for its duration. ## Reads, writes and scale Writes are O(log N) and every one of them replicates, so a board updated on every game event is a write-throughput question as much as a data-structure one — batch or debounce if the same player scores many times per second. Reads (top N, my rank, neighbours) are all cheap and read-only, so they can be served from replicas if slightly stale ranks are acceptable, which most leaderboards tolerate. The one thing you cannot do is split a single Sorted Set across cluster nodes: a leaderboard key lives on one node, and a very hot global board becomes a hot key. Per-region or per-cohort boards merged client-side are the standard escape hatch. ## What to say in an interview Name the key layout, the two write commands and *why* `GT` beats a client-side compare, the three read queries with their complexities, the 0-based rank pitfall, the tie semantics, and per-period keys with TTLs. That is a complete, production-shaped answer in under two minutes.
- Why prefer ZADD with the GT flag over reading the current score and conditionally writing it?A read followed by a conditional write is a check-then-act race: two concurrent updates can both read the old value and the lower one can win, silently erasing a better score. GT pushes the comparison into the single atomic command, so the stored score can only move upward regardless of ordering or concurrency. It also makes the write idempotent, which means a retried request after a timeout is harmless — an important property when the client cannot tell whether its first attempt landed.
- Two players have identical scores. What rank does each get, and how would you make ties meaningful?Redis breaks the tie by comparing the member strings byte-wise, so both get distinct ranks in a deterministic but product-meaningless order — there is no joint or dense ranking. To make ties meaningful you fold a tiebreaker into the score itself, for example multiplying the real score and subtracting a normalised timestamp so the earlier achiever ranks higher. The constraint is double precision: integers are exact only to 2^53, so the packing must fit within that budget or low-order bits disappear.
- How do you support both a monthly and an all-time leaderboard without doubling your write logic?Write both keys in the same pipeline on each scoring event — a per-period key such as lb:2026-08 with an EXPIRE covering how long that period stays visible, and the all-time key with no expiry. Rolling windows like 'last 7 days' are then built from daily keys with ZUNIONSTORE on a schedule rather than per request, because a union over large boards is one long blocking command. The write path stays a fixed, small number of O(log N) commands.
saying these in an interview costs you the question
- Reporting ZREVRANK directly as the display rank, producing an off-by-one
- Treating a nil rank for an unknown player as rank 0 or first place
- Reading the current score then writing a higher one from the client instead of using ZADD GT
- Expecting players with equal scores to receive the same (joint) rank
- Packing a score plus timestamp into one double without checking the 2^53 exact-integer limit