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 memberasks: "What is the score of member $X$?" A pure Skip List or B-Tree ordered by(score, ele)cannot search byelealone without scanning all $N$ nodes in $O(N)$ time!ZRANGEBYSCORE key min maxasks: "Return all members with score $\in [\text{min}, \text{max}]$." A hash table keyed byelecannot answer ordered range queries without an $O(N \log N)$ sort!ZRANK key memberandZRANGE key 100 150ask: "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):
- A Hash Table (
dict *dict): Mapsele -> scorein $O(1)$ time. - An Order-Statistic Skip List (
zskiplist *zsl): Indexes(score, ele)in sorted order, augmented with level span counters and a level-0backwardpointer.
span Augmentation for $O(\log N)$ Rank QueriesInside 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?
- First, look up
score = dictFind(zset->dict, member)in $O(1)$ time! - Second, search the Skip List for the composite key
(score, member)from top level down to level 0. Every time we step horizontally acrosscurr->level[k].forward, we addrank += curr->level[k].span! When we reach the target node at level 0, the accumulatedrankis 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
Redis ZSET Command Dispatch & Encoding Promotion Flow
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"]
ziplist Cascade Updates with listpackTo 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!
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.
Invariant & State
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;
}
};
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.