All Posts

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

Skip lists, dual indexing, and the five production patterns every backend engineer reaches for.

Abstract AlgorithmsAbstract Algorithms
Β·Β·21 min read
Share
Share on X / Twitter
Share on LinkedIn
Copy link

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.

πŸ“– 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. Your leaderboard is now a liability.

Redis Sorted Sets solve this in microseconds. Here's the one-liner:

// 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);

That zadd is O(log N). The zrevrange is O(log N + M) where M is the number of results returned β€” in this case 10. It doesn't matter whether the set has 10 members or 10 million. The cost grows logarithmically, not linearly, because Redis doesn't use a table scan β€” it uses a skip list.

Sorted Sets (command prefix Z) are one of the five core Redis data types, alongside Strings, Hashes, Lists, and Sets. They're the right tool whenever you need ordered, scored data with fast range queries.


πŸ” 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.
  • Scores can repeat; members cannot.

Think of it as a scoreboard at a bowling alley. Each lane has a name (the member) and a running score (the score). The board is always kept sorted. When Alice rolls a strike and her score changes, she moves up the board instantly β€” no separate sort step.

ConceptSQL EquivalentZSET Equivalent
Memberuser_id primary keyString identifier
Scorescore columnfloat64 value
Sorted accessORDER BY score DESCZREVRANGE / ZREVRANGEBYSCORE
Point lookupSELECT score WHERE user_id = ?ZSCORE β€” O(1)
Range queryWHERE score BETWEEN a AND bZRANGEBYSCORE min max

The key difference: in SQL, sorting is a query-time operation. In a ZSET, order is maintained continuously as members are added or updated. You never pay a bulk sort cost.


βš™οΈ 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:

OperationCommandComplexityPrimary Use Case
Add / update memberZADD key score memberO(log N)Add a user score; update it atomically
Range by rankZRANGE / ZREVRANGE key start stopO(log N + M)Top-10 leaderboard; paginate a feed
Range by scoreZRANGEBYSCORE key min maxO(log N + M)Fetch posts in a time window
Remove a memberZREM key memberO(log N)Remove expired session tokens
Score lookupZSCORE key memberO(1)Check a user's current score
Count in score rangeZCOUNT key min maxO(log N)Count requests in a rate-limit window
Rank of a memberZRANK / ZREVRANK key memberO(log N)Show a user their leaderboard position

ZADD is idempotent for updates: if the member already exists, the score is updated in place and the member is repositioned in O(log N). 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.


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

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. Each new node is assigned a random level (1–32 in Redis) with probability Β½ per additional level.

Level 4:  [head] -----------------------------------------> [tail]
Level 3:  [head] ------------> [score:4200] --------------> [tail]
Level 2:  [head] -> [1100] --> [score:4200] -> [7800] ----> [tail]
Level 1:  [head] -> [1100] --> [2900] -------> [4200] -> [7800] -> [tail]

To find a target score, Redis starts at the highest level and walks right until the next node exceeds the target, then drops down a level and repeats. This gives O(log N) average search β€” identical to a BST, but with much simpler concurrent modification (no rotations).

Why not a BST? Skip lists support O(log N) range queries naturally: once you locate the start score, you walk forward along Level 1 to collect the range. A BST requires an in-order traversal that revisits parent nodes. Redis author Antirez has noted that skip lists are also easier to reason about and debug.

graph LR
    classDef node fill:#4a9fd4,stroke:#2d7aad,color:#fff
    classDef head fill:#2c3e50,stroke:#1a252f,color:#fff
    classDef tail fill:#7f8c8d,stroke:#616a6b,color:#fff

    H([HEAD]):::head
    N1["score:1100 | alice"]:::node
    N2["score:2900 | bob"]:::node
    N3["score:4200 | carol"]:::node
    N4["score:7800 | dave"]:::node
    T([TAIL]):::tail

    H -->|"L4 express"| N3
    H -->|"L3 express"| N3
    H -->|"L2"| N1
    H -->|"L1"| N1
    N1 -->|"L2"| N3
    N1 -->|"L1"| N2
    N2 -->|"L1"| N3
    N3 -->|"L2"| N4
    N3 -->|"L1"| N4
    N4 -->|"L1"| T

The skip list stores members in ascending score order. Higher-level pointers act as express lanes, letting Redis skip large chunks of the list and achieve O(log N) traversal.

2. Hash Table (for O(1) score lookup)

Every ZSET also maintains a hash table mapping member β†’ score. When you call ZSCORE key member, Redis goes straight to the hash table β€” no list traversal. This is why ZSCORE is O(1) while ZRANGEBYSCORE is O(log N + M).

The cost of this dual structure is memory: each member is stored twice (once in the skip list node, once as a hash table key). Redis compresses small ZSETs as a listpack (previously ziplist) when both member count ≀ 128 and all member strings ≀ 64 bytes β€” trading O(N) scan time for dramatically lower memory overhead at small cardinality. Once either threshold is crossed, Redis automatically promotes to the skip list + hash table representation.

Performance Analysis: Why O(log N) Holds Under Load

OperationWhy This Complexity
ZADDSkip list insert: find position O(log N), insert pointer chain O(log N). Hash table insert: O(1) amortized.
ZRANGEBYSCORESkip list seek to min O(log N), then walk M nodes O(M). Total: O(log N + M).
ZSCOREHash table lookup only. O(1).
ZREMRemove from skip list O(log N), remove from hash table O(1). Total: O(log N).
ZCOUNTTwo skip list seeks: O(log N). No node data read needed β€” Redis uses span metadata.

Memory bottleneck: Each skip list node stores the member string, score, backward pointer, and up to 32 forward pointers. At 10 million members with average 20-byte member strings, expect roughly 800 MB–1.2 GB per ZSET. Always set a maxmemory policy and size your ZSET keys carefully β€” don't store all users in a single ZSET if you can shard by region or cohort.

Blocking at scale: Redis is single-threaded for command execution. A ZRANGEBYSCORE returning 500,000 members will block the event loop for tens of milliseconds. Prefer bounded range queries (LIMIT offset count) and paginate aggressively.


πŸ“Š Five Patterns, One Data Structure: How ZSETs Connect to Production Problems

The diagram below shows the five most common ZSET use cases and the specific commands that power each one.

graph TD
    classDef svc fill:#f5a623,stroke:#d4880a,color:#000
    classDef db fill:#4a9fd4,stroke:#2d7aad,color:#fff
    classDef cache fill:#cc2936,stroke:#a01e2b,color:#fff
    classDef mq fill:#27ae60,stroke:#1e8449,color:#fff
    classDef client fill:#2c3e50,stroke:#1a252f,color:#fff
    classDef infra fill:#7f8c8d,stroke:#616a6b,color:#fff

    ZSET[("Redis ZSET")]:::cache

    LB["Leaderboard: ZADD / ZREVRANGE"]:::svc
    FEED["Social Feed Cache: ZADD timestamp / ZRANGE"]:::svc
    RL["Rate Limiter: ZADD / ZCOUNT / ZREMRANGEBYSCORE"]:::svc
    JQ["Priority Job Queue: ZADD priority / ZPOPMIN"]:::mq
    SS["Session Store: ZADD expiry / ZRANGEBYSCORE"]:::infra

    ZSET --> LB
    ZSET --> FEED
    ZSET --> RL
    ZSET --> JQ
    ZSET --> SS

Every arrow represents a different query shape. Leaderboards query by rank. Feed caches query by score range (time window). Rate limiters count members in a score range. Job queues pop the minimum. Session stores range-query expired members by score.


🌍 Five Production Patterns That Reach for ZSETs First

1. 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.

2. 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 key. On read, ZREVRANGEBYSCORE feed:<userId> +inf -inf LIMIT 0 20 fetches the 20 most recent posts β€” a single O(log N + 20) operation per page. Older posts are trimmed with ZREMRANGEBYRANK feed:<userId> 0 -1001 to cap the feed at 1,000 entries.

See Write-Time vs Read-Time Fan-Out for the full celebrity threshold trade-off.

3. Sliding Window Rate Limiter

Key: ratelimit:<userId>
Score: request timestamp in milliseconds
Member: unique request UUID

Each incoming request does three things atomically (via MULTI/EXEC or Lua):

  1. ZREMRANGEBYSCORE β€” evict requests older than the window.
  2. ZCOUNT β€” count remaining requests in the window.
  3. ZADD β€” record this request if under the limit.

This gives an exact sliding window with no fixed-bucket approximation errors. See System Design HLD: Rate Limiter for the full design.

4. Priority Job Queue

Key: job-queue:default
Score: priority (lower = higher priority, e.g. 1 runs before 10)
Member: job:<jobId>

Workers call ZPOPMIN job-queue:default to atomically claim the highest-priority job. Because ZPOPMIN is atomic, no two workers can claim the same job β€” no distributed locking required. For delayed jobs, set the score to a future Unix timestamp and poll with ZRANGEBYSCORE 0 <now> LIMIT 0 1.

5. Expiring Session Tokens

Key: active-sessions
Score: expiry timestamp (Unix)
Member: session:<sessionId>

A background job runs ZRANGEBYSCORE active-sessions 0 <now> periodically to collect expired sessions, then ZREMs them. Because the set is sorted by expiry, the query is always O(log N + M_expired) β€” no full scan.


βš–οΈ When Sorted Sets Become the Problem

ZSETs are powerful but they carry real failure modes in production.

Hot key write amplification. A viral post triggers fan-out: one ZADD becomes 5 million ZADDs across 5 million follower feed keys. Each write serializes through a single Redis shard. Even at 100K writes/sec per shard, that's 50 seconds of catch-up. Mitigation: use a follower-count threshold (typically 10K) and switch celebrities to read-time pull β€” see the hybrid model.

Unbounded ZSET growth. A rate-limit key that never calls ZREMRANGEBYSCORE grows forever. At 1M requests/day per user, a 7-day retention window means 7M members per key β€” roughly 560 MB just for one user. Always pair writes with a trim or expiry sweep.

Large range queries blocking the event loop. ZRANGEBYSCORE -inf +inf on a 500K-member set takes ~50 ms. During those 50 ms, every other Redis client is stalled. Mitigate with LIMIT offset count pagination or Redis Cluster to shard large keys.

Score precision loss. ZSET scores are IEEE 754 doubles (64-bit). Integers up to 2⁡³ are represented exactly. Beyond that, two logically different scores may map to the same float β€” breaking sort order. Avoid using large Unix timestamps in nanoseconds as scores; use milliseconds instead.

Failure ModeTriggerMitigation
Hot key write stormCelebrity fan-outHybrid push/pull at follower threshold
Unbounded key growthMissing trim / expiry sweepZREMRANGEBYRANK or ZREMRANGEBYSCORE on every write
Event loop blockingUnbounded range queryAlways use LIMIT; paginate
Score precision lossScores > 2⁡³Use millisecond timestamps; keep scores within safe integer range

🧭 When to Use a ZSET and When to Walk Away

SituationRecommendation
Use whenYou need ordered access, range queries, or rank lookups on a set of unique members with numeric scores β€” leaderboards, feeds, rate windows, priority queues
Avoid whenYou only need unordered membership checks or pure key-value access β€” a Redis Set or Hash is cheaper
Avoid whenYour members are not unique per ordering dimension β€” e.g., multiple users with identical game IDs require a composite member key
Better alternativeFor very small sorted sets (< 50 items rarely written), a Redis List sorted on insert is simpler and uses less memory
Edge casesFan-out to > 10K followers: switch to read-time pull. Scores spanning > 2⁡³: use lexicographic member encoding instead of numeric score. ZSETs are per-node β€” cross-shard range queries require application-level merge.

πŸ§ͺ Practical Examples: Leaderboard, Rate Limiter, and Feed Pagination in Java

Example 1: Real-Time Leaderboard with Jedis

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
    }
}

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(log N). No locks, no transactions.

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.

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;
    }
}

The sliding window diagram shows the flow:

flowchart TD
    classDef svc fill:#f5a623,stroke:#d4880a,color:#000
    classDef cache fill:#cc2936,stroke:#a01e2b,color:#fff
    classDef ok fill:#27ae60,stroke:#1e8449,color:#fff
    classDef deny fill:#7f8c8d,stroke:#616a6b,color:#fff

    REQ(["Incoming Request"]):::svc
    EVICT["ZREMRANGEBYSCORE: Evict requests older than window"]:::cache
    COUNT["ZCOUNT: Count requests in current window"]:::cache
    CHECK{"count >= limit?"}:::svc
    ADD["ZADD: Record this request with timestamp"]:::cache
    EXPIRE["EXPIRE: Reset TTL on key"]:::cache
    ALLOW(["Allow Request"]):::ok
    BLOCK(["Reject: 429 Too Many Requests"]):::deny

    REQ --> EVICT
    EVICT --> COUNT
    COUNT --> CHECK
    CHECK -- No --> ADD
    ADD --> EXPIRE
    EXPIRE --> ALLOW
    CHECK -- Yes --> BLOCK

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.

