Ashok G

Draft

Redis internals: the protocol, the event loop and why one thread is fast

Redis runs your commands one at a time, on one thread, and still handles very high request rates. That sounds like a contradiction until you see where a request actually spends its time.

This article follows a command from the socket to the data structure and back. It starts with the bytes on the wire, builds a small working server, then goes through the memory layouts and the machinery around them: expiry, eviction, persistence, replication and cluster.

The short answer

Redis is fast on one thread for five reasons:

  1. The data is in memory. A command touches RAM, not a disk, so its work takes on the order of a microsecond.
  2. The commands are small. Most are O(1) or O(log n). Nothing plans a query.
  3. There are no locks. One thread owns the data, so nothing waits on anything else, and no cache lines bounce between cores.
  4. The kernel does the waiting. epoll or kqueue reports which sockets are ready, so one thread can serve thousands of connections and never sits blocked on any of them.
  5. The formats are cheap. The protocol is length-prefixed and simple to parse, and small values use compact, cache-friendly layouts.

The bottleneck is rarely the data work. It is the network: system calls and round trips. That is why pipelining can multiply throughput, as measured below.

The protocol: RESP

Redis clients talk to the server over TCP using RESP, the Redis Serialization Protocol. The first byte of every value gives its type. Lines end in \r\n.

First byteTypeExample
+Simple string+OK\r\n
-Error-ERR unknown command\r\n
:Integer:42\r\n
$Bulk string$3\r\nash\r\n
*Array*2\r\n$3\r\nGET\r\n$4\r\nname\r\n

A request is always an array of bulk strings. SET name ash becomes:

*3\r\n
$3\r\nSET\r\n
$4\r\nname\r\n
$3\r\nash\r\n

Those are the actual bytes, split across lines for reading. A reply depends on the command.

Three design choices matter here.

Bulk strings carry their length. $3 says exactly three bytes follow. The parser does not scan for a terminator, so values can hold any binary data, including \r\n itself.

Parsing needs almost no state. Read a line, look at the first byte, and you know what comes next.

It is readable by a person. You can speak it by hand. With a Redis server on the default port:

printf 'PING\r\n' | nc -q1 localhost 6379
printf '*2\r\n$3\r\nGET\r\n$4\r\nname\r\n' | nc -q1 localhost 6379

The first line uses the inline form, a plain space-separated command that exists for humans at a terminal. On macOS, replace -q1 with -w1.

RESP2 and RESP3

Every connection starts in RESP2. A client opts in to RESP3, added in Redis 6.0, by sending HELLO 3.

RESP2 has no way to say “this array is a map”, so HGETALL returns a flat list and each client library has to know that this command’s reply should become a dictionary. RESP3 fixes that by adding types.

First byteRESP3 type
_Null
#Boolean
,Double
(Big number
!Bulk error
=Verbatim string
%Map
~Set
>Push
|Attribute

Push is the important one. In RESP2, a connection in subscribed mode cannot run ordinary commands, because a reply and a published message would be indistinguishable. A push value is marked as out-of-band, so both can share a connection. Client-side caching relies on this: with CLIENT TRACKING ON, the server pushes invalidation messages when a key the client cached has changed.

A real server in 100 lines

Nothing shows the design better than building it. This Python server speaks enough RESP to work with a real protocol, and uses the same structure as Redis: one thread, one loop, and non-blocking sockets.

import selectors, socket, time

store = {}          # key -> bytes
expires = {}        # key -> deadline in monotonic seconds
sel = selectors.DefaultSelector()

def encode(value):
    if value is None:
        return b"$-1\r\n"
    if isinstance(value, int):
        return b":%d\r\n" % value
    if isinstance(value, Exception):
        return b"-ERR %s\r\n" % str(value).encode()
    if value == "OK" or value == "PONG":
        return b"+%s\r\n" % value.encode()
    return b"$%d\r\n%s\r\n" % (len(value), value)

def parse(buf):
    """Return (command, bytes used), or (None, 0) if incomplete."""
    if not buf.startswith(b"*"):
        end = buf.find(b"\r\n")                  # inline command
        return (bytes(buf[:end]).split(), end + 2) if end >= 0 else (None, 0)
    end = buf.find(b"\r\n")
    if end < 0:
        return None, 0
    count, pos, args = int(buf[1:end]), end + 2, []
    for _ in range(count):
        end = buf.find(b"\r\n", pos)
        if end < 0:
            return None, 0
        size = int(buf[pos + 1:end])
        pos = end + 2
        if len(buf) < pos + size + 2:
            return None, 0
        args.append(bytes(buf[pos:pos + size]))
        pos += size + 2
    return args, pos

def alive(key):
    deadline = expires.get(key)
    if deadline is not None and time.monotonic() > deadline:   # lazy expiry
        store.pop(key, None)
        expires.pop(key, None)
    return key in store

def execute(args):
    cmd = args[0].upper()
    if cmd == b"PING":
        return "PONG"
    if cmd == b"SET":
        store[args[1]] = args[2]
        expires.pop(args[1], None)
        return "OK"
    if cmd == b"GET":
        return store[args[1]] if alive(args[1]) else None
    if cmd == b"INCR":
        value = int(store[args[1]]) + 1 if alive(args[1]) else 1
        store[args[1]] = b"%d" % value
        return value
    if cmd == b"EXPIRE":
        if not alive(args[1]):
            return 0
        expires[args[1]] = time.monotonic() + int(args[2])
        return 1
    if cmd == b"SLOW":                           # stands in for KEYS or a big sort
        time.sleep(float(args[1]))
        return "OK"
    return ValueError(f"unknown command {cmd.decode()}")

def on_accept(server):
    conn, _ = server.accept()
    conn.setblocking(False)
    sel.register(conn, selectors.EVENT_READ, bytearray())

def on_read(conn, buf):
    data = conn.recv(65536)
    if not data:
        sel.unregister(conn)
        conn.close()
        return
    buf += data
    out = bytearray()
    while buf:
        args, used = parse(buf)
        if args is None:
            break
        del buf[:used]
        out += encode(execute(args))
    if out:
        conn.sendall(out)

def serve(port=6399):
    server = socket.socket()
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind(("127.0.0.1", port))
    server.listen()
    server.setblocking(False)
    sel.register(server, selectors.EVENT_READ)
    while True:                                  # the event loop
        for key, _ in sel.select():
            if key.data is None:
                on_accept(key.fileobj)
            else:
                on_read(key.fileobj, key.data)

if __name__ == "__main__":
    serve()

Every connection has its own buffer. That is how the server copes with TCP, which delivers a stream of bytes rather than messages. A read may contain half a command or twenty commands. parse returns “incomplete” for the first case, and the loop in on_read handles the second.

Real Redis has the same shape. It has a per-client query buffer, and a function that parses as many complete commands as it finds.

Talking to it

A short client sent these commands over a socket. The bytes that came back:

SET name ash   -> +OK\r\n
GET name       -> $3\r\nash\r\n
GET missing    -> $-1\r\n
INCR hits      -> :1\r\n
INCR hits      -> :2\r\n
NOPE           -> -ERR unknown command NOPE\r\n
PING (inline)  -> +PONG\r\n

$-1 is how RESP2 writes null. Sending a single command in two separate writes, split in the middle, returned +OK as normal, so the buffering works. A key with EXPIRE t 1 returned its value straight away and $-1 after 1.1 seconds.

Measurement 1: atomicity needs no locks

Eight client threads each sent INCR c 5,000 times, on eight connections, at the same time.

8 clients INCR: $5\r\n40000\r\n

The total is exactly 40,000. No update was lost, and the server contains no lock. A read-modify-write cannot be interrupted because nothing else runs until execute returns. Every Redis command gets this guarantee for free.

Measurement 2: pipelining

Pipelining means sending many commands without waiting for each reply. This is 50,000 SET commands on loopback, on one laptop, against the Python server:

Commands per writeCommands per second
146,890
10319,428
100777,819
1000905,365

That is a factor of 19, and the server did not get any faster. With one command per write, every command costs a send, a recv, a trip through select and two context switches. Batching pays those costs once per batch.

The numbers show the Python server, so ignore their absolute size. The shape is what carries over. It also understates the real gain, since these figures come from loopback. Across a network with a 1 ms round trip, a client doing one command at a time cannot exceed about 1,000 per second on one connection, whatever the server’s speed.

Measurement 3: the price of one thread

One client sent PING every 20 ms while another ran a command that blocks for half a second.

PING median 0.41 ms, worst 489 ms

The slow command stopped everything. Every connection waited, including ones that only wanted a PING. This is the central trade-off of the design: one thread removes locks and also removes any isolation between requests. One KEYS * on a large dataset, a DEL of a huge key on an old version, or a long Lua script stalls every client.

The event loop in real Redis

Redis has its own small event library, ae. It wraps the best mechanism on each platform: epoll on Linux, kqueue on BSD and macOS, and select as a fallback.

The main loop, simplified:

while not stopped:
    beforeSleep()             # write pending replies, fsync the AOF, handle expiry
    events = epoll_wait(...)  # sleep until a socket is ready or a timer is due
    for each ready socket:
        read into the client's query buffer
        parse complete commands
        execute them, appending replies to the client's output buffer
    run time events           # serverCron

Two details are worth noting.

Replies are not written immediately. A command appends its reply to a buffer on the client. Just before the next sleep, beforeSleep writes the pending replies out. This groups work, and it lets the AOF be written before clients see a reply.

serverCron does the housekeeping. It runs hz times per second, 10 by default, and handles things like background expiry, rehashing steps, client timeouts, and starting scheduled saves.

“Single-threaded” is not quite true

Commands execute on one thread. Other work does not.

  • Background I/O threads handle slow operations such as closing files, syncing the AOF to disk, and freeing memory.
  • Lazy freeing, added in 4.0, moves large deallocations to the background. UNLINK removes a key from the keyspace immediately and frees the memory later, unlike DEL, which frees it inline. FLUSHALL ASYNC works the same way.
  • Forked children write snapshots and rewrite the AOF. These are separate processes.
  • I/O threads, added in 6.0 and enabled with io-threads, move socket reading, writing and protocol parsing onto extra threads. Redis 8 reworked this implementation.

I/O threads show where the time goes. The Redis team kept command execution on one thread and put threads on the network side, because that is where the cost was. The guarantee that commands run one at a time is unchanged.

Why the latency of a command is so low

A rough budget for one small command on one connection:

StepRough cost
Round trip inside a datacentre100 to 500 µs
read and write system callsSingle-digit µs each
Parse the requestWell under 1 µs
Hash lookup, with a few cache missesAbout 100 to 500 ns

These are orders of magnitude, not measurements. The point is the ratio. The data structure work is the smallest line on the table. Adding a lock and a second thread to speed up the last row would do little, and would add contention. Horizontal scaling, by running several Redis processes, buys more.

How memory is laid out

Redis gets a lot of speed and compactness from choosing a different layout for small and large values, and switching automatically. You can see the current choice with OBJECT ENCODING.

Every value starts with a small header, the redisObject:

struct redisObject {
    unsigned type:4;       // string, list, set, zset, hash...
    unsigned encoding:4;   // how it is stored in memory
    unsigned lru:24;       // access clock, or LFU data
    int refcount;
    void *ptr;             // the data
};                         // 16 bytes on a 64-bit system

The type is what you see. The encoding is how it is held.

Strings: SDS

Redis does not use plain C strings. It uses SDS, the Simple Dynamic String, which puts a small header before the bytes:

+-----+-------+-------+------------------+----+
| len | alloc | flags | bytes...         | \0 |
+-----+-------+-------+------------------+----+
                        ^ pointer handed to the caller

This gives a length lookup in O(1), rather than a scan, plus binary safety, and spare capacity so that repeated appends do not reallocate each time. The trailing \0 means an SDS can still be passed to C functions. The header comes in several sizes so that short strings do not pay for a 64-bit length.

A string value has three encodings:

SET a 12345            OBJECT ENCODING a  ->  int
SET b "hello"          OBJECT ENCODING b  ->  embstr
SET c <64 bytes>       OBJECT ENCODING c  ->  raw

int stores the number in the pointer field itself, with no allocation. embstr covers strings up to 44 bytes, and places the header and string in one allocation, so reading it touches one cache line region and needs one malloc. That 44 comes from fitting in a 64-byte allocation. raw uses two allocations.

Small collections: listpack

A small hash or list could be a hash table or a linked list, but those are full of pointers. On a 64-bit machine, each pointer costs eight bytes and sends the CPU to another cache line.

A listpack is one flat block of memory holding entries end to end:

| total bytes | count | entry | entry | entry | ... | end |

Finding a field is a linear scan, which sounds worse than a hash lookup. For a few dozen entries in one contiguous block it often is not, because the prefetcher loves sequential reads and the block has no pointers to chase. This is the same effect that makes arrays beat linked lists in practice.

HSET user name ash age 30
OBJECT ENCODING user      ->  listpack

Add enough fields, or one long enough value, and Redis converts it to a hash table. The conversion is one-way. The thresholds are configurable, and CONFIG GET *listpack* shows the current ones.

Listpack replaced an older format called ziplist, which had a cascading-update problem: growing one entry could force every following entry to be rewritten. A listpack entry records its own length, which removes the dependency between neighbours.

Lists: quicklist

A large list is a quicklist, which is a doubly linked list of listpacks. Pushing to either end is O(1), and memory stays compact because most entries sit inside flat blocks. Nodes in the middle can be compressed with LZF, controlled by list-compress-depth, because queues rarely touch the middle.

Sets: intset

A set of only integers can be an intset, a sorted array of integers. Lookup is a binary search. It uses the narrowest width that fits, 16, 32 or 64 bits, and widens all entries when a bigger value arrives.

SADD s 1 2 3
OBJECT ENCODING s      ->  intset
SADD s hello
OBJECT ENCODING s      ->  listpack

From 7.2, small mixed sets use a listpack. Large sets use a hash table.

Sorted sets: a skip list and a hash table

A large sorted set holds two structures at once.

  • A hash table maps each member to its score, so ZSCORE is O(1).
  • A skip list keeps members ordered, so ZRANGE and ZADD are O(log n).

A skip list is a sorted linked list with extra express lanes.

Level 3: 1 -------------------------> 50
Level 2: 1 --------> 20 ------------> 50
Level 1: 1 --> 7 --> 20 --> 33 -----> 50
Level 0: 1 --> 7 --> 20 --> 33 --> 41 -> 50

To search, start at the top lane, move right until the next node is too far, and drop down. Each new node chooses its height randomly, with each extra level having a probability of 1/4 in Redis. This gives O(log n) on average with no rebalancing.

Redis extends the structure by storing a span on each link: how many nodes that link jumps over. Adding the spans along a search gives a member’s rank, which is how ZRANK runs in O(log n). Salvatore Sanfilippo, who created Redis, chose skip lists over balanced trees partly because they are simpler to implement, and easy to extend in this way.

Hash tables and incremental rehashing

The whole keyspace is a hash table, and so are large hashes and sets. A growing hash table has to resize, and copying millions of entries at once would stop the server for a long time, which one thread cannot afford.

So Redis keeps two tables and moves entries a few at a time.

ht[0]  (old, 4 buckets)         ht[1]  (new, 8 buckets)
[0] -> moved                    [0] -> a
[1] -> moved                    [1] -> b -> c
[2] -> d -> e    <- rehash index
[3] -> f

During a rehash:

  • Each lookup, insert or delete also migrates one bucket.
  • serverCron migrates more in small timed slices, so an idle server also finishes.
  • A lookup checks both tables.
  • An insert goes only to the new table, so the old one only shrinks.

A single huge pause turns into a tiny cost on many operations. This is a pattern to remember: on a single thread, large work has to be broken into small pieces. You will see it again in expiry, and in SCAN.

Redis also avoids rehashing while a child process is saving. Rehashing writes to many pages, and each write would force a page copy, which is explained in the persistence section.

Other structures

  • Streams are a radix tree whose nodes hold listpacks. Entries are compact and IDs are sorted, so range reads are efficient.
  • HyperLogLog estimates the count of distinct items, in about 12 KB, with a standard error of about 0.81%, however many items you add.
  • Geo commands are a sorted set whose score is a geohash.
  • Bitmaps are ordinary strings addressed bit by bit.

Expiry: lazy plus active

Redis stores expiry times in a second table, and removes keys in two ways.

Lazy expiry. When a command touches a key, Redis checks the deadline, as alive does in the server above. It costs almost nothing, but on its own a key that is never read again stays in memory forever.

Active expiry. A periodic job takes a random sample of keys with deadlines and deletes the expired ones. If a large share of the sample was expired, it goes again. A time limit stops it from taking over the thread.

This is probabilistic by design. Scanning every key would be a stall, so Redis accepts a small share of expired keys sitting in memory for a while.

Replicas do not expire keys on their own timer. They wait for a DEL from the primary, which keeps the data consistent, while still reporting a logically expired key as missing.

Eviction: an approximate LRU

With a maxmemory limit set, Redis applies a policy when memory is full. The default, noeviction, returns errors on writes. The others are allkeys-lru, allkeys-lfu, allkeys-random, and volatile-* variants that only consider keys with an expiry.

A true LRU needs a linked list through every key. That adds two pointers per key, plus pointer updates on every read. Redis uses those 24 lru bits instead. To evict, it samples a few keys and removes the one idle the longest, improved by a small pool of good candidates kept between evictions. The maxmemory-samples setting, 5 by default, sets the sample size.

How good can sampling be? A simulation with 20,000 keys, Zipf-distributed traffic, a cache of 1,000 and 300,000 requests gives:

Keys sampledHit rate
1 (random eviction)56.9%
360.5%
560.9%
1061.3%
6461.3%
Exact LRU61.3%

Five samples get within half a point of the exact result, for no per-key pointers and no extra writes on reads. The simulation is simpler than Redis, which has the candidate pool as well.

LFU, added in 4.0, reuses the same 24 bits: 16 bits for a last-decrement time and 8 for a logarithmic frequency counter. Eight bits cannot count to a million directly, so the counter increases with a probability that falls as it grows. It decays over time so old popularity fades.

Persistence: fork and copy-on-write

Redis keeps data in memory, but it can save to disk in two ways.

RDB snapshots

BGSAVE calls fork(). The child process gets a frozen view of the entire dataset, and writes it to a file, while the parent keeps serving.

This needs no copying of the data, because of copy-on-write. After a fork, parent and child share every physical page, marked read-only. When the parent writes to a page, the CPU faults, and the kernel copies that one page. The child never sees the change.

After fork:     parent --+
                         +--> page A, page B, page C
                child ---+

Parent writes B:
                parent ----> page A, page B', page C
                child  ----> page A, page B,  page C

The costs are specific:

  • fork() itself blocks the main thread, because the kernel has to copy page tables, and their size grows with memory. On a large instance this can be many milliseconds or more. The latest_fork_usec field in INFO reports it.
  • Memory can grow during the save. A write-heavy load copies many pages. The worst case approaches double the memory, though it is usually much less.
  • Transparent huge pages make this worse. With 2 MB pages, one small write copies 2 MB rather than 4 KB. The Redis latency documentation recommends disabling them.
  • vm.overcommit_memory=1 is recommended, so the fork does not fail for lack of memory the child will likely never touch.

A snapshot gives a compact file and a fast restart. Anything written since the last snapshot is lost in a crash.

AOF: the append-only file

The AOF logs every write command, in RESP. On restart, Redis replays it. The durability is set by appendfsync:

SettingBehaviourLoss on a power failure
alwaysfsync on every write batchVery little, but it is slow
everysecfsync once a second, in a background threadAbout a second
noLeave it to the operating systemWhatever the OS had not written

everysec is the usual choice and the default. The log grows without limit, so a background rewrite creates a compact replacement. Since 7.0, the AOF is several files tracked by a manifest: a base snapshot plus incremental logs. By default, the base is in RDB format, which makes loading faster.

Many deployments run both: AOF for durability, and RDB files as backups.

Replication

Replication is asynchronous by default. The primary answers the client and then streams the same write to the replicas.

replica -> primary:  PSYNC <replication id> <offset>
primary -> replica:  +CONTINUE                      (partial)
                     +FULLRESYNC <id> <offset>      (full)

Each primary has a replication ID and a byte offset into its stream. A small in-memory ring, the replication backlog, holds recent stream data. A replica that briefly disconnects asks to continue from its offset. If that data is still in the backlog, only the gap is sent. If not, the primary sends a full RDB snapshot, then the stream of writes that arrived during the transfer.

A write acknowledged by the primary can be lost if the primary dies first. WAIT 1 100 blocks until one replica has acknowledged, up to 100 ms, which narrows the window. It does not turn Redis into a strongly consistent system, because failover can still promote a replica that missed the write.

Sentinel adds automatic failover: a group of processes watches the primary, agrees that it is down, and promotes a replica.

Cluster

Redis Cluster splits data across primaries with no proxy in between. There are 16,384 hash slots.

slot = CRC16(key) mod 16384

Each primary owns a range of slots, and nodes share cluster state through a gossip protocol on a separate port. A client that reaches the wrong node gets a redirect.

GET user:42
-MOVED 8331 10.0.0.5:6379

MOVED means the slot lives elsewhere permanently, so a good client updates its slot map. ASK means the slot is being migrated, and this key has already gone: send this one request to the other node, but do not update the map.

Multi-key commands work only if all keys are in one slot. Hash tags force that: only the part in braces is hashed, so {user:42}:cart and {user:42}:orders share a slot.

Scaling this way is how Redis uses more cores: run more single-threaded processes, on one machine or many.

Transactions and scripts

MULTI queues commands and EXEC runs them together, with nothing in between. There is no rollback. If a command fails at runtime, the others still apply. WATCH adds optimistic locking: EXEC aborts if a watched key changed.

WATCH balance
GET balance
MULTI
SET balance 90
EXEC          -> null if another client changed balance

A Lua script also runs atomically, for the same reason as any command. This pattern makes a fixed-window rate limiter safe, as INCR and EXPIRE happen together:

local n = redis.call("INCR", KEYS[1])
if n == 1 then
  redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return n
redis-cli EVAL "$(cat limit.lua)" 1 rate:ash 60

A slow script blocks the server like any other long command. After a threshold, 5 seconds by default, Redis starts answering other clients with BUSY but still does not run their commands. Redis Functions, added in 7.0, keep scripts on the server as named, persisted code.

Commands that hurt, and what to use

Since one slow command stops everything, the complexity of each command matters in production.

AvoidWhyUse instead
KEYS *Walks every keySCAN
DEL on a huge keyFrees it inlineUNLINK
SMEMBERS, HGETALL on huge keysO(n), and a big replySSCAN, HSCAN
LRANGE 0 -1 on a long listO(n)Ranges, paging
FLUSHALLFrees everythingFLUSHALL ASYNC
One round trip per commandTime goes on the networkPipelining

SCAN deserves a note. It returns a cursor and a few keys, doing a small amount of work per call: the same break-it-up pattern as rehashing. It iterates the cursor with reversed bits, a neat trick that means buckets visited before a table resize do not need revisiting after one. The cost is a weak guarantee: a key can appear twice.

Looking inside a running Redis

With redis-server running, these show everything discussed above.

redis-cli --latency                 # round-trip times, live
redis-cli --bigkeys                 # biggest key of each type
redis-cli --memkeys                 # keys using the most memory
redis-cli MEMORY USAGE user         # bytes for a key, with overhead
redis-cli OBJECT ENCODING user      # the layout in use
redis-cli SLOWLOG GET 10            # commands over the threshold
redis-cli LATENCY DOCTOR            # a written report on latency
redis-cli INFO stats                # counters, including latest_fork_usec
redis-cli MONITOR                   # every command, costly, briefly only
redis-benchmark -t set,get -n 100000 -P 16   # with a pipeline of 16

To see the event loop directly on Linux:

sudo strace -f -e trace=epoll_wait,read,write -p $(pidof redis-server)

Each epoll_wait is the loop going around. A burst of pipelined commands shows up as one read and one write, the effect measured in the table above.

The licence, and Valkey

Redis was BSD-licensed for most of its life. In March 2024, from version 7.4, it moved to a dual RSALv2 and SSPLv1 licence. In response, the Linux Foundation backed Valkey, a BSD-licensed fork from Redis 7.2.4. In 2025, Redis 8 added AGPLv3 as a third option. The two projects share the design described here, and they diverge in newer features.

What to take away

The design of Redis comes down to one decision and its consequences. Keep data in memory, run one command at a time, and everything else follows.

  • No locks are needed, and every command is atomic.
  • Slow work has to be avoided, sliced into pieces, or moved off the thread.
  • Compact layouts matter because memory is the main cost.
  • The network dominates, so batching wins.
  • To use more cores, run more processes.

Incremental rehashing, sampled eviction, lazy freeing and SCAN look like separate features, but they are all the same idea: never hold the thread for long.