DSA DSA In Depth
0%
case redisskiplistlistpack

Production Case Study: Redis ZSET (Listpack vs SkipList + Dict Dual Indexing)

7 min read Module 1.3: Linked Structures, Monotonic Stacks/Queues & Cache-Conscious Lists Updated Oct 23, 2026
📄 Cheat Sheet
Redis Sorted Sets (ZSET) Internals: Listpack vs SkipList + Hash Table Dual Indexing
Redis Redis Sorted Sets (ZSET) Internals: Listpack vs SkipList + Hash Table Dual Indexing Watch on YouTube ↗

Deep-dive lecture covering Production Case Study: Redis ZSET (Listpack vs SkipList + Dict Dual Indexing).

0:00 / --:--

Dual-Indexing Architecture: Why Redis Combines a Hash Table + Augmented Skip List

Consider the API contract of a Redis Sorted Set (ZSET, implemented in src/t_zset.c by Salvatore Sanfilippo): every element is a unique string ele paired with a 64-bit IEEE-754 floating-point double score, ordered primarily by score ascending and secondarily by lexicographical ele ascending. Look at the conflicting access patterns required:

  • ZSCORE key member asks: "What is the score of member $X$?" A pure Skip List or B-Tree ordered by (score, ele) cannot search by ele alone without scanning all $N$ nodes in $O(N)$ time!
  • ZRANGEBYSCORE key min max asks: "Return all members with score $\in [\text{min}, \text{max}]$." A hash table keyed by ele cannot answer ordered range queries without an $O(N \log N)$ sort!
  • ZRANK key member and ZRANGE key 100 150 ask: "What is the 0-based positional index of member $X$?" and "Return the 100th through 150th elements."

Redis solves all three simultaneously inside struct zset by composing two structures that share the exact same underlying string allocations (sds):

  1. A Hash Table (dict *dict): Maps ele -> score in $O(1)$ time.
  2. An Order-Statistic Skip List (zskiplist *zsl): Indexes (score, ele) in sorted order, augmented with level span counters and a level-0 backward pointer.
Structural Invariant: Skip List span Augmentation for $O(\log N)$ Rank Queries

Inside Redis's zskiplistNode, each level $k$ in the tower stores not just a forward pointer zskiplistNode *forward, but also an unsigned integer unsigned long span:

$$\text{level}[k].\text{span} = \text{number of level-0 nodes traversed from } u \text{ to } u.\text{level}[k].\text{forward}$$

How does ZRANK key member compute the exact 0-based rank of member in $O(\log N)$ time?

  1. First, look up score = dictFind(zset->dict, member) in $O(1)$ time!
  2. Second, search the Skip List for the composite key (score, member) from top level down to level 0. Every time we step horizontally across curr->level[k].forward, we add rank += curr->level[k].span! When we reach the target node at level 0, the accumulated rank is its exact 1-based position in the sorted set!

Adaptive Representation: Small OBJ_ENCODING_LISTPACK vs. Large OBJ_ENCODING_SKIPLIST

Encoding Mode Activation Threshold (Default) Memory Overhead per Item ZADD / ZSCORE Why It Wins in Its Regime
OBJ_ENCODING_LISTPACK (formerly ziplist) $\le 128$ elements AND all strings $\le 64$ bytes $\approx 2 - 4$ bytes header (1 contiguous buffer) $O(N)$ linear scan + memmove For $N \le 128$, scanning a contiguous 2 KB buffer in L1d is faster than chasing pointers and uses $10\times$ less RAM!
OBJ_ENCODING_SKIPLIST (dict + zskiplist) $> 128$ elements OR any string $> 64$ bytes $\approx 64 - 80$ bytes (dict entry + skiplist node) $O(\log N)$ / $O(1)$ Asymptotic $O(\log N)$ scaling for thousands to millions of items.

Visualizing Redis zskiplistNode Layout & span Rank Accumulation

ZRANK "u4": Step 1: dict["u4"] -> score 40 (O(1)). Step 2: Sum span Along SkipList Path (2 + 1 + 1 = Rank 4) HEAD L1: sp=2 HEAD L0: sp=1 "u1" (10) L0 "u2" L1: sp=3 "u2" (20) sp=1 "u3" (30) sp=1 "u4" (40) HIT! Why Redis Replaced ziplist with listpack (Redis 7.0): - Old ziplist stored prevlen (1B or 5B) of the PREVIOUS entry; inserting a >=254B entry could trigger an O(N^2) "Cascade Update" as every subsequent 250-253B entry expanded from 1B to 5B prevlen!

Redis ZSET Command Dispatch & Encoding Promotion Flow

flowchart LR A["ZADD key score member"] --> B{"Current Encoding?"} B -- "LISTPACK" --> C{"len > 128 OR
strlen(member) > 64?"} C -- "No" --> D["Insert into flat
listpack buffer"] C -- "Yes (Promote)" --> E["Convert listpack to
dict + zskiplist"] B -- "SKIPLIST" --> F["Look up old score in dict
If exists, update zskiplist
Else insert into dict & zskiplist"]
Hardware Reality: Why Redis Eliminated ziplist Cascade Updates with listpack

To support reverse iteration (ZREVRANGE), Redis's original compact format (ziplist) stored a variable-length field prevrawlen at the start of each entry: 1 byte if the previous entry was $< 254$ bytes, or 5 bytes if $\ge 254$ bytes. Imagine a ziplist where thousands of consecutive entries are each 253 bytes long. Inserting a single 300-byte entry before them forced the next entry's prevrawlen to expand from 1 byte to 5 bytes—growing that entry from 253 bytes to 257 bytes ($ge 254$), which forced the next entry to expand to 5 bytes, triggering a catastrophic $O(N^2)$ cascading reallocation storm! Salvatore Sanfilippo solved this in listpack: each entry stores its own length at the end of the entry ([encoding | data | backlen]), so forward scans read left-to-right and backward scans read backlen right-to-left, making every entry's byte size 100% independent of its neighbors!

Skip List Multi-Level Express Lane Search & Insertion
Expected O(log N)

Traverse multi-level express towers from top level L_max down to L_0. Move right while next.key < target, otherwise drop down one level — achieving O(log N) search without tree rotations.

Step 1 / 6
Custom Input:
Timeline

Invariant & State

At Level L3, Node(-∞): invariant key(curr) ≤ 42 < key(curr.next[L3])
🔗 Live Pointer & State Transition Graph — Skip List Multi-Level Express Lane Search & Insertion Active Pointer Jump/Transition Matched/Optimal
HEAD [0] ─── [1] ─── [2] ─── [3] ─── [4] 50 [5] ─── [6] ─── [7] HEAD [8] ─── [9]
L3
HEAD
───
───
───
───
50
───
───
L2
HEAD
───
20
───
───
50
───
───
L1
HEAD
───
20
───
42
50
───
75
L0
HEAD
10
20
30
42
50
60
75
Target Key
42
Current Level
L3
Current Node
HEAD (-∞)
Pointer Hops
1
[Step 1] Start at L3 header (-∞). Next at L3 is 50 > 42 → drop to L2.

Production Implementation: Order-Statistic Skip List Node with rank & get_by_rank

#include <array>
#include <cstddef>
#include <cstdint>
#include <string>

// Simplified Redis-style Order-Statistic Skip List supporting O(log N) Rank & Select
class RankedSkipList {
  static constexpr int MAX_LEVEL = 16;

  struct LevelLink {
    int next_idx = 0;       // 0 = nullptr sentinel
    std::size_t span = 0;   // Number of level-0 nodes skipped by this link
  };

  struct Node {
    double score = 0.0;
    int member_id = 0;
    int backward = 0;
    std::array<LevelLink, MAX_LEVEL> level{};
  };

  std::array<Node, 4096> pool_{}; // Contiguous pool; index 0 is head sentinel
  int pool_used_ = 1;
  int max_lvl_ = 1;

  static bool less_than(double s1, int m1, double s2, int m2) noexcept {
    return (s1 < s2) || (s1 == s2 && m1 < m2);
  }

public:
  // O(log N) ZRANK: Returns 1-based rank of (score, member_id), or 0 if not found
  std::size_t get_rank(double score, int member_id) const noexcept {
    std::size_t rank = 0;
    int x = 0; // Start at head (index 0)
    for (int i = max_lvl_ - 1; i >= 0; --i) {
      while (pool_[x].level[i].next_idx != 0) {
        const int nxt = pool_[x].level[i].next_idx;
        if (less_than(pool_[nxt].score, pool_[nxt].member_id, score, member_id) ||
            (pool_[nxt].score == score && pool_[nxt].member_id == member_id)) {
          rank += pool_[x].level[i].span;
          x = nxt;
        } else {
          break;
        }
      }
      if (x != 0 && pool_[x].score == score && pool_[x].member_id == member_id) {
        return rank;
      }
    }
    return 0;
  }

  // O(log N) ZRANGE by Rank: Retrieve node at 1-based positional rank target_rank
  const Node* get_element_by_rank(std::size_t target_rank) const noexcept {
    std::size_t traversed = 0;
    int x = 0;
    for (int i = max_lvl_ - 1; i >= 0; --i) {
      while (pool_[x].level[i].next_idx != 0 &&
             traversed + pool_[x].level[i].span <= target_rank) {
        traversed += pool_[x].level[i].span;
        x = pool_[x].level[i].next_idx;
      }
      if (traversed == target_rank) return &pool_[x];
    }
    return nullptr;
  }
};
Production Pitfall: Updating a Member's Score in a Skip List Without Deleting and Reinserting

When a user executes ZADD key new_score existing_member in Redis, can you simply look up the node via the hash table and overwrite node->score = new_score in place? Only if the node's relative sorted position between its level-0 predecessor and successor does not change! If new_score moves the node past even a single neighbor, overwriting score in place corrupts the sorted invariant of every Skip List level and breaks all span counters. Redis explicitly checks whether the new score still lies strictly between node->backward->score and node->level[0].forward->score; if not, it deletes the node (decrementing span on untouched upper levels) and reinserts it at its new position.

Check yourself Question 1 of 4
Concept Check Dual-Indexed `OBJ_ENCODING_SKIPLIST` Architecture recall

When a Redis Sorted Set (`ZSET`) exceeds `zset-max-listpack-entries` (default 128), Redis converts it to `OBJ_ENCODING_SKIPLIST`, which contains both a `dict` (hash table) and a `zskiplist`. Why are BOTH data structures required inside the same `zset` struct?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

How does choosing $p = 1/4$ instead of $p = 1/2$ affect memory per node and expected search comparisons?

Key Takeaways

Redis ZSETs combine a hash table (dict: member -> score) for O(1) point lookups with an order-statistic Skip List (zskiplist: (score, member)) for O(log N) range and rank queries.
Augmenting each Skip List forward pointer with a span counter (number of level-0 nodes skipped) enables O(log N) positional rank lookup (ZRANK) and k-th element selection (ZRANGE).
For small ZSETs (<= 128 elements, <= 64B strings), Redis uses a single contiguous listpack array instead of a Skip List + Dict, saving 10x memory and exploiting L1 cache locality.
Redis 7.0 replaced ziplist with listpack by moving each entry's length field (backlen) to the end of the entry itself, eliminating O(N^2) cascading length-expansion updates.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue