Redis Internals
Data Structures Under the Hood
Redis is not just a key-value store. Each data type has a specific internal encoding that changes based on size for memory efficiency.
graph TD
STRING["String<br/>int (if integer)<br/>embstr (<=44 bytes, one alloc)<br/>raw (>44 bytes, separate alloc)"]
LIST["List<br/>listpack (<=128 elements, <=64 bytes each)<br/>quicklist (linked list of listpacks) for large lists"]
HASH["Hash<br/>listpack (<=128 fields, <=64 bytes each)<br/>hashtable (beyond threshold)"]
SET["Set<br/>listpack (<=128 integers)<br/>intset (pure integers, sorted array)<br/>hashtable (mixed or large)"]
ZSET["Sorted Set (ZSet)<br/>listpack (<=128 elements, <=64 bytes each)<br/>skiplist + hashtable (beyond threshold)"]
STREAM["Stream<br/>Radix tree of listpacks<br/>append-only log with consumer groups"]
Why listpack first? Dense memory layout — all elements contiguous. Cache-friendly. Converts to hashtable/skiplist when it exceeds thresholds (configurable via hash-max-listpack-entries).
Skiplist for sorted sets: O(log n) insert/delete/rank. Alternative to B-tree for in-memory sorted data. Each node has random forward pointers at multiple levels.
Memory Model
graph TD
REDIS_PROC["Redis process<br/>(single-threaded event loop)"] --> MEM["Memory allocator<br/>jemalloc (default)"]
MEM --> DICT["Main dictionary<br/>hash table of all keys<br/>expires dict (TTL keys)"]
DICT --> OBJS["Redis objects<br/>encoded as above"]
Redis is single-threaded for command execution. I/O is handled by an event loop (like Node.js). Commands execute atomically — no two commands run concurrently.
Since Redis 6.0: I/O threads for reading/writing network (multi-threaded I/O), but command execution still single-threaded.
Persistence: RDB vs AOF
graph LR
subgraph RDB["RDB (Redis Database Dump)"]
RDB1["Fork child process<br/>child writes point-in-time snapshot<br/>parent continues serving requests<br/>File: dump.rdb<br/>Compact binary format"]
RDB2["Pros: fast restart (load binary)<br/>Cons: data loss since last snapshot"]
end
subgraph AOF["AOF (Append Only File)"]
AOF1["Every write command appended to file<br/>File: appendonly.aof<br/>Human-readable command log"]
AOF2["appendfsync always: fsync per write (slow, zero loss)<br/>appendfsync everysec: fsync per second (balance)<br/>appendfsync no: OS decides (fast, some loss)"]
AOF3["AOF rewrite: compact by replaying state<br/>BGREWRITEAOF command"]
end
subgraph BOTH["RDB + AOF (recommended production)"]
B1["On restart: load RDB (fast)<br/>then replay AOF tail (recent changes)<br/>Best of both worlds"]
end
Eviction Policies
When maxmemory is reached, Redis uses an eviction policy:
| Policy | Behavior | Use case |
|---|---|---|
noeviction |
Return error on writes | Never evict (critical data) |
allkeys-lru |
Evict least recently used key | General cache |
volatile-lru |
LRU among keys with TTL set | Mixed persistent + cache |
allkeys-lfu |
Evict least frequently used | Skewed access patterns |
allkeys-random |
Random eviction | Access pattern unknown |
volatile-ttl |
Evict key closest to expiry | TTL-managed cache |
CONFIG SET maxmemory 4gb
CONFIG SET maxmemory-policy allkeys-lru
Replication
sequenceDiagram
participant MASTER as Redis Master
participant REPLICA as Redis Replica
Note over MASTER,REPLICA: Initial sync (PSYNC command)
REPLICA->>MASTER: PSYNC ? -1 (first time)
MASTER->>MASTER: BGSAVE — fork, create RDB snapshot
MASTER-->>REPLICA: +FULLRESYNC <replication_id> <offset>
MASTER-->>REPLICA: RDB file (full snapshot)
Note over REPLICA: Load RDB
MASTER-->>REPLICA: Buffered commands during RDB transfer
Note over REPLICA: Apply buffered commands
Note over MASTER,REPLICA: Ongoing replication (partial sync on reconnect)
MASTER->>REPLICA: Command stream (same as AOF)
REPLICA->>REPLICA: Apply commands
Note over MASTER,REPLICA: On reconnect (within replication backlog)
REPLICA->>MASTER: PSYNC <replication_id> <last_offset>
MASTER-->>REPLICA: Only missed commands (partial resync)
Replication is asynchronous. Replica may be behind. WAIT numreplicas timeout blocks until N replicas confirm offset — simulate synchronous replication.
Redis Cluster Internals
graph TD
subgraph "Hash slot distribution"
KEY["SET order:123 data"] --> HASH2["CRC16('order:123') % 16384 = 7832"]
HASH2 --> MASTER2["Master 2<br/>owns slots 5461-10922"]
end
subgraph "Gossip protocol"
M1["Master 1"] -->|"heartbeat every 100ms"| M2["Master 2"]
M2 --> M3["Master 3"]
M3 --> M1
Note["Each node knows state of all others via gossip<br/>Failure detection: PFAIL after cluster-node-timeout<br/>FAIL after majority agree"]
end
ASK vs MOVED redirects:
MOVED: key permanently lives on different node (after slot migration complete)ASK: key temporarily on different node (during slot migration) — one-time redirect
Lua Scripting (Atomic Operations)
# INCR with conditional limit — atomic via Lua
EVAL "
local current = redis.call('GET', KEYS[1])
if current and tonumber(current) >= tonumber(ARGV[1]) then
return 0
end
return redis.call('INCR', KEYS[1])
" 1 rate:user:123 100
# Lua scripts execute atomically — no race conditions between GET and INCR
Key Metrics
# Memory usage (alert > 80% of maxmemory)
redis_memory_used_bytes / redis_memory_max_bytes > 0.8
# Hit rate (alert < 90%)
rate(redis_keyspace_hits_total[5m]) /
(rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m])) < 0.9
# Replica count (counts replicas, NOT lag — for lag use master_repl_offset − slave_repl_offset)
redis_connected_slaves < 1
# Evicted keys per second (should be 0 for non-cache workloads)
rate(redis_evicted_keys_total[1m]) > 0
# Command latency
redis_commands_duration_seconds_total / redis_commands_processed_total > 0.001
Pub/Sub
# Publisher
PUBLISH notifications '{"type":"order_paid","id":"123"}'
# Subscriber (blocks waiting for messages)
SUBSCRIBE notifications
# Or pattern-subscribe
PSUBSCRIBE order.* # matches order.paid, order.cancelled, etc.
Pub/Sub is fire-and-forget — messages are lost if no subscriber is connected. For durability use Streams instead.
Redis Streams (Persistent Message Queue)
# Producer: append to stream
XADD orders * user_id 123 amount 99.99 status paid
# Returns: "1700000000000-0" (auto-generated ID: timestamp-sequence)
# Consumer group: multiple consumers, each gets different messages
XGROUP CREATE orders payments $ MKSTREAM
# Consumer reads (and acknowledges)
XREADGROUP GROUP payments consumer-1 COUNT 10 BLOCK 0 STREAMS orders >
# > means: give me undelivered messages for this consumer
XACK orders payments 1700000000000-0 # mark as processed
# Re-deliver messages not acknowledged after 30 seconds
XAUTOCLAIM orders payments consumer-1 30000 0-0
# Check pending (unacknowledged) messages
XPENDING orders payments - + 10
Streams = persistent, ordered, consumer groups, exactly-once delivery. Much more powerful than Pub/Sub.
Sentinel vs Cluster
graph TD
subgraph "Sentinel (HA without sharding)"
SM["Master<br/>all writes + reads"] --> SR1["Replica 1"]
SM --> SR2["Replica 2"]
SENT1["Sentinel 1"] & SENT2["Sentinel 2"] & SENT3["Sentinel 3"] -->|"monitor + elect"| SM
end
subgraph "Cluster (sharding + HA)"
CM1["Master 1<br/>slots 0-5460"] --> CR1["Replica 1"]
CM2["Master 2<br/>slots 5461-10922"] --> CR2["Replica 2"]
CM3["Master 3<br/>slots 10923-16383"] --> CR3["Replica 3"]
end
| Sentinel | Cluster | |
|---|---|---|
| Sharding | No — single dataset | Yes — 16384 hash slots |
| Max memory | Single node | N × node memory |
| Multi-key ops | Full support | Only within same slot |
| Failover | Sentinel-orchestrated (~15s) | Gossip-based (~15s) |
| Use when | Dataset fits one node | Dataset needs horizontal scale |
WAIT Command — Synchronous Replication
# Ensure at least N replicas have received writes before returning
SET key value
WAIT 1 1000 # wait for 1 replica, timeout 1000ms
# Returns: number of replicas that ACKed
# Simulate synchronous replication for critical writes
SET account:alice:balance 500
WAIT 1 500 # 0-RTT usually, blocks if replica is lagging
Key Expiry Internals
Redis uses two mechanisms to expire keys:
- Lazy expiry: checks TTL only when the key is accessed — no CPU cost, but expired keys linger in memory
- Active expiry: background job samples 20 random keys every 100ms, deletes expired ones, repeats if >25% were expired
Consequence: a key's TTL can expire but the key still occupies memory until accessed or the background job finds it. For memory-sensitive workloads, use maxmemory-policy to force eviction.
Pipeline and MULTI/EXEC
# Pipelining: send multiple commands without waiting for responses
# (network optimization — reduces round trips)
redis-cli --pipe << 'EOF'
SET key1 val1
SET key2 val2
INCR counter
EOF
# Transactions: atomic execution of multiple commands
MULTI
SET account:alice 500
SET account:bob 300
INCR tx_counter
EXEC
# All three execute atomically — no other client's commands interleaved
# Unlike DB transactions: no rollback on command error (EXEC always runs all)
Debugging and Profiling
# Real-time command monitor (like tcpdump for Redis)
redis-cli MONITOR
# Shows every command as it arrives — use briefly, high overhead
# Slow log (commands > slowlog-log-slower-than microseconds)
redis-cli CONFIG SET slowlog-log-slower-than 10000 # 10ms
redis-cli SLOWLOG GET 10
# 1) 1) (integer) 14 # log entry ID
# 2) (integer) 1700000000 # timestamp
# 3) (integer) 15000 # execution time (microseconds)
# 4) 1) "KEYS" # command + args (KEYS is O(n) — never use in prod)
# 2) "*"
# Memory analysis
redis-cli MEMORY USAGE mykey # bytes used by one key
redis-cli MEMORY DOCTOR # automated analysis
redis-cli --bigkeys # scan for large keys (run off-peak)
redis-cli --memkeys # sample keys by memory usage
Cluster Failure Modes
Slot coverage loss — the cluster goes read-only
Redis Cluster requires all 16384 hash slots to be covered by a reachable master. If a master goes down AND its replica fails to be promoted (or there is no replica), those slots become unavailable. The cluster refuses writes to uncovered slots.
# Check cluster health
redis-cli -h redis-node-1 -p 6379 CLUSTER INFO
# cluster_state:ok ← healthy
# cluster_state:fail ← one or more slots uncovered
# Find which slots are uncovered
redis-cli -h redis-node-1 -p 6379 CLUSTER NODES | grep fail
# <node-id> <ip>:6379 master,fail - ... ← failed master
# Manual failover (if replica is running but hasn't promoted)
redis-cli -h <replica-host> -p 6379 CLUSTER FAILOVER
# or force (ignores replication lag — may lose recent writes)
redis-cli -h <replica-host> -p 6379 CLUSTER FAILOVER FORCE
Recovery checklist:
# After failed node comes back
redis-cli -h redis-node-1 CLUSTER MEET <recovered-node-ip> 6379
redis-cli -h redis-node-1 CLUSTER REPLICATE <new-master-id>
# Resync: node downloads full RDB from master (can take minutes for large datasets)
redis-cli -h <recovered-node> REPLICATION # watch master_sync_in_progress
Split-brain — cluster partitioned
Redis Cluster prevents split-brain by requiring quorum (majority of masters) to elect new masters. With 6 nodes (3 masters + 3 replicas), losing one AZ means:
- 1 master unreachable → its replica promotes ✓
- 2 masters unreachable (minority) → quorum lost → cluster goes down ✗
3-master cluster loses 2 masters simultaneously:
Masters remaining: 1 (cannot form quorum of 2 out of 3)
Result: cluster_state:fail, writes rejected
Fix: multi-AZ with odd number of masters ≥ 3
Multi-AZ layout for resilience:
AZ-a: master-1 (slots 0-5460) + replica-4
AZ-b: master-2 (slots 5461-10922) + replica-5
AZ-c: master-3 (slots 10923-16383) + replica-6
# Replicas are in DIFFERENT AZ from their master
# AZ loss takes one master and one replica (for a different master)
# Quorum of 2 masters remains → cluster stays up
Hot key problem
A hot key is a single key receiving a disproportionate share of traffic. In Redis Cluster, a hot key maps to one slot → one master → that node becomes the bottleneck.
Detection:
# redis-cli hot key analysis (requires maxmemory-policy != noeviction)
redis-cli -h redis-node-1 --hotkeys
# OUTPUT: hot key 'user:session:12345' - freq: 50000/sec
# Monitor in real time
redis-cli -h redis-node-1 MONITOR | grep "GET\|SET" | head -100
# Warning: MONITOR is O(n) per command, use sparingly in production
# Use redis-cell or keydb for rate info
redis-cli -h redis-node-1 OBJECT FREQ <keyname> # LFU policy only
Solutions:
1. Client-side caching (Redis 6+ tracking mode)
Client caches value locally; server sends invalidation when key changes
→ reduces hot key traffic by 90%+ for mostly-read keys
2. Key sharding — append suffix to spread across slots
"user:session" → "user:session:{0}", "user:session:{1}", ..., "user:session:{N}"
Client randomly picks a shard; reads from any, writes to all
→ N shards = N nodes share the load
N = 10-50 for very hot keys
3. Local in-process cache (L1)
Store hot key in application memory (sync with Redis TTL)
→ near-zero latency, no network, but stale by TTL window
4. Read replicas
READONLY command on replica allows reads
→ distributes read traffic across replica + master
redis-cli -h <replica> READONLY
GET user:session:12345 # served by replica
# Python: client-side sharding for hot key
import hashlib, random
def hot_key_get(redis_client, base_key: str, shards: int = 10) -> str:
shard = random.randint(0, shards - 1)
return redis_client.get(f"{base_key}:{shard}")
def hot_key_set(redis_client, base_key: str, value: str, shards: int = 10):
pipe = redis_client.pipeline()
for i in range(shards):
pipe.set(f"{base_key}:{i}", value, ex=300)
pipe.execute()
Sentinel vs Cluster — decision guide
| Sentinel | Cluster | |
|---|---|---|
| Use case | Single dataset, HA failover | Horizontal scale + HA |
| Data sharding | No (all nodes have full dataset) | Yes (16384 hash slots) |
| Scale-out | No | Yes — add masters for more capacity |
| Multi-key ops | All keys work | Keys must be in same slot (use hash tags {user}) |
| Complexity | Low | High |
| Min nodes | 3 Sentinels + 1 master + 1 replica | 6 nodes (3 master + 3 replica) |
| Failover time | ~30s (default) | ~15s (faster gossip-based) |
| When to use | <100GB dataset, simplicity preferred | >100GB or >1M ops/sec |
Hash tags for multi-key ops in cluster:
Without hash tag:
MGET user:1:name user:1:email → may be on different slots → CROSSSLOT error
With hash tag (curly braces define the slot key):
MGET {user:1}:name {user:1}:email → both hash to "user:1" → same slot → works