TSTier SlateAll templates

Data & analytics

Leaderboard - a sorted set and the rank nobody stores

How a sorted set answers top ten and my rank in milliseconds, and why rank is computed rather than stored.

  • System design
  • starter
  • 8 step walkthrough
  • 5 tables

Share

  • Facebook
  • X

What this board gets wrong on purpose

The tension

The whole global board is one sorted-set key on one Redis shard: every ZADD and every ZREVRANK in the world lands on one single-threaded node, so it cannot be scaled out by adding shards without making rank a sum across shards. Redis replicates asynchronously, so a failover silently forgets the last second of score updates until the rebuild job replays the log - and until it finishes, some players see a rank that is wrong. Ties are broken by packing time into the score, which only works while the score fits in a double's 53 bits.

Practise this as an interview

Take Leaderboard - a sorted set and the rank nobody stores as a timed system design interview on a blank board - on your own, with the requirements to check yourself against, or with a candidate you invite.

Practise this as an interview

Start here

Why this design

A leaderboard looks like a SELECT ... ORDER BY score DESC LIMIT 10, and for a few thousand players it is. It stops being one when two questions have to be answered for millions of players at once: who is in the top ten, and what is MY rank. The first is easy with an index. The second is the trap - "count everyone above me" over a B-tree is a scan, and storing rank in a column means one good game rewrites most of the table. The answer is a structure that keeps members in order AND knows how many sit between any two points: a sorted set.

What you will learn

  • Why rank is computed at read time, never stored, and the one exception (a frozen season).
  • How a sorted set (skip list + hash) gives O(log N) insert, O(log N) rank and O(log N + 10) top ten.
  • That 50M players is about 5 GB in a sorted set and 100M updates a day is ~1,200/s - one Redis node handles both.
  • Why the sorted set is an index you can rebuild, not the source of truth, and what that costs on failover.
  • How ties are broken by packing time into the score, and where that trick runs out.

How to read this board

  1. Top: SCORE PATH. A player finishes a match, the game server reports it, the score service records it durably and publishes an event.
  2. Right: STORES. The score store (truth) and the red sorted set (the index that answers ranks).
  3. Bottom: READ PATH. A player opens the board; the leaderboard API asks the sorted set and decorates ten ids with names.
  4. Bottom right: JOBS. The rebuild job and the season snapshot - the two things that exist because the sorted set can be lost.
  5. Press play for the walkthrough, then read pages 1 to 6, then the tables at the bottom.
  6. The conversation to have is the red box: one key, one shard. Page 5 says what you would do when one node is no longer enough.

1 · Requirements

Functional

  • Submit a score at the end of a match. A player's board score is their BEST score this season (or a running total - pick one; this board uses best).
  • Show the top 10 (and top 100) for the global board.
  • Show my rank and score, and the 5 players directly above and below me ("around me").
  • Boards per season. A season ends, its final ranks are frozen and prizes are paid from that snapshot.
  • Display names and avatars beside each entry.

Non-functional

  • Top ten and my rank: p99 under 50 ms. My rank appears on the end-of-match screen, so it is on the critical path of every game.
  • A new score is visible on the board within 1 to 2 seconds.
  • Scores are durable: an accepted match result is never lost. The ranking index may be rebuilt.
  • Ranks are exact for the top 10,000. Below that, "top 12%" is an acceptable answer if exact becomes expensive.
  • Score submission is authoritative from the game server, never the client, or the top ten fills with cheaters.

Out of scope

  • Friends-only boards (a small per-user sorted set, or a sort at read time over a few hundred friends).
  • Anti-cheat beyond "only the game server may submit".
  • Per-region boards - the same design with one key per region.

2 · Capacity estimation

Assumptions

Players on the all-time board          50,000,000
Daily active players                   10,000,000
Matches per player per day                     10
Board opens per player per day                  3
Peak factor                                    x5

Writes

Score submissions   10M x 10    = 100,000,000 / day
                    / 86,400    =       1,160 /s average
                    x 5         =      ~5,800 /s peak

Reads

My rank on the results screen   = 1 per match
                                =   1,160 /s avg, 5,800 /s peak
Board opens  10M x 3 = 30M/day  =     350 /s avg, 1,750 /s peak
  each = ZREVRANGE 0 9 + ZREVRANK + around-me range
Total sorted-set commands at peak    ~ 5,800 + 1,750 x 3
                                     ~ 11,000 /s