Example 3: Feed Pagination with ZRANGEBYSCORE

import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.ZRangeByScoreParams;
import java.util.Set;

public class FeedCacheService {

    private final Jedis jedis;
    private static final int MAX_FEED_SIZE = 1000;

    public FeedCacheService(Jedis jedis) { this.jedis = jedis; }

    // Fan-out writer: call this for each follower when a post is created
    public void pushToFeed(String followerId, String postId, long timestampMs) {
        String key = "feed:" + followerId;
        jedis.zadd(key, timestampMs, postId);
        // Trim to last MAX_FEED_SIZE entries (remove oldest)
        jedis.zremrangeByRank(key, 0, -(MAX_FEED_SIZE + 1));
    }

    // Reader: page through a follower's feed, newest first
    public Set<String> getFeedPage(String followerId, long beforeTimestampMs, int pageSize) {
        String key = "feed:" + followerId;
        // ZREVRANGEBYSCORE: from 'before' timestamp down to oldest, page by pageSize
        return jedis.zrevrangeByScore(key, beforeTimestampMs - 1, 0,
            new ZRangeByScoreParams().limit(0, pageSize));
    }
}

Each pushToFeed trims the feed to 1,000 entries immediately. getFeedPage uses the last-seen timestamp as a cursor β€” pass the timestamp of the oldest post on the current page to get the next page. This avoids the offset-based pagination trap where LIMIT 1000 20 still scans 1,000 entries.


πŸ› οΈ Jedis and Lettuce: Redis Sorted Set Clients in Java

Jedis (Synchronous)

Jedis is the canonical synchronous Redis client for Java. It maps directly to Redis commands with no abstraction layer β€” ideal when you want explicit control.

// Jedis β€” synchronous, single-threaded connection
try (JedisPool pool = new JedisPool("localhost", 6379);
     Jedis jedis = pool.getResource()) {

    // ZADD with NX flag: only add if member does not exist
    jedis.zadd("scores", ZAddParams.zAddParams().nx(), 5000.0, "user:99");

    // ZRANGEBYSCORE with WITHSCORES and LIMIT
    List<Tuple> results = jedis.zrangeByScoreWithScores(
        "scores", 1000, 9999, 0, 20);

    for (Tuple t : results) {
        System.out.printf("Member: %s  Score: %.0f%n", t.getElement(), t.getScore());
    }
}

Lettuce (Asynchronous / Reactive)

Lettuce uses a single multiplexed connection and is the better choice for high-throughput async pipelines.

import io.lettuce.core.RedisClient;
import io.lettuce.core.api.async.RedisAsyncCommands;
import io.lettuce.core.ScoredValue;
import java.util.List;
import java.util.concurrent.CompletableFuture;

RedisClient client = RedisClient.create("redis://localhost:6379");
RedisAsyncCommands<String, String> async = client.connect().async();

// Async ZADD β€” returns CompletableFuture<Long>
CompletableFuture<Long> addFuture = async.zadd("scores", 9000.0, "user:77")
    .toCompletableFuture();

// Async ZREVRANGE with scores β€” top 5
CompletableFuture<List<ScoredValue<String>>> top5Future =
    async.zrevrangeWithScores("scores", 0, 4).toCompletableFuture();

top5Future.thenAccept(results ->
    results.forEach(sv ->
        System.out.printf("Member: %s  Score: %.0f%n", sv.getValue(), sv.getScore())
    )
);

Spring Data Redis: ZSetOperations

If you're using Spring Boot, ZSetOperations<K,V> wraps ZSETs with a type-safe API:

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import java.util.Set;

@Service
public class SpringLeaderboardService {

    private final ZSetOperations<String, String> zSetOps;

    public SpringLeaderboardService(RedisTemplate<String, String> redisTemplate) {
        this.zSetOps = redisTemplate.opsForZSet();
    }

    public void updateScore(String userId, double score) {
        zSetOps.add("leaderboard", userId, score);
    }

    public Set<ZSetOperations.TypedTuple<String>> getTop10() {
        return zSetOps.reverseRangeWithScores("leaderboard", 0, 9);
    }

    public Long getUserRank(String userId) {
        return zSetOps.reverseRank("leaderboard", userId);
    }
}

ZSetOperations is the recommended entry point in Spring applications β€” it handles serialization, connection management, and plays nicely with @Cacheable and reactive streams via ReactiveZSetOperations.


πŸ“š Lessons Learned: What Trips Engineers Up in Production

1. Not trimming your ZSETs. 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.

2. Using ZADD to increment when you want ZINCRBY. ZADD key 100 member sets the score to 100, overwriting the old value. If you're accumulating points, use ZINCRBY key 50 member to add 50 to whatever the current score is. Many leaderboard bugs trace back to this mistake.

3. Treating ZSET range queries as free. ZRANGEBYSCORE -inf +inf on a large key blocks the event loop. Any query that returns M results costs O(M) in data serialization on top of the O(log N) seek. Always paginate with LIMIT.

4. Forgetting the listpack threshold. ZSETs with ≀ 128 members and all members ≀ 64 bytes use a compact listpack encoding. The moment you exceed either threshold, Redis converts the whole key to skip list + hash table β€” memory usage spikes 3–5x. If you're profiling a sudden memory jump, check DEBUG OBJECT <key> for the encoding.

5. Not using ZPOPMIN for job queues. Polling with ZRANGEBYSCORE 0 <now> LIMIT 0 1 followed by ZREM is a two-step operation β€” two workers can claim the same job between the two commands. ZPOPMIN is atomic; use it.


πŸ“Œ Summary: What You Should Walk Away Knowing

  • 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.
  • Most operations are O(log N), making ZSETs scale to tens of millions of members without degradation β€” unlike SQL ORDER BY.
  • ZSCORE is O(1) β€” not O(log N). It bypasses the skip list entirely and hits the hash table directly.
  • Small ZSETs use listpack encoding: under the 128-member / 64-byte thresholds, Redis compresses the structure. Expect a memory spike when you cross those limits.
  • The five patterns: leaderboards (ZREVRANGE), feed caches (ZADD + timestamp score), sliding-window rate limiters (ZADD + ZCOUNT), priority queues (ZPOPMIN), expiring sessions (ZRANGEBYSCORE with timestamp).
  • Always trim: unbounded ZSETs are a memory leak. Pair every write with a ZREMRANGEBYRANK or ZREMRANGEBYSCORE high-water mark.
  • One sentence to remember: A Redis Sorted Set is a scoreboard that stays sorted for free β€” and it never needs a GROUP BY.

πŸ“ Practice Quiz: Test Your ZSET Intuition

  1. What is the time complexity of ZSCORE?

    • A) O(log N) β€” it traverses the skip list
    • B) O(1) β€” it uses the hash table
    • C) O(N) β€” it scans all members Correct Answer: B
  2. You need to implement a sliding-window rate limiter that counts requests in the last 60 seconds. Which combination of ZSET commands is correct?

    • A) ZADD to record, LLEN to count, LTRIM to evict
    • B) ZADD to record, ZCOUNT min max to count, ZREMRANGEBYSCORE to evict old entries
    • C) SADD to record, SCARD to count, SREM to evict Correct Answer: B
  3. A ZSET key has 50 members, each with a 30-byte string member. Redis uses listpack encoding. You add a 51st member. What happens?

    • A) The ZADD command fails with an error
    • B) Redis silently drops the oldest member to stay under 50
    • C) Redis automatically converts the key to skip list + hash table encoding Correct Answer: C
  4. You are designing a priority job queue where multiple worker threads must claim jobs atomically, with no two workers claiming the same job. What is the correct approach? (Open-ended challenge β€” no single correct answer)

    Consider: which ZSET command is atomic for both selection and removal? What score scheme maps to priority order? How would you handle the case where no jobs are available? What changes if you need delayed execution (run job after a specific timestamp)?

    Correct Answer: Use ZPOPMIN (atomic remove-and-return of lowest-score member) with score = priority number (lower score = higher priority). For delayed jobs, use score = future Unix timestamp and only pop when score <= now. If no jobs exist, ZPOPMIN returns empty β€” workers should sleep and retry with backoff. For multi-consumer safety, Redis 6.2+ offers BZPOPMIN for blocking pop.



Abstract Algorithms

Written by

Abstract Algorithms

@abstractalgorithms