Chapter 10: Design a Real-time Gaming Leaderboard

volume2 leaderboard redis sorted-set real-time

Status: 🟩 Interview ready
Difficulty: Medium-Hard
Time to complete: 45 min read + practice


Overview

A gaming leaderboard tracks player scores and shows real-time rankings β€” who is at the top, and where you stand among all players. Examples: Clash of Clans, Fortnite, Candy Crush, any competitive online game.

Why this matters:

  • Excellent Redis Sorted Set teaching problem (appears at many mid-size/large tech companies)
  • Teaches real-time data structures, trade-offs between SQL and Redis, sharding strategies
  • Practical and concrete β€” easy to discuss clearly in an interview

Problem Statement

Design a real-time gaming leaderboard that:

  • Tracks player scores for a game (or multiple games)
  • Shows the top-N players globally (default: top 10)
  • Shows an individual player’s rank in real-time
  • Updates scores as players earn points during gameplay
  • Supports historical leaderboards (e.g., weekly, monthly)

Step 1: Requirements & Scope (5 min)

Functional Requirements

Clarifying questions:

  • One global leaderboard or per-game? β†’ Per game (each game has its own)
  • How many players? β†’ 5 million DAU
  • Top how many players shown? β†’ Top 10 (but system should support top-N)
  • Can we show user’s own rank? β†’ Yes, show their rank even if not in top 10
  • How fresh does the ranking need to be? β†’ Near real-time (within seconds)
  • Historical data? β†’ Yes, weekly and monthly leaderboards
  • Score update volume? β†’ 50 million score updates per day

Scope:

  • Update a player’s score
  • Get top-10 players with scores
  • Get a specific player’s rank and score
  • Per-game leaderboards
  • Historical (weekly/monthly) leaderboards

Non-Functional Requirements

  • Low latency reads: < 100ms for top-10 query and rank lookup
  • High write throughput: 50M updates/day β‰ˆ ~580 updates/sec average, ~2,500 peak
  • Scalability: 5M DAU, growing
  • Availability: 99.99% (leaderboard is customer-facing, but not life-critical)
  • Consistency: Near real-time (eventual is acceptable, within a few seconds)
  • Durability: Score data must survive restarts

Step 2: High-Level Design (10 min)

Two Core Operations

Operation 1: Score update

Client (game event: player earned 100 points)
  β”‚
  β–Ό
API Server
  β”‚
  β–Ό
Score Update Service
  β”‚
  β”œβ”€β”€β–Ά Leaderboard Store (update ranking)
  └──▢ Score DB (persist score history)

Operation 2: Leaderboard query

Client (show me the leaderboard)
  β”‚
  β–Ό
API Server
  β”‚
  β–Ό
Leaderboard Service
  β”‚
  β–Ό
Leaderboard Store (Redis) β†’ top-10 players + user's rank

High-Level Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                        Game Server                          β”‚
β”‚  (Player scores point β†’ generates score event)              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚
                    β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”
                    β”‚    Kafka    β”‚  (decouple game server
                    β”‚  (score     β”‚   from score processing)
                    β”‚   events)   β”‚
                    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
                           β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚   Score Processor       β”‚
              β”‚   (Kafka consumer,      β”‚
              β”‚    validates events,    β”‚
              β”‚    deduplicates)        β”‚
              β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
                   β”‚             β”‚
         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”    β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
         β”‚   Redis    β”‚    β”‚   MySQL /       β”‚
         β”‚  Sorted    β”‚    β”‚   PostgreSQL    β”‚
         β”‚    Set     β”‚    β”‚  (score history,β”‚
         β”‚ (rankings) β”‚    β”‚   persistence)  β”‚
         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                   β”‚
         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
         β”‚  Leaderboard API   β”‚
         β”‚  (top-N, user rank)β”‚
         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                   β”‚
              β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”
              β”‚ Clients β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

API Design

Score operations:

POST /scores
Body: { "game_id": "chess", "player_id": "alice", "delta": 100 }
Response: { "player_id": "alice", "new_score": 1450, "rank": 7 }

Leaderboard queries:

GET /leaderboards/{game_id}/top?limit=10
Response: [
  { "rank": 1, "player_id": "bob",   "score": 9800 },
  { "rank": 2, "player_id": "carol", "score": 9700 },
  ...
]

GET /leaderboards/{game_id}/players/{player_id}/rank
Response: { "player_id": "alice", "rank": 42, "score": 1450 }

Step 3: Deep Dive (20 min)

Storage Option 1: Relational Database

Naive approach β€” store scores in MySQL:

CREATE TABLE player_scores (
    game_id    VARCHAR(50),
    player_id  VARCHAR(50),
    score      INT,
    updated_at TIMESTAMP,
    PRIMARY KEY (game_id, player_id)
);
 
-- Top-10 query:
SELECT player_id, score
FROM player_scores
WHERE game_id = 'chess'
ORDER BY score DESC
LIMIT 10;
 
-- User rank query (SLOW! Full table scan):
SELECT COUNT(*) + 1 AS rank
FROM player_scores
WHERE game_id = 'chess'
  AND score > (SELECT score FROM player_scores
               WHERE game_id = 'chess' AND player_id = 'alice');

Problems:

  • ORDER BY score DESC LIMIT 10 β€” requires full table scan or full index scan for millions of rows
  • Rank query requires counting all players with higher scores β€” O(N)
  • At 5M players per game, every rank lookup is expensive
  • Adding an index on (game_id, score) helps but still O(log N) per update + O(log N + K) per range query β€” the DB overhead and lock contention don’t compare to Redis

When SQL is OK: Game has < 100K players, or leaderboard updates are infrequent (daily refresh).

Storage Option 2: Redis Sorted Set (Best Choice!)

Redis Sorted Set is purpose-built for this exact use case:

  • Members (player IDs) each have a score (float)
  • Members are kept sorted by score automatically
  • O(log N) for insert/update
  • O(log N + K) for top-K range query
  • O(log N) for rank lookup

Redis Sorted Set commands:

# Add or update a player's score
ZADD leaderboard:chess 1450 "alice"
# β†’ O(log N)

# Increment score (most common: player earns delta points)
ZINCRBY leaderboard:chess 100 "alice"
# β†’ atomically adds 100 to alice's score, O(log N)

# Get top 10 players (highest scores first)
ZREVRANGE leaderboard:chess 0 9 WITHSCORES
# β†’ returns [ ("bob", 9800), ("carol", 9700), ... ]
# β†’ O(log N + K) where K=10

# Get alice's rank (0-indexed, highest score = rank 0)
ZREVRANK leaderboard:chess "alice"
# β†’ returns 41  (rank 42 = 0-indexed 41)
# β†’ O(log N)

# Get alice's score
ZSCORE leaderboard:chess "alice"
# β†’ returns 1450
# β†’ O(1)

# Get players ranked 10-19 (for pagination)
ZREVRANGE leaderboard:chess 10 19 WITHSCORES
# β†’ O(log N + K)

# Get total number of players in leaderboard
ZCARD leaderboard:chess
# β†’ O(1)

# Remove a player (account deletion)
ZREM leaderboard:chess "alice"
# β†’ O(log N)

Time complexity summary:

OperationCommandComplexityNotes
Add/update scoreZADDO(log N)Insert + maintain sorted order
Increment scoreZINCRBYO(log N)Atomic increment + re-sort
Top-K queryZREVRANGEO(log N + K)Very fast for small K
User rankZREVRANKO(log N)No counting loop!
User scoreZSCOREO(1)Direct hash lookup
Player countZCARDO(1)Metadata

Why Redis Sorted Set beats SQL:

AspectSQL (ORDER BY)Redis Sorted Set
Top-10 queryO(N log N) or full index scanO(log N + 10)
Rank lookupO(N) count queryO(log N)
Score updateO(log N) update + reindexO(log N) ZINCRBY
Write throughputModerate (lock contention)Very high (in-memory)
Data structureB-tree indexSkip list (sorted by design)

Key Design: One Sorted Set Per Game

Key naming convention:
  leaderboard:{game_id}             β†’ global leaderboard for a game
  leaderboard:{game_id}:weekly:{week}  β†’ weekly leaderboard (e.g., week 202415)
  leaderboard:{game_id}:monthly:{month} β†’ monthly (e.g., 2024-04)

Examples:
  leaderboard:chess                β†’ global chess leaderboard
  leaderboard:chess:weekly:202415  β†’ chess week 15 of 2024
  leaderboard:chess:monthly:202404 β†’ chess April 2024

Persistence: Protecting Redis Data

Redis is in-memory, so we need persistence strategies:

Option 1: Redis AOF (Append-Only File)

Every write command is appended to a log file on disk.
On restart, Redis replays the log to rebuild state.
Pro: Near-zero data loss (configurable fsync: every second or every write)
Con: AOF file grows large over time (periodic rewrite/compaction needed)
Use: Production β€” set appendfsync everysec for 1-second max data loss

Option 2: Redis RDB (Snapshot)

Redis forks and saves a point-in-time snapshot of all data to disk.
Configurable: every N seconds if M keys changed.
Pro: Fast restarts (load snapshot), compact storage
Con: Data loss between snapshots (e.g., lose 5 min if snapshot every 5 min)
Use: Secondary backup, or when small data loss is acceptable

Option 3: Redis + MySQL dual write

Score Processor writes to both Redis AND MySQL atomically (or near-atomically).
  β†’ MySQL = source of truth (full history, durable)
  β†’ Redis = cache layer (fast reads, ranking)

On Redis failure:
  β†’ Rebuild sorted set by reading scores from MySQL
  β†’ SELECT player_id, score FROM player_scores WHERE game_id = 'chess'
  β†’ ZADD in bulk to recreate leaderboard

Best for production: Use Redis AOF for immediate durability +
MySQL as source of truth for full history and rebuilds.

Sharding for Large-Scale Games

Problem: One Redis sorted set per game can become a bottleneck for huge games (100M+ players).

Strategy 1: Shard by score range

Shard 1 (Redis instance A): players with score 0–9,999
Shard 2 (Redis instance B): players with score 10,000–19,999
Shard 3 (Redis instance C): players with score 20,000+

Top-10 query:
  β†’ Query Shard 3 (highest scores) β†’ get top-10 from that shard
  β†’ If Shard 3 has fewer than 10 players, query Shard 2, merge results

Rank query for Alice (score 1450):
  β†’ She is in Shard 1
  β†’ Rank = (all players in Shard 3) + (all players in Shard 2) + (ZREVRANK in Shard 1)

Strategy 2: Shard by player_id hash (for write distribution)

Shard = hash(player_id) % N

Each shard stores a subset of players (by ID, not by score).
All shards together form the global leaderboard.

Top-10 global: Query all N shards, get top-10 from each, merge and sort.
β†’ Merge cost: O(N Γ— 10) β€” acceptable when N is small (e.g., 10 shards)

User rank for Alice:
β†’ Find Alice's score on her shard (O(log N))
β†’ Count players with higher score on EACH other shard (range query)
β†’ Sum up = global rank
β†’ More expensive but doable

Recommendation:

  • 5M DAU per game β†’ single Redis sorted set easily handles this (millions of members per sorted set is fine)
  • Shard by player_id hash if > 100M players in a single game leaderboard
  • Score-range sharding is simpler for top-N queries but complicates rank lookup

Merging Shard Results

When top-10 requires merging multiple shards:

Shard A top-3: [(carol, 9700), (dave, 9600), (eve, 9500)]
Shard B top-3: [(bob, 9800), (frank, 9400), (grace, 9300)]
Shard C top-3: [(henry, 9100), (ivan, 9000), (judy, 8900)]

Merge (sorted merge of K sorted lists):
  bob:9800, carol:9700, dave:9600, eve:9500, frank:9400, grace:9300,
  henry:9100, ivan:9000, judy:8900, ...

Global top-3: [(bob, 9800), (carol, 9700), (dave, 9600)]

Algorithm: K-way merge (min-heap) β€” O(K log K) where K = number of shards Γ— top-N per shard.

Score Tie-Breaking

Two players with the same score β€” who ranks higher?

Option 1: Earlier score wins (most common in gaming)

Encode score as composite float:
  encoded_score = (score Γ— 10^13) - timestamp_ms

Alice: score=1450 at t=1000 β†’ 14500000000000 - 1000 = 14499999999000
Bob:   score=1450 at t=2000 β†’ 14500000000000 - 2000 = 14499999998000

ZADD leaderboard:chess 14499999999000 "alice"  ← higher score β†’ better rank
ZADD leaderboard:chess 14499999998000 "bob"    ← same point score, later time β†’ lower rank

Result: Alice ranks above Bob (she got there first)

Option 2: Alphabetical tiebreak (simpler but less fair)

Redis sorted sets use lexicographical order as a tiebreaker
for members with equal scores.
"alice" < "bob" alphabetically β†’ alice ranks higher at same score.

Option 3: No tiebreak (allow shared ranks)

Both players at rank N β€” display "T-42" (tied 42nd).
Simple but requires extra logic to number the display.

Recommendation: Composite score encoding (Option 1) gives deterministic, fair ranking with no extra storage.

Historical Leaderboards

Weekly leaderboards:

Strategy:
  - Separate sorted set per week: leaderboard:chess:weekly:{year_week}
  - Score update goes to BOTH global AND current week's sorted set
  - At end of week: archive top-N to MySQL, set Redis key expiry

ZADD leaderboard:chess          100  "alice"  ← global
ZADD leaderboard:chess:weekly:202415 100 "alice"  ← week 15, 2024

At start of new week (Sunday midnight):
  1. Read top-1000 from leaderboard:chess:weekly:202415
  2. INSERT INTO weekly_archive (game_id, week, rank, player_id, score)
  3. SET EXPIRE leaderboard:chess:weekly:202415 604800 (keep 7 more days for queries)
  4. New key leaderboard:chess:weekly:202416 starts fresh at zero

Monthly leaderboards:

Same pattern: leaderboard:chess:monthly:2024-04
Archive at month end, expire Redis key after a grace period.

Score reset:

Option A: Use separate keys per period (above) β€” no reset needed
Option B: Single key, reset at period boundary
  β†’ RENAME leaderboard:chess:weekly to leaderboard:chess:weekly:prev
  β†’ DEL leaderboard:chess:weekly (start fresh)
  β†’ Option B loses granularity; Option A is preferred

Score Update Pipeline

For high-throughput score updates, use a message queue:

Game Server
  β”‚ Produces: { game_id, player_id, delta, event_id, timestamp }
  β–Ό
Kafka Topic: "score-events"
  β”‚ (partitioned by game_id for ordering within a game)
  β–Ό
Score Processor (Kafka Consumer Group)
  β”‚ 1. Deduplication: check event_id (idempotency key) in Redis/DB
  β”‚ 2. Validate: delta is positive, player exists, game exists
  β”‚ 3. ZINCRBY leaderboard:{game_id} {delta} {player_id}
  β”‚ 4. UPDATE player_scores SET score = score + delta WHERE ...
  β–Ό
Redis Sorted Set + MySQL

Why Kafka:

  • Buffer bursts (game events spike during tournaments)
  • Decouple game server from score processing
  • Replay events on processor failure
  • Multiple consumers (leaderboard + analytics + notifications)

Idempotency: Each score event has a unique event_id. Score Processor checks if event_id already processed before applying. Prevents double-counting if event replays.

Leaderboard for Friends / Regional

Friends leaderboard:

At query time:
  1. Fetch user's friend list (from social graph service)
  2. ZSCORE leaderboard:{game_id} {friend_id} for each friend
  3. Build and sort in application layer

Or pre-compute:
  - Maintain per-user sorted set of friend scores (expensive at scale)
  - Preferred: query-time computation for small friend lists (< 500 friends)

Regional leaderboard:

Separate sorted set per region:
  leaderboard:{game_id}:region:us-east
  leaderboard:{game_id}:region:eu-west

Score updates fan out to global + regional sorted set.
Same Redis Sorted Set commands β€” just different key.

Design Summary

ConcernChoiceReasoning
Core data structureRedis Sorted SetO(log N) insert + rank, O(1) score lookup
Score updatesZINCRBYAtomic increment, no read-modify-write race
Top-N queryZREVRANGEO(log N + K), purpose-built
Rank lookupZREVRANKO(log N), no COUNT query needed
PersistenceRedis AOF + MySQL dual writeDurability + rebuild capability
Score pipelineKafka + Score ProcessorDecouple, buffer bursts, idempotency
HistoricalSeparate sorted set per period, archive to MySQLNo global reset risk, full history
Tie-breakingComposite score (score Γ— M - timestamp)Fair FIFO tiebreak
ShardingBy player_id hash if > 100M playersWrite distribution + K-way merge for top-N
Friends leaderboardQuery-time ZSCORE per friendSimple, no pre-computation for small lists

Final Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     API Gateway                                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚
           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
           β”‚                               β”‚
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  Leaderboard    β”‚             β”‚  Score Update   β”‚
  β”‚  Service        β”‚             β”‚  Service        β”‚
  β”‚  (Read path)    β”‚             β”‚  (Write path)   β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜             β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚                               β”‚
           β”‚                        β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”
           β”‚                        β”‚    Kafka    β”‚
           β”‚                        β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
           β”‚                               β”‚
           β”‚                      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”
           β”‚                      β”‚ Score Processor  β”‚
           β”‚                      β”‚ (dedup, validate)β”‚
           β”‚                      β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚                               β”‚
           └────────────┐  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚  β”‚
               β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”
               β”‚   Redis Cluster    β”‚
               β”‚                   β”‚
               β”‚  leaderboard:chess β”‚
               β”‚  leaderboard:chess β”‚
               β”‚  :weekly:202415    β”‚
               β”‚  ...               β”‚
               β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚ AOF + snapshots
               β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
               β”‚     MySQL          β”‚
               β”‚  (player_scores,   β”‚
               β”‚   weekly_archive)  β”‚
               β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Interview Questions & Answers

Q: Why is Redis Sorted Set better than SQL ORDER BY for a leaderboard?
A: SQL ORDER BY requires a full index scan on (game_id, score) for top-N queries β€” O(N) to scan and sort, and counting rows for rank lookup is O(N). Redis Sorted Set maintains elements in sorted order at all times using a skip list. ZREVRANGE (top-K) is O(log N + K) and ZREVRANK is O(log N). At 5M players, O(log 5M) β‰ˆ 23 operations vs O(5M) for a count. Redis also has much higher write throughput (in-memory, no lock contention).

Q: How do you handle tie-breaking when two players have the same score?
A: Encode a composite score into the Redis sorted set value: composite = score Γ— 10^13 - timestamp_ms. Players who reached the same score earlier get a higher composite value, so they rank above later players with the same score. This is purely numeric and fits in a 64-bit float. The tiebreak is deterministic and fair (first to reach the score wins) with no extra storage.

Q: How would you shard the leaderboard for a game with 100M+ players?
A: Shard by hash(player_id) % N across N Redis instances. Each shard holds a sorted set for a subset of players. For top-10 global: query all N shards for their top-10, then K-way merge (min-heap) the results β€” O(N Γ— log(NΓ—10)) computation on the application layer. For user rank: get user’s score from their shard, then count players with higher scores on each other shard using ZCOUNT, sum them up. This scales writes linearly but merging adds latency β€” profile N to keep merge latency under budget.

Q: How do you implement historical (weekly/monthly) leaderboards?
A: Use a separate Redis sorted set per time period (e.g., leaderboard:chess:weekly:202415). Score updates write to both the global sorted set and the current period’s sorted set. At the period boundary: read the top players, archive to MySQL, and set an expiry on the Redis key. Start a fresh key for the new period. This avoids risky in-place resets and keeps history accessible for a grace period via the expiring Redis key, with permanent storage in MySQL.

Q: What happens if Redis goes down?
A: With AOF enabled (appendfsync everysec), at most 1 second of score updates is lost on restart. Redis replays the AOF log to reconstruct state. If the loss is unacceptable, also write scores to MySQL (dual write). On Redis restart or complete failure, rebuild the sorted set from MySQL: SELECT player_id, score FROM player_scores WHERE game_id = X, then ZADD in bulk. Piplined ZADD can load millions of records in seconds.


Key Takeaways

  1. Redis Sorted Set is the definitive data structure for leaderboards: O(log N) insert + rank, O(log N + K) top-K β€” purpose-built for this.
  2. ZINCRBY enables atomic score increments: No read-modify-write, no race conditions.
  3. SQL ORDER BY doesn’t scale: O(N) rank lookup via COUNT becomes unacceptable at millions of players.
  4. Persist with AOF + MySQL dual write: Redis AOF for fast recovery; MySQL as source of truth for rebuilds and history.
  5. Kafka decouples score events from processing: Buffers traffic spikes, enables deduplication, replays on failure.
  6. Tie-breaking via composite score encoding: Encode score Γ— scale factor - timestamp into Redis float β€” no extra fields.
  7. Historical leaderboards use separate keys per period: Archive to MySQL at period end; never reset in-place.


Practice this design! Great interview question with clear right answers. Be ready to:

  1. Immediately say Redis Sorted Set and explain the commands (ZADD, ZREVRANGE, ZREVRANK, ZINCRBY)
  2. Compare time complexity vs SQL ORDER BY
  3. Discuss persistence (AOF vs MySQL dual write)
  4. Explain Kafka pipeline for score updates
  5. Handle historical leaderboards and tie-breaking

Last Updated: 2026-04-13
Status: Medium-Hard β€” Clear winner (Redis Sorted Set) β€” Nail the commands and complexity!