A single Redis node does well over 100,000 simple commands a second, and these are O(log N) with log2(50M) ≈ 26 levels. So the sorted set is not a throughput problem today - it is a single-node problem: there is exactly one of it. Throughput would become the problem at roughly 10x this traffic.

Why not SQL

SELECT count(*) FROM best_scores WHERE score > :mine
  rank 25,000,000 = scan 25M index entries
  ~ seconds per query, x 5,800 /s  = impossible

A B-tree finds a score fast but does not know how many entries sit before it. A skip list in a sorted set stores the span of every link, so it counts while it walks.

3 · Storage estimation

The sorted set

member  player id, 16 B
score   8 B double
skip list node + hash entry + pointers ~ 70 B
                         ~ 100 B per member
50M members x 100 B    =  ~5 GB
one key per season, keep current + previous = ~10 GB

Ten gigabytes fits in memory on one node with room to spare, which is why the board can draw one. It is also why it is tempting never to split it.

The score store (truth)

score_events row   ~ 80 B   (player, season, points, time)
100M / day x 80 B  =   8 GB / day
keep 90 days       = 720 GB, then roll up or archive

best_scores row    ~ 60 B
50M players        =   3 GB per season

Tie-breaking inside one double

score = points x 2^32 + (2^32 - 1 - seconds_since_season_start)
  earlier achiever ranks higher at equal points
  a double is exact to 2^53, so points must stay under 2^21
  = 2,097,152 points - check the game can never exceed it

Redis orders equal scores by member name, which is arbitrary. Packing time into the low bits makes ties deterministic and fair, at the cost of a hard ceiling on the score.

4 · Availability

Targets

  • Score submission: 99.95 percent. A lost result is a support ticket.
  • Rank reads: 99.9 percent, degrading to "rank unavailable" rather than a wrong number.

What fails, and what it costs

  • The sorted set node (red). Replicated to a standby asynchronously. On failover the standby may be missing the last second of ZADDs - those players see their old best. The fix is that the score service never relied on Redis for durability: the rebuild job replays score events from the last checkpoint and the board heals in seconds to minutes.
  • Losing the sorted set entirely. Rebuild from best_scores: read 50M rows, ZADD in batches of 1,000 - about 50,000 pipelined batches, a few minutes. During that window serve the top ten from a cached copy and hide "my rank".
  • The score store. The durable part; a primary with a synchronous standby. If it is down, submissions are queued at the game server and retried - the match result is held, not dropped.
  • The event log. If the rank updater lags, the board is stale, not wrong. Alert on consumer lag above 5 seconds.

Degrade, do not lie

A rank that is stale by two seconds is fine. A rank that is wrong after a failover and stays wrong is not. That is why the updater writes with ZADD GT (only raise a score), which makes replaying the same events twice harmless.

5 · How it works

Score path

The match ends on the game server, which is the only thing allowed to report a score. The score service inserts the result into score_events, upserts best_scores if it beat the old best, and publishes a score event. The rank updater consumes events and runs ZADD season:42 GT <packed score> <player>. GT means the stored score only ever goes up, so a replayed or out-of-order event cannot lower anyone.

Read path

  • Top ten: ZREVRANGE season:42 0 9 WITHSCORES - O(log N + 10).
  • My rank: ZREVRANK season:42 <player> - O(log N); add 1 for a human rank.
  • Around me: ZREVRANGE season:42 rank-5 rank+5.
  • Then one multi-get of ten display names from the profile cache. The sorted set holds ids only, so a rename never touches it.

Why rank is never stored

Rank is a function of everyone else. If player A jumps from 900,000th to 10th, every player from 10th to 899,999th drops by one. A rank column turns one score update into up to N row updates; a sorted set turns it into one O(log N) insert and computes rank on the way down the skip list. The one time a rank IS written is when it stops changing: the season snapshot copies the final order into season_final_ranks so prizes are paid from a fixed list.

When one node is not enough

  • Read replicas for ZREVRANGE / ZREVRANK - easy, slightly stale.
  • Split by score range (shard 1 holds scores above X ...). My rank = my rank in my shard + the sizes (ZCARD) of all higher shards. Exact, but boundaries must move as scores grow.
  • Exact rank for the top 10,000, and a percentile from a histogram of score buckets for everyone else. Most games do this.

6 · Deployment plan

Topology

  • Score service and leaderboard API: stateless, several replicas behind a load balancer, one region near the game servers.
  • Redis: one primary per season key with one replica in another zone, persistence on (AOF every second) to shorten rebuilds.
  • Postgres: primary with a synchronous standby for score_events and best_scores.
  • Event log: a partitioned log (Kafka or similar) keyed by player id, so one player's events stay in order.

Rollout

  • Build the new season key before the season starts; flip the API to it by config at the season boundary.
  • Change the score packing only with a new key, never in place - old and new scores do not compare.
  • Canary the rank updater by running it into a shadow key and diffing the top 1,000 against the live key.

What to watch

Redis CPU (it is one thread), command latency p99, memory of the season key, rank updater consumer lag, and the count gap between ZCARD season:42 and the row count of best_scores for that season - if they drift apart, the index is missing players.

The walkthrough

  1. A match ends

    A player finishes a match. The game server, not the client, reports the score - a client that could submit its own number would own the top ten by lunchtime.

  2. Record the score durably

    The score service appends the result to score_events and raises best_scores if this beat the old best. This is the truth. Nothing about rank is written here.

  3. Publish a score event

    The service publishes an event and answers the game server. Ranking happens a moment later, off this request.

  4. One ZADD, and every rank moves

    The rank updater runs ZADD GT on the season key. One O(log N) insert, and implicitly every player below has dropped a place - without a single one of their rows being touched. That is why rank is never stored.

  5. Somebody opens the board

    A player opens the leaderboard tab or reaches the results screen. The client asks for the top ten and for its own rank.

  6. Rank is counted at read time

    ZREVRANGE 0 9 returns the top ten; ZREVRANK counts how many members sit above this player by walking the skip list - about 26 levels for 50M players. Both land on the same single key on the same single node.

  7. Ids become names

    The sorted set holds only ids. Ten names and avatars come from a cache in one multi-get, so renaming yourself never touches the ranking.

  8. The index can be rebuilt

    If the sorted set node fails over and forgets its last second, or dies outright, the rebuild job refills it from best_scores. That is the price of the red box: one node, asynchronously replicated. At the end of a season, the snapshot writes the final order down - the only time a rank is ever stored.

The data model

  • players

    Who a member id in the sorted set is. Names live here, not in the sorted set, so a rename touches one row.

    • player_iduuidprimary key
    • display_namevarcharnot null
    • country_codevarcharISO 3166 alpha-2, for regional boards.
    • created_attimestamptznot null
  • seasons

    One sorted-set key per season: season:<season_id>.

    • season_idintegerprimary key
    • namevarcharnot null
    • starts_attimestamptznot null
    • ends_attimestamptznot null
  • score_events

    Every accepted match result, append only. The log the rebuild job can replay.

    • event_iduuidprimary key
    • player_iduuidnot null
    • season_idintegernot null
    • pointsintegernot null
    • played_attimestamptznot null
  • best_scores

    One row per player per season. Note what is missing: there is no rank column.

    • season_idintegerprimary key
    • player_iduuidprimary key
    • best_pointsintegernot null
    • achieved_attimestamptznot nullPacked into the low bits of the sorted-set score to break ties.
  • season_final_ranks

    The only stored rank on the board - written once, when the season ends and ranks stop changing.

    • season_idintegerprimary key
    • final_rankintegerprimary key
    • player_iduuidnot null
    • best_pointsintegernot null

On the board

  • SCORE PATH · A MATCH ENDS

    • Player
    • Game server · match over: 4,210 pts
    • Score service · POST /scores
    • Score events · log, keyed by player
    • Rank updater · ZADD GT season:42
  • STORES · TRUTH AND INDEX

    • Score store · Postgres, the truth
    • Sorted set · one key, 50M members
  • READ PATH · TOP TEN AND MY RANK

    • Player
    • Game client · results screen, board tab
    • Leaderboard API · GET /top, /me
    • Profile cache · id -> name, avatar
  • JOBS · REBUILD AND FREEZE

    • Rebuild job · best_scores -> ZADD
    • Season snapshot · freezes final ranks

More boards

Pipelines, metrics and the lag between event and answer.

  • Ad serving - an auction inside a page load
  • Analytics pipeline - the lag between an event and the answer
  • Log aggregation - the index is bigger than the data
  • Metrics and monitoring - cardinality is the thing that kills you
  • Recommendation engine - retrieve a thousand, rank twenty
  • Search autocomplete - an answer before the word is finished
  • View counter - counting a million clicks without a million writes
  • Uptime monitor and status page - checking from outside
  • A/B testing platform - assignment is the easy half
  • ETL and the data warehouse - yesterday, correctly
  • Full-text search - the inverted index and the shard that is slow
All 100 templatesOpen Tierslate
Tierslate

tierslate.com

HomeTemplatesPrivacyTerms