Redis Simulator: one thread, no locks.
Redis is an in-memory data store that runs commands on a single event loop, which is why it needs no locks across its six core data types. Type a command, watch it queue across four connections, watch the loop pull it out and run it. Flip pipelining and feel the network round-trip disappear. It's a single thread all the way down.
The left column is four client connections, each with its own queue of pending commands — whatever you type at the CLI lands on the active client's queue. The middle is the entire server: one core, pulling commands off the connections round-robin, one at a time. Below the log sits the keyspace table, showing each key's type, current value, TTL, and hit count, which is what the eviction policies read when memory passes the 8-key limit.
Start with the INCR race seed: three clients each fire five INCRs at the same counter, and the final value is exactly 15 — no locks, no lost updates, because nothing ever runs concurrently. Then flip pipelining on and resend anything. The per-command time in the log drops from 3µs to 0.3µs, yet the server's behaviour is identical. The surprise is that the 10× speedup comes entirely from the client not waiting between commands; the single thread was never the bottleneck.
Six data types, one event loop
The whole architecture fits in a chapter, which is most of why it shipped.
Redis has strings, lists, hashes, sets, sorted sets, and streams. The first five are the
classic kit; streams arrived in 5.0 (2017) as a radix-tree-backed append log for fan-out
messaging. Sorted sets are skip lists with a companion hash table for O(log N) range queries
and O(1) member lookups; everything else is the obvious data structure under the obvious
name. The command set is a thin facade over those structures — LPUSH is a
linked-list prepend, ZADD is a skip-list insert, HSET is a hash
put. That's the whole computer.
Around the data structures, one thread runs an event loop. On Linux it sits in
epoll_wait; on macOS it's kqueue; the abstraction lives in
ae.c, a 400-line file Antirez wrote himself rather than pull in libev. When a
file descriptor is readable, the loop parses the RESP protocol frame, dispatches to the
command function, mutates the keyspace, writes the reply into the output buffer, and goes
back to epoll_wait. There are no mutexes, no condition variables, no atomics.
A command either ran or it didn't; the next command starts when this one returns.
The serialisation guarantee is the most underappreciated feature. INCR is atomic
not because of clever CAS but because nothing else can run while it does. Three clients firing
INCR counter in parallel produce exactly the answer you'd expect, because the
loop runs them one after the other. This is the same property Lua scripts inherit:
EVAL runs to completion before anything else gets a turn, which is why a 200ms
Lua script will hang every other client for 200ms.
Pipelining is not batching
It changes nothing about the server. It changes everything about the network.
A synchronous Redis client sends one command, waits for the reply, sends the next. On
localhost this costs roughly 50µs per command — most of it the round-trip across loopback
and the syscall overhead on both ends. redis-benchmark -n 100000 on a laptop
tops out around 50–150K ops/sec; across a cloud network with a 0.5ms RTT it collapses to
roughly 2K ops/sec, because every command waits for one fsync-shaped delay.
Pipelining means the client sends N commands without waiting for replies, then reads the N
replies in one go. The server is unchanged — it still processes them one at a time, in
order, in the single event loop. What changes is that the network RTT is amortised across
the batch. redis-benchmark -P 50 on the same laptop hits a million ops per
second easily; the bottleneck moves from the network to the parser.
Critically, pipelining is not a transaction. Other clients can interleave their commands
between any two of yours; if you need atomicity you reach for MULTI or a Lua
script. And pipelining doesn't make individual commands faster — it just deletes the wait
for the reply between them. If your workload is two commands at a time with a think-time in
between, pipelining buys you nothing. If you're loading a million keys, it's the only sane
way.
Eviction is a policy choice, not a guarantee
The "LRU" you configure isn't really LRU, and that's fine.
When maxmemory is hit, the configured policy fires. noeviction is
the surprising default for many deployments: every write that would exceed the limit returns
OOM command not allowed. Safer than data loss, painful if you wanted a cache.
allkeys-lru evicts the least-recently-used key regardless of TTL;
volatile-lru only touches keys with a TTL set; allkeys-lfu
(introduced in Redis 4) tracks access frequency instead of recency, which matters for
seasonal workloads where last week's hot key shouldn't get evicted just because today's
access pattern is briefly different.
The "LRU" Redis uses isn't true LRU. True LRU would need a doubly-linked list of every key
ordered by access time — an O(1) eviction at the cost of carrying a list pointer pair per
key and updating both on every access. Antirez rejected the memory tax. Instead Redis
samples N random keys (default 5, tunable via maxmemory-samples) and evicts the
oldest in the sample. Redis 3.0 added a small pool to remember good candidates across
sampling rounds; Redis 4.0's LFU variant uses a 24-bit counter with logarithmic increment
so popular keys cap out instead of growing unboundedly.
The approximation is shockingly good in practice. Antirez published a graph comparing true
LRU, approximated LRU with 5 samples, and 10 samples against a Zipf workload; the 10-sample
line is visually indistinguishable from true LRU and the 5-sample line is within a few
percent. Memory accounting in Redis is measured by jemalloc's reported
allocated bytes, not by counting keys, so a hash with many fields counts more than a single
short string — which is why HSET on a million-field hash can blow past
maxmemory faster than you'd guess from key count alone.
Transactions, and the closer truth
MULTI/EXEC is atomic, not ACID. Lua is the real isolation primitive.
MULTI opens a transaction; commands sent after it are queued on the server
side rather than executed, and the server replies QUEUED to each.
EXEC runs the whole batch as one uninterrupted unit — no other client gets a
turn until the batch finishes. DISCARD throws the queue away. That's atomicity:
all of them or none of them, with no interleaving. There's no rollback. If a command's
syntax fails at queue time (you typed garbage), the whole transaction is aborted at
EXEC time. If a command fails at execution time (you ran INCR on
a string that isn't an integer), Redis runs the other queued commands anyway and reports
the failure for that one entry. Most SQL people find this dishonest the first time they see
it; the Redis answer is that rollback would require keeping an undo log of every keyspace
change, which would defeat the whole "in-memory and fast" premise.
Optimistic concurrency is the missing piece, and it's spelled WATCH. Before
your MULTI, WATCH key tells the server to track that key; if any
other client modifies it before your EXEC, the transaction is aborted and
EXEC returns nil. The pattern is read-modify-write done safely:
WATCH balance; GET balance; MULTI; SET balance new; EXEC. If somebody else
touched balance in between, you retry. This is enough for most counter and
balance patterns, and it composes cleanly with pipelining.
The closer truth is EVAL. A Lua script runs to completion inside the event
loop with full keyspace access and no interleaving. That gets you real read-modify-write
atomicity, conditional logic, and complex multi-key operations without the round-trip
overhead of WATCH retries. Antirez added it in 2.6 (2012) explicitly to give
users a single primitive that subsumed transactions, stored procedures, and atomic
operations. The price is that a slow script blocks everything; the lua-time-limit
default of 5 seconds is the safety valve, and after that SCRIPT KILL is your
emergency stop.