Redis Sorted Sets Explained: Skip Lists, Scores, and Real-World Use Cases
Under the Hood: Why ZSETs Use Two Data Structures at Once
Redis Sorted Sets (ZSETs) store unique members each paired with a floating-point score, kept in sorted order at all times.
π― System Scenario & Requirements
System Goal
Redis Sorted Sets (ZSETs) store unique members each paired with a floating-point score, kept in sorted order at all times.
Actors & Components
- Application or API tier
- Core service under design
- Operator or SRE observer
Functional Requirements
- TLDR: Redis Sorted Sets (ZSETs) store unique members each paired with a floating-point score, kept in sorted order at all times.
- Internally they use a skip list for O(log N) range queries and a hash table for O(1) score lookup β giving you the best of both.
- The five patterns you'll reach for most: leaderboards, social feed caches, sliding-window rate limiters, priority job queues, and expiring session stores.
Constraints & Assumptions
- Constraints are derived from the rebuilt scenario stages.
β‘ Execution Stages & Decision Trace
Stage 1 β The 4-Second Query: When SQL ORDER BY Breaks at Scale
You built a leaderboard that worked fine at 1,000 users.
At 10 million, your SELECT user id, score FROM scores ORDER BY score DESC LIMIT 10 takes 4 seconds β even with an index.
Deep Dive
Scenario context
Redis Sorted Sets (ZSETs) store unique members each paired with a floating-point score, kept in sorted order at all times. Internally they use a skip list for O(log N) range queries and a hash table for O(1) score lookup β giving you the best of both. The five patterns you'll reach for most: leaderboards, social feed caches, sliding-window rate limiters, priority job queues, and expiring session stores.
<!-β¦
This walkthrough focuses on Under the Hood: Why ZSETs Use Two Data Structures at Once.
Before making a design choice, clarify:
- The Internals: Skip List + Hash Table, in Parallel A Redis Sorted Set is not one data structure β it's two, maintained in parallel and kept consistent on every write: 1.
- Skip List (for ordered operations) A skip list is a probabilistic alternative to a balanced BST.
- Imagine a linked list, but some nodes have "express lane" pointers that skip over many nodes at higher levels.
Concepts in play: caching, database-indexing, hash-tables-and-bloom-filters, jvm-internals-and-garbage-collection
Stage 1 β The 4-Second Query: When SQL ORDER BY Breaks at Scale
- You built a leaderboard that worked fine at 1,000 users.
- At 10 million, your SELECT user id, score FROM scores ORDER BY score DESC LIMIT 10 takes 4 seconds β even with an index.
- Adding a write every time a user earns points means constant B-tree rebalancing under concurrent load.
- At peak traffic the query planner gives up on the index and full-scans.
// Add or update a user's score
jedis.zadd("leaderboard:season1", 9850.0, "user:42");
// Fetch the top 10 (highest score first)
List<Tuple> top10 = jedis.zrevrangeWithScores("leaderboard:season1", 0, 9);
Observable outcome: At peak traffic the query planner gives up on the index and full-scans.
Stage 2 β Scores, Members, and Sorted Order: The ZSET Mental Model
- A Sorted Set is a collection where: - Every member is a unique string (user ID, post ID, job ID, session token β anything).
- Every member carries a floating-point score that determines its rank.
- Members are always stored in score-ascending order .
- Ties are broken lexicographically by member string.
Observable outcome: Ties are broken lexicographically by member string.
Stage 3 β Every ZSET Command You'll Actually Use
- The seven operations that cover 95% of real-world ZSET usage, with their complexity and the use case each targets: ZADD is idempotent for updates: if the member already exists, the score is updated in place and the membβ¦
- No delete-and-reinsert required.
- ZPOPMIN / ZPOPMAX (Redis 5+) atomically remove and return the lowest/highest-scored member β critical for priority job queues where you must claim a job atomically.
Observable outcome: ZPOPMIN / ZPOPMAX (Redis 5+) atomically remove and return the lowest/highest-scored member β critical for priority job queues where you must claim a job atomically.
Stage 4 β Five Production Patterns That Reach for ZSETs First
- Leaderboards Key: leaderboard:season1 Score: user's total points Member: user:<userId ZADD updates a user's score atomically (no read-modify-write loop).
- ZREVRANGE 0 9 WITHSCORES returns the top 10 in one command.
- ZREVRANK user:42 gives a user their rank in O(log N) without fetching all higher-ranked users.
- Social Feed Caching (Write-Time Fan-Out) Key: feed:<followerId Score: post timestamp (Unix epoch) Member: post:<postId When a user posts, fan-out writes ZADD feed:<followerId <timestamp <postId into each follower's feedβ¦
Observable outcome: Social Feed Caching (Write-Time Fan-Out) Key: feed:<followerId Score: post timestamp (Unix epoch) Member: post:<postId When a user posts, fan-out writes ZADD feed:<followerId <timestamp <postId into each follower's feedβ¦
Stage 5 β When to Use a ZSET and When to Walk Away
Observable outcome: You can explain how βWhen to Use a ZSET and When to Walk Awayβ changes the architecture or implementation path.
Stage 6 β Practical Examples: Leaderboard, Rate Limiter, and Feed Pagination in Java
- Example 1: Real-Time Leaderboard with Jedis Each zadd call atomically inserts or updates β if user:42 already has score 9800 , calling zadd leaderboard:season1 9850 user:42 moves them to the correct new position in O(loβ¦
- Example 2: Sliding Window Rate Limiter The sliding window rate limiter uses timestamps as scores so that "requests in the last N seconds" is always a score range query.
- The sliding window diagram shows the flow: The pipeline executes ZREMRANGEBYSCORE β ZCOUNT atomically before deciding.
- The count snapshot is always taken after evicting stale entries β this is what makes it a true sliding window, not a fixed bucket.
import redis.clients.jedis.Jedis;
import redis.clients.jedis.resps.Tuple;
import java.util.List;
public class LeaderboardService {
private final Jedis jedis;
private static final String KEY = "leaderboard:season1";
public LeaderboardService(Jedis jedis) {
this.jedis = jedis;
}
// Add or update a user's score (atomic β no read-modify-write needed)
public void recordScore(String userId, double score) {
jedis.zadd(KEY, score, userId);
}
// Top N players, highest score first, with scores
public List<Tuple> getTopN(int n) {
return jedis.zrevrangeWithScores(KEY, 0, n - 1);
}
// Zero-based rank (0 = #1 on leaderboard)
public Long getRank(String userId) {
return jedis.zrevrank(KEY, userId);
}
// User's current score
public Double getScore(String userId) {
return jedis.zscore(KEY, userId); // O(1) hash table lookup
}
}
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
import java.util.UUID;
public class SlidingWindowRateLimiter {
private final Jedis jedis;
private final int maxRequests;
private final long windowMs;
public SlidingWindowRateLimiter(Jedis jedis, int maxRequests, long windowMs) {
this.jedis = jedis;
this.maxRequests = maxRequests;
this.windowMs = windowMs;
}
public boolean isAllowed(String userId) {
String key = "ratelimit:" + userId;
long now = System.currentTimeMillis();
long windowStart = now - windowMs;
String requestId = UUID.randomUUID().toString();
// Pipeline: evict old requests, count, add new, set TTL
Pipeline pipe = jedis.pipelined();
pipe.zremrangeByScore(key, 0, windowStart - 1); // remove expired
var countResp = pipe.zcount(key, windowStart, now); // count in window
pipe.zadd(key, now, requestId); // record this request
pipe.expire(key, (windowMs / 1000) + 5); // TTL for cleanup
pipe.sync();
// Decision based on count BEFORE this request was added
long count = countResp.get();
if (count >= maxRequests) {
jedis.zrem(key, requestId); // undo: we're over the limit
return false;
}
return true;
}
}
Observable outcome: The count snapshot is always taken after evicting stale entries β this is what makes it a true sliding window, not a fixed bucket.
End-to-end outcome
After working through the stages, you should be able to explain:
- Feed keys and rate-limit keys grow unboundedly if you never call ZREMRANGEBYRANK or ZREMRANGEBYSCORE .
- Set a trim watermark on every write, not in a background job β background jobs lag under load.
- A Sorted Set is a dual-indexed structure : skip list for ordered traversal, hash table for O(1) score lookup.
- Both are kept consistent on every write.
Alternatives considered
The source focuses on one primary design. A second architecture is not compared, so no alternative is invented here.
Failure and degraded modes
The source does not isolate a separate failure mode. Limits that matter for the scenario are called out in the stage decisions above.
