Deconstructing Redis Internals: Architecture, Event Loops, and Memory Mechanics

Arpit Bhayani

Arpit Bhayani

Oct 14, 2022 • 8 min read

Play

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 | |
|                                                      +----------------+ |
+-------------------------------------------------------------------------+
  1. 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.
  2. Non-Blocking Sockets: Every accepted client connection is registered as a file descriptor (FD) set to non-blocking mode (O_NONBLOCK).
  3. 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.
  4. 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 IdentifierData TypeExample Wire FormatParsed 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\nfoo\r\n3\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:

  1. 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.
  2. Embedded Strings (OBJ_ENCODING_EMBSTR): For short strings (typically 44\le 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.
  3. 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                       |
                       +-----------------------------------------+
  1. Each redisObject records a 24-bit timestamp (lru) marking its last access time.
  2. When memory limits are breached (maxmemory), Redis samples NN random keys (configured by maxmemory-samples, typically set between 5 and 10).
  3. It calculates each sampled key’s idle time: idle_time = current_lru_clock - robj.lru.
  4. 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)\text{Total Latency} = N \times (\text{RTT} + T_{\text{execute}})

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 DimensionImplementation ChoiceTrade-OffAdvantage
ConcurrencySingle-threaded event loopSusceptible to head-of-line blocking on O(N)O(N) operationsEliminates locking, race conditions, and context-switching overhead
I/O EngineNon-blocking OS primitives (epoll/kqueue)Requires an event-driven, non-blocking code patternSupports high socket concurrency using minimal memory overhead
EvictionApproximated LRU samplingSlight statistical divergence from exact LRU orderingZero memory overhead for linked-list pointers; zero pointer mutations on reads
DurabilityConfigurable AOF (everysec)Trade-off between I/O performance and absolute crash safetyDecouples memory-speed command execution from physical disk latency
Arpit Bhayani

Principal Engineer II at Razorpay - building Agent Studio, Ex-staff engg at GCP Memorystore & Dataproc, Creator of DiceDB, ex-Amazon Fast Data, ex-Director of Engg. SRE and Data Engineering at Unacademy. I spark engineering curiosity through my no-fluff engineering videos on YouTube and my courses