Redis Data Structures & Memory Model: What the Server Actually Stores
Every Redis data structure silently switches between two internal encodings at configurable size thresholds — a compact listpack for small datasets and a performant skiplist or hashtable for large ones. Crossing a threshold mid-traffic triggers an invisible in-place promotion that spikes memory and latency with no log entry. This article deconstructs SDS strings, listpack-to-skiplist transitions, intset encoding chains, the jemalloc fragmentation model, and why KEYS * is a production footgun.
Redis Mastery
Redis Data Structures & Memory Model: What the Server Actually Stores
Redis is not a cache you bolt onto a slow database — it is a data structure server with a precisely bounded contract: sub-millisecond latency, in-memory semantics, and optional persistence. The canonical failure mode of this contract is always the same: an engineer calls SET, receives OK, and assumes Redis will remember that value indefinitely, under any memory pressure, through any process restart. The articles in this series exist to replace that assumption with mechanical precision. This first article starts at the foundation — the data structure encodings the server actually uses — because you cannot reason about persistence guarantees, eviction policies, or replication semantics without first understanding what Redis is doing with your data at the byte level.
This is Part 1 of the Redis Mastery series. It is the entry point for the series and has no prerequisites beyond basic Redis CLI familiarity (GET, SET, DEL). For a surface-level overview of Redis data structure commands, see Specialized Data Stores: Redis, Elasticsearch, Cassandra, Neo4j first — this article covers the internals behind those commands.
1. Key Design & the SCAN Footgun
Before touching encoding internals, there is a production footgun so common it belongs at the top of any Redis article: KEYS *.
Redis is single-threaded for command execution. A command that takes 5 seconds consumes the event loop for 5 seconds. KEYS is that command. It is legitimate in development against a small keyspace. It has no legitimate use in production.
SCAN provides weak consistency: keys inserted or deleted during an iteration may appear 0 or 2 times. For cache invalidation workflows this is acceptable. For auditing or exact counts, it is not.
1.1 Production Key Naming Convention
The industry-standard naming schema for Redis keys is:
Key naming rules:
- Colons as separators — universally recognized convention; allows namespace-aware
SCAN MATCH. - No spaces — spaces break
redis-cliargument parsing. - Lowercase — Redis keys are case-sensitive;
Session:42andsession:42are different keys. - Bounded length — Redis keys can be up to 512MB but every extra byte is overhead in the key table. Keep keys under 100 bytes.
1.2 Pipelining: N Commands for the Price of One RTT
Every individual Redis command incurs a full TCP round-trip: client sends, server processes, server responds. At 1ms RTT per command, executing 1,000 commands sequentially costs 1,000ms. Pipelining batches those commands into a single write to the socket:
Pipelining is a client-side network optimization. It is not a server-side atomicity mechanism. Commands in a pipeline are not guaranteed to execute without interleaving from other clients — they are simply batched into fewer TCP writes. For atomicity, use MULTI/EXEC or Lua scripts (covered in Part 6).
2. String Internals: SDS, Integer Encoding & the 44-Byte Threshold
When you run SET name "Alice", Redis does not store a C-string. It stores a Simple Dynamic String (SDS) — a custom string type designed to eliminate C-string's limitations: O(N) strlen, unsafe appends, and no binary safety.
2.1 SDS Structure
An SDS string carries its metadata inline:
Benefits over C-strings:
O(1)length retrieval (nostrlenscan)- Binary-safe (can store
\0bytes — useful for serialized binary payloads) - Pre-allocated space to avoid reallocation on append
2.2 The Three String Encodings
Redis selects one of three internal encodings for a string value:
| Encoding | Condition | Memory layout |
|---|---|---|
OBJ_ENCODING_INT |
Value is an integer that fits in a long |
Stored as raw integer in the pointer field — no SDS allocation |
OBJ_ENCODING_EMBSTR |
String length ≤ 44 bytes (Redis 4.0+) | Single contiguous allocation: robj + SDS header + content |
OBJ_ENCODING_RAW |
String length > 44 bytes | Two separate allocations: robj and SDS separately |
embstr is immutable. Any modification (e.g., APPEND, SETRANGE) to an embstr string first converts it to raw, even if the result would still fit in 44 bytes. After a single APPEND, OBJECT ENCODING returns raw permanently for that key. This is why you should not use APPEND on high-cardinality session data — each mutation allocates a new separate SDS object.
Store integer counters without quotes: SET hits 0 encodes as int (8 bytes on 64-bit). Storing SET hits "0" forces embstr encoding unnecessarily. For counters using INCR/DECR, the server always operates on the integer representation, but the initial encoding matters for memory at scale.
3. Hash Internals: listpack → hashtable
A Redis Hash (HSET/HGET) is the standard primitive for storing object fields. Its internal encoding depends on the number of fields and their sizes.
3.1 Dual Encoding
| Condition | Internal Encoding | Memory behaviour |
|---|---|---|
Fields ≤ hash-max-listpack-entries (default: 128) AND each value ≤ hash-max-listpack-value bytes (default: 64) |
listpack |
Contiguous flat array — compact, cache-friendly, O(N) field lookup |
| Either threshold exceeded | hashtable |
Hash table with chaining — O(1) average field lookup, higher memory overhead |
Encoding promotion is one-way and permanent for a key's lifetime. Once a Hash promotes to hashtable, it will not revert to listpack even if you delete fields below the threshold. The only way to revert is to delete and recreate the key. This means a transient burst of fields — even if immediately cleaned up — causes a permanent memory footprint increase for that key.
3.2 The Threshold Tuning Trade-off
Raising hash-max-listpack-entries keeps more Hashes in compact listpack format — saves memory, but increases per-key read CPU cost (O(N) scan vs O(1) hashtable lookup). The right threshold depends on your read pattern:
| Workload | Recommended threshold | Rationale |
|---|---|---|
| Write-heavy, rarely read all fields | Higher (256–512) | Memory savings outweigh O(N) scan cost |
| Read-heavy, full hash scans common | Lower (64) | Promote to O(1) hashtable sooner |
| Fixed small objects (sessions, configs) | Default (128) | Optimal for typical web backend payloads |
4. Sorted Set Internals: listpack → skiplist + hashtable
The Sorted Set (ZADD/ZRANGE/ZRANK) is Redis's most mechanically interesting data structure. It maintains elements in score order while supporting O(1) member score lookup.
4.1 Dual Encoding
| Condition | Internal Encoding | Characteristics |
|---|---|---|
Elements ≤ zset-max-listpack-entries (default: 128) AND each member ≤ zset-max-listpack-value bytes (default: 64) |
listpack |
Contiguous (member, score) pairs. O(N) range scans — acceptable for small sets. |
| Either threshold exceeded | skiplist + hashtable |
Two simultaneous structures sharing the same SDS member objects. |
4.2 The skiplist + hashtable Dual Structure
When a Sorted Set promotes, Redis creates two simultaneous internal structures that reference the same underlying SDS string objects (no string duplication):
Why a skiplist instead of a balanced BST (AVL, Red-Black)?
Redis chose the skiplist for three reasons:
- Range scans: A skiplist's base layer is a linked list — range queries (
ZRANGE score1 score2) traverse it linearly, which has better CPU cache locality than an in-order BST traversal. - Simpler implementation: No rotations, no rebalancing. Probabilistic level assignment via coin flip.
- The
spanfield: Each skiplist node stores aspanvalue at every level — the number of nodes skipped at that level. This letsZRANKcompute rank in O(log N) by summing spans, not by counting nodes one by one.
The skiplist span field transforms a search structure into a ranking structure. Without span, ZRANK would require traversing every node from the head to count position. With span, it accumulates the rank by summing spans at each level during the O(log N) search. This is why ZRANK and ZREVRANK are O(log N), not O(N).
4.3 Configuration
5. List Internals: listpack → quicklist
A Redis List (LPUSH/RPOP/LRANGE) is used for work queues, activity feeds, and ordered collections.
5.1 The quicklist
In Redis 7.2+, Lists use the quicklist encoding for all sizes above the listpack threshold. A quicklist is a doubly-linked list of listpack nodes — it provides O(1) head/tail push-pop while keeping individual nodes compact.
| Condition | Encoding |
|---|---|
≤ list-max-listpack-size entries (default: 128) AND each ≤ 64 bytes |
listpack |
| Exceeds either | quicklist (linked list of listpack nodes) |
6. Set Internals: intset → listpack → hashtable
Sets (SADD/SMEMBERS/SISMEMBER) have a three-stage encoding chain unique among Redis data structures:
| Stage | Condition | Encoding | Memory |
|---|---|---|---|
| 1 | All members are integers AND count ≤ set-max-intset-entries (default: 512) |
intset |
Sorted array of integers. O(log N) lookup via binary search. Extremely compact. |
| 2 | Non-integer member added OR count > intset limit AND count ≤ set-max-listpack-entries (default: 128) |
listpack |
Contiguous flat array. |
| 3 | Either listpack limit exceeded | hashtable |
Standard hash table. O(1) lookup. |
The intset → listpack demotion is silent and permanent. Adding a single non-integer member to an intset converts it to listpack immediately, even if you then remove that member. An intset Set of 500 user IDs consumes roughly 4KB. The equivalent hashtable Set consumes ~48KB — a 12× memory difference. If your Set values are always integers (user IDs, product IDs, numeric flags), never add a string member.
7. Memory Model: used_memory, Fragmentation & Diagnostic Commands
7.1 used_memory vs used_memory_rss
| Metric | What it means |
|---|---|
used_memory |
Logical memory: sum of all key/value allocations tracked by Redis |
used_memory_rss |
Physical RSS: what the Linux kernel reports as Resident Set Size |
mem_fragmentation_ratio |
rss / used_memory — gap caused by the jemalloc allocator |
jemalloc allocates memory in fixed-size size classes (8, 16, 32, 48, 64, 80, 96, 128 bytes, ...). A 33-byte value is allocated in a 48-byte size class — 15 bytes wasted. Over millions of keys of varying sizes, this internal fragmentation grows. Additionally, when keys are deleted, freed arenas are not immediately returned to the OS — they remain as rss while used_memory drops.
| Fragmentation ratio | Interpretation |
|---|---|
< 1.0 |
OS swapped Redis memory to disk — severe latency cliff, immediate action required |
1.0 – 1.5 |
Healthy — normal jemalloc arena overhead |
1.5 – 2.0 |
Elevated — consider active defragmentation |
> 2.0 |
High fragmentation — risk of OOM kill without actual data growth |
For deep coverage of eviction policies, active defragmentation (activedefrag yes), and the approximate sampled LRU algorithm, see High-Concurrency Cache Hazards §4. This article covers the diagnostic side; that article covers the remediation.
7.2 Diagnostic Commands
8. Key Expiry Mechanics: Why Keys Outlive Their TTL
When you set SET key value EX 60, Redis does not start a timer per key. Expiry is handled by two co-existing mechanisms:
8.1 Lazy Expiry (On Access)
When a client requests an expired key, Redis checks the expiry timestamp at access time and deletes the key before returning nil. This is O(1) per access — no background work.
Consequence: keys that are never accessed after expiry are not removed by lazy expiry alone. They consume memory indefinitely.
8.2 Active Expiry (Background Sampling)
To reclaim memory from unaccessed expired keys, Redis runs an active expiry cycle 10 times per second (configurable via hz). The algorithm is probabilistic:
Keys can outlive their TTL under write-heavy load. If the active expiry cycle consistently finds > 25% expired keys (meaning expiry is outpacing the cycle), it loops — but is still bounded by the cycle time budget. In extreme cases, expired keys can persist for seconds or minutes beyond their TTL before the cycle catches up. This is not a bug; it is a deliberate trade-off to prevent the expiry cycle from consuming excessive CPU.
Summary
| Concept | Rule |
|---|---|
KEYS * footgun |
Blocks the event loop for full O(N) scan — use SCAN cursor MATCH pattern COUNT N in all production code. |
| Key naming | Use {namespace}:{entity-id}:{attribute} schema; colons as separators; lowercase; under 100 bytes. |
| Pipelining | Batches N commands into 1 RTT — a client-side network optimization, not server-side atomicity. |
| String encodings | int for integers; embstr for ≤ 44-byte strings (immutable); raw for larger strings. |
| Hash/ZSet/List encoding | listpack (compact, O(N) lookup) → hashtable or skiplist+hashtable (O(1)/O(log N)) at configurable thresholds. Promotion is one-way per key. |
| Set encoding chain | intset (integers only, 7× denser) → listpack → hashtable. A single non-integer member permanently demotes from intset. |
| OBJECT ENCODING | First diagnostic command for any memory investigation — reveals the actual internal layout of a key. |
| Fragmentation ratio | used_memory_rss / used_memory — healthy at 1.0–1.5; > 2.0 risks OOM kill without actual data growth. |
| Active expiry | Probabilistic — keys can outlive their TTL by seconds under write-heavy load; this is by design. |
What's Next
In Part 2: Redis Persistence — RDB, AOF & Hybrid Mode, we confront the most dangerous Redis misconception: that
SETfollowed byOKmeans the data survives a crash. We deconstructBGSAVEfork-and-copy mechanics, theappendfsyncdurability spectrum, theno-appendfsync-on-rewritefootgun that silently widens your data-loss window to minutes, and why Hybrid mode is the 2025 production default.
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.