Skip to main content
Articles/Redis/redis-sorted-sets-explained
Scenario Article

Redis Sorted Sets Explained: Skip Lists, Scores, and Real-World Use Cases

Under the Hood: Why ZSETs Use Two Data Structures at Once

Abstract Algorithmsβ€’Mar 29, 2026
XLinkedInReddit
πŸ’‘ Executive Summary

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

1

Stage 1 β€” The 4-Second Query: When SQL ORDER BY Breaks at Scale

Action / Decision:

You built a leaderboard that worked fine at 1,000 users.

Component Behavior:

At 10 million, your SELECT user id, score FROM scores ORDER BY score DESC LIMIT 10 takes 4 seconds β€” even with an index.

Observable Output: Adding a write every time a user earns points means constant B-tree rebalancing under concurrent load.
Read Full Stage in Deep Dive ↓
πŸ“–

Deep Dive

Detailed Architectural Analysis

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.

Share this case study

Discuss architecture trade-offs and lessons with your team.

Share:XLinkedInReddit