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.

ops/sec
mem
0/8
clients
4

pipelining: eviction: seed:
c1@redis >
as:
connections (queue depth)
c1 0
— idle
c2 0
— idle
c3 0
— idle
c4 0
— idle
event loop (single thread)
core 0
stopped
queued0
ops0
reply
log (last 8)
— no commands yet — type one above —
keyspace
— empty keyspace — try SET counter 1 —
string0
list0
hash0
set0
zset0

What you're looking at

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.

Found this useful?