Why Redis Is More Than Just a Cache
Redis is widely recognized as an in-memory caching layer, yet its architectural role extends far beyond temporary key-value storage. It is an in-memory, versatile data structure store supporting strings, lists, sets, sorted sets, bitmaps, hyperloglogs, and geospatial indexes.
From an engineering perspective, Redis presents a fascinating design paradigm: how can a single-threaded server achieve sub-millisecond latencies while managing tens of thousands of concurrent TCP connections?
Understanding Redis requires deconstructing its core subsystems from the ground up: network I/O multiplexing, the serialization protocol, internal memory representations, eviction algorithms, and persistence mechanics.
The Single-Threaded Architecture and I/O Multiplexing
The perception that Redis is “single-threaded” refers strictly to its execution engine: the command processing cycle. Modern Redis uses background threads for non-blocking deletions (UNLINK), fsync operations, and file I/O, but command execution operates on a single execution loop.
The Core Event Loop
Traditional blocking network architectures allocate a thread or process per connection. This model degrades at scale due to memory overhead (thread stacks) and the kernel cost of frequent context switches. Redis sidesteps this bottleneck using non-blocking I/O and I/O multiplexing.
+-------------------------------------------------------------------------+
| Client A |
| Client B |
| Client C |
+-----------------------------------+-------------------------------------+
| (TCP Connections)
v
+------------------------------+
| Linux Kernel (epoll / kqueue)|
+--------------+---------------+
| Ready Events (FDs)
v
+-------------------------------------------------------------------------+
| Redis Event Loop |
| |
| +-----------------------+ +-------------------+ +------------+ |
| | aeProcessEvents() | --> | Read Socket / RESP| --> | Execute | |
| | (epoll_wait) | | Parsing | | Engine | |
| +-----------------------+ +-------------------+ +------+-----+ |
| | |
| v |
| +----------+-----+ |
| | In-Memory Dict | |
| +----------------+ |
+-------------------------------------------------------------------------+
- Event Abstraction Layer: Redis abstracts OS-specific notification systems behind a lightweight wrapper (e.g.,
ae.c). On Linux, it uses epoll; on BSD/macOS, it uses kqueue; on other POSIX variants, it falls back to select.
- Non-Blocking Sockets: Every accepted client connection is registered as a file descriptor (FD) set to non-blocking mode (
O_NONBLOCK).
- Event Notification: Instead of polling each connection, the event loop blocks inside
epoll_wait() until the OS kernel notifies the process that one or more file descriptors are readable, writable, or in an error state.
- Linear Dispatch: The event loop iterates over the signaled file descriptors sequentially, reading incoming bytes into a per-client buffer, parsing the command, executing the state mutation in-memory, and appending responses to the client write buffer.
By ensuring every in-memory operation completes in nanoseconds to microseconds, a single execution thread can process hundreds of thousands of operations per second without incurring synchronization overhead, lock contention, or race conditions.
RESP: REdis Serialization Protocol
To keep serialization overhead minimal, Redis communicates with clients using RESP (REdis Serialization Protocol). RESP is a human-readable, binary-safe text protocol that is straightforward to parse.
In RESP, the first byte identifies the payload type:
| Type Identifier | Data Type | Example Wire Format | Parsed Value |
|---|
+ | Simple String | `+OK\r | |
| ` | OK | | |
- | Error | `-Error message\r | |
| ` | Error: Error message | | |
: | Integer | `:1000\r | |
| ` | 1000 | | |
$ | Bulk String | `$6\r\nfoobar\r | |
| ` | foobar | | |
* | Array | `*2\r\n3\nfoo˚\n˚3\r\nbar\r | |
| ` | ["foo", "bar"] | | |
Parsing Command Buffers
A command sent from the client (e.g., SET mykey myval) arrives as an array of bulk strings:
*3\r
$3\r
SET\r
$5\r
mykey\r
$5\r
myval\r
Because length prefixes ($5) precede the payload, the parser avoids dynamic scans for string terminators, enabling efficient zero-copy buffer slicing directly into memory structures.
Memory Modeling and Object Representation (robj)
Redis does not directly store raw strings or byte arrays in its internal dictionary. Every key and value is wrapped inside a generic structure known as a Redis Object (robj).
typedef struct redisObject {
unsigned type:4; // OBJ_STRING, OBJ_LIST, OBJ_SET, OBJ_ZSET, OBJ_HASH
unsigned encoding:4; // Internal representation (e.g., OBJ_ENCODING_RAW, OBJ_ENCODING_INT)
unsigned lru:24; // LRU time (relative to global lru_clock) or LFU frequency counter
int refcount; // Reference counter for memory sharing / garbage collection
void *ptr; // Pointer to the actual memory layout (e.g., sds string, ziplist)
} robj;
Compact Encodings
Redis achieves exceptional memory density by dynamically switching internal encodings based on data size:
- Integers (
OBJ_ENCODING_INT): If a string value parses as a 64-bit signed integer, Redis stores the long directly inside the void *ptr field instead of allocating a separate memory block.
- Embedded Strings (
OBJ_ENCODING_EMBSTR): For short strings (typically ≤44 bytes), the robj metadata header and the string buffer (SDS - Simple Dynamic String) are allocated together in a single contiguous malloc() block, maximizing CPU cache locality and eliminating pointer indirection.
- Raw Strings (
OBJ_ENCODING_RAW): For larger strings, memory is allocated in two disconnected chunks: the robj container and the SDS buffer.
Expiration and Eviction Mechanics
Because memory is finite, Redis requires robust lifecycle controls through Time-To-Live (TTL) expiration and out-of-memory eviction policies.
Dual-Strategy TTL Expiration
Redis manages expiring keys using two complementary patterns:
- Passive Expiration (Lazy): When a client queries a key, Redis checks whether it is past its expiration deadline. If expired, the key is destroyed on the fly and
nil is returned.
- Active Expiration (Periodic): A background timer runs periodically (e.g., 10 times per second) sampling keys with TTLs. If the sample reveals that more than a threshold percentage (typically 25%) are expired, the routine loops aggressively to purge stale keys, keeping memory consumption in check.
True LRU vs. Approximated LRU
A textbook Least Recently Used (LRU) cache requires an intrusive doubly linked list running through every element. Moving a node to the head of the list on every access adds significant pointer overhead (16 to 24 bytes per entry) and can introduce cache invalidation overhead on the CPU.
Instead, Redis uses an Approximated LRU Algorithm:
+----------------------------------------------------------------+
| Random Sampling of Key Space (e.g., N=5) |
| [ Key 1 ] [ Key 2 ] [ Key 3 ] [ Key 4 ] [ Key 5 ] |
+-------+-----------+-----------+-----------+-----------+--------+
| | | | |
v v v v v
(LRU: 120s) (LRU: 450s) (LRU: 30s) (LRU: 800s) (LRU: 60s)
|
v
+-----------------------------------------+
| Pick key with longest idle time (800s) |
| Evict from memory |
+-----------------------------------------+
- Each
redisObject records a 24-bit timestamp (lru) marking its last access time.
- When memory limits are breached (
maxmemory), Redis samples N random keys (configured by maxmemory-samples, typically set between 5 and 10).
- It calculates each sampled key’s idle time:
idle_time = current_lru_clock - robj.lru.
- The key with the highest idle time is evicted from memory.
As sample sizes increase, approximated LRU approaches the accuracy of exact LRU while requiring zero pointer manipulation during read operations.
Command Pipelining and Persistence
Pipelining: Amortizing Network Round-Trip Time
In standard request-response operations, execution time is bound by network latency (RTT):
Total Latency=N×(RTT+Texecute)
Pipelining allows clients to buffer multiple commands and stream them over the TCP socket in a single write operation. Redis processes each command sequentially from the kernel socket buffer and aggregates responses into an output buffer, returning them all at once. This shifts network overhead from per-command overhead to bulk operations, approaching memory-speed limits.
Persistence: Append-Only File (AOF) & fsync Trade-offs
Redis handles durability by logging every write operation to disk using the Append-Only File (AOF):
Client Write ---> Redis Command Engine ---> In-Memory Dict
|
v
AOF Buffer (User Memory)
|
| write() syscall
v
OS Page Cache (Kernel)
|
| fsync() control
v
Physical Disk Storage
The frequency of the fsync() system call determines the durability trade-off:
appendfsync no: The OS handles flushing the kernel page cache. Maximizes throughput, but risks losing seconds of data if the host crashes.
appendfsync always: Calls fsync() after every command before returning a response. Guarantees maximum durability, but drops write throughput to the IOPS limit of the physical storage disk.
appendfsync everysec: A dedicated background thread calls fsync() once per second. This serves as the recommended balance, offering high throughput while capping catastrophic data loss at roughly one to two seconds of writes.
Summary of Architectural Trade-offs
| Engineering Dimension | Implementation Choice | Trade-Off | Advantage |
|---|
| Concurrency | Single-threaded event loop | Susceptible to head-of-line blocking on O(N) operations | Eliminates locking, race conditions, and context-switching overhead |
| I/O Engine | Non-blocking OS primitives (epoll/kqueue) | Requires an event-driven, non-blocking code pattern | Supports high socket concurrency using minimal memory overhead |
| Eviction | Approximated LRU sampling | Slight statistical divergence from exact LRU ordering | Zero memory overhead for linked-list pointers; zero pointer mutations on reads |
| Durability | Configurable AOF (everysec) | Trade-off between I/O performance and absolute crash safety | Decouples memory-speed command execution from physical disk latency |