Design a Leaderboard
Prompt
A mobile game team wants a leaderboard. Players earn points as they play, and the game needs to show "the top players" and "where I rank." They've shipped the game already; they just bolted a database query onto it and it's falling over. Design something that holds up.
How this round runs
"Top players" and "where I rank" sound simple, but the brief hides how big, how fresh, and how the two queries differ. You drive: surface those, then design it, and I'll push you on a player's own rank at scale and make you commit to real-time vs batch and own the cost.
Model answer
1. Requirements I'd surface first.
- How fresh must it be? "Top players, real-time as I play" and "top players, refreshed hourly" are completely different systems. Real-time means every score update re-ranks; batch means I can recompute a snapshot on a schedule. I'd surface this because it's the single biggest fork.
- How many players, how many updates/sec? Say 50M registered, 5M daily-active, peak ~10k score-updates/sec. That sizes the data structure and tells me whether a "sort the table" approach is even viable (it isn't at this write rate).
- Which queries, exactly? Top-K (K=100?) and a specific player's own rank — those have very different costs and people forget the second one is the hard one.
- Global only, or also per-region / per-friends / per-season? That multiplies the number of ranked sets.
- Ties? Two players on the same score — stable order by who-got-there-first?
Non-functional: top-K read is hammered (every player opens the leaderboard screen), so it must be O(log n)-ish and cacheable; a player's own rank should resolve in low ms.
2. High-level design.
score event --> ingest API --> sorted set (Redis ZSET) keyed by leaderboard
| |
ZREVRANGE 0..K (top-K) ZREVRANK player (own rank)
|
periodic snapshot --> durable store (recovery + history)
The core structure is a sorted set (Redis ZSET — a skip list + hash). It gives
O(log n) insert/update, O(log n + K) top-K via ZREVRANGE, and — crucially —
O(log n) rank of an arbitrary member via ZREVRANK. That last one is why a
sorted set beats "keep a sorted array": an array can't answer "what's player
X's rank" without scanning. I'd back it with periodic snapshots to a durable store
for crash recovery and historical seasons.
3. Deep-dive: top-K and a player's own rank at scale. Top-K is the easy
half — ZREVRANGE leaderboard 0 99, and I'd cache the result with a short TTL
because thousands of players request the same top-100 and it changes slowly at the
head. The hard half is a specific player's own rank. ZREVRANK is O(log n)
in one ZSET — fine up to a point. But two things break it at scale:
- The set outgrows one node. 50M members in one ZSET is memory-heavy and a single hot key. Sharding by score range lets me parallelize, but then a global rank = "my rank within my shard" + "count of everyone in higher shards", which needs per-shard counts maintained as scores move across shard boundaries.
- Exact rank for tens of millions is rarely needed. Nobody ranked 4,002,118th
needs the exact integer. So I'd serve approximate rank outside the top tier —
bucket scores into a histogram and answer "you're in the top ~12%", reserving
exact
ZREVRANKfor players near the top where the precise number matters.
4. A committed trade-off and its cost. I'd commit to real-time updates
via the sorted set for the live global board, plus a batched nightly job for
historical/seasonal boards. The cost I accept out loud: the live board is
authoritative and always fresh, but I pay for it with memory (the whole active set
in RAM) and a hot write key under peak load — if a single ZSET key becomes the
bottleneck, I accept sharding complexity (cross-shard rank math) as the price of
keeping rank queries fast. The alternative — recompute-on-read from the database —
is simpler and cheaper to store but turns every "where do I rank" into an
O(n log n) sort, which is exactly the query that's already toppling their current
setup. So I'm explicitly trading memory + write-hotspot for read speed, because the
read is the product.
5. Operational concerns. Failure mode: the Redis node holding the ZSET dies and the in-memory ranking is gone. I'd detect it on the node's health/replication lag and recover from the latest snapshot in the durable store, replaying score events since the snapshot from the ingest log so I don't lose recent points. To bound data loss I'd snapshot frequently and keep a replica for fast failover. Rollback for a bad scoring change: scores are events, so I can recompute the board from the event log rather than trusting a corrupted live set.
- Surfaced freshness (real-time vs batch), scale, and the two distinct queries before designing
- Picked sorted set and justified it specifically by O(log n) arbitrary-member rank, not just 'Redis is fast'
- Identified own-rank-at-scale as the hard part and offered approximate rank for the long tail
- Committed to real-time + batched-history and named the cost (memory + hot write key)
- Gave a node-loss recovery path from snapshot plus event replay
- 'How do you get a player's own rank when there are 50M players?' → ZREVRANK is O(log n), but shard by score and sum higher-shard counts, or serve approximate rank for the long tail
- 'What breaks at 10x updates/sec?' → the single ZSET key is a write hotspot; shard by score range and maintain per-shard counts
- 'Why not just ORDER BY score in Postgres?' → that's an O(n log n) sort per query — the exact thing already failing; commit to the sorted set and own the memory cost
- 'How do you handle ties deterministically?' → encode a tiebreaker (timestamp) into the score so order is stable