Why Is Single-Threaded Redis So Fast? And the Landmine of O(N) Commands

· tech

#redis#performance

📑 Contents

The first post said one reason Redis is fast is “single thread + no locks”. That sounds backwards — isn’t single-threaded slow? This post settles it: why a single thread is faster here, what it buys, and its double-edged price — one slow command blocks everyone.

Why a single thread is faster here

The key: Redis’s bottleneck was never the CPU; it’s memory and the network. Every command is a simple memory operation and the CPU almost always has capacity to spare; the real limit is “how fast data moves, how fast the network swallows”. Since the CPU isn’t the bottleneck, the little parallel-compute benefit multithreading could bring is limited, while the price is locks, races, context switches. So Redis goes the other way: single-threaded, and all of that cost disappears.

Then how does one thread serve tens of thousands of connections at once? An event loop + I/O multiplexing (epoll):

Single thread + event loop: one queue, one at a time client client client client 10k+ conns epollI/O multiplexingone thread watches all command queue (one line)cmd1cmd2cmd3 single-thread executionone at a time, each atomicno locks, no races Redis 6's "multithreading" is only for socket reads/writes, the network chores — command execution stays single-threaded, so atomicity and lock-freedom are unchanged
One thread runs one loop, using epoll to watch tens of thousands of connections at once: whoever has data gets handled, the commands go into one queue, and they run to completion one by one. The model buys three things — no locks (no threads fighting over resources), no races (two commands can never modify the same key at the same time), atomic (each command finishes before the next starts, indivisible by construction). The single thread isn't a compromise; it's a design advantage bought with "simple and predictable"

What Redis 6’s “multithreading” is (don’t misread it)

You may have heard “Redis 6 supports multithreading now” — a line that’s easy to misread. The fact: the multithreading Redis 6 added is only for network I/O (reading requests, writing responses back to sockets — serialisation chores that genuinely take time under heavy traffic); but command execution itself is still single-threaded. So it didn’t become a “multithreaded database”, and every lock-free, atomic benefit above still holds. This matters: don’t assume that because “Redis is multithreaded now” the slow-command problem has gone away — it hasn’t; execution is still one queue.

The price: one slow command, everyone blocked

The single thread’s biggest price hides in “every command waits in the same queue”: if one command runs long, every other client waits. There’s no second thread to serve them:

One slow command, everyone blocked ❌ all at once KEYS * · full-DB scan O(N) · done in one go (holds the only thread) → meanwhile clients B, C, D all wait → timeout → upstream retries → fuel on the fire ✅ cursor batches SCAN(1) other cmd SCAN(2) other cmd SCAN(3) each scan does a small O(1) batch; other commands slip in between → nobody blocked likewise HSCAN / SSCAN / ZSCAN; UNLINK (async) instead of DEL for big keys; SLOWLOG to catch slow commands
Because there's only one queue, one O(N) slow command leaves the whole Redis unresponsive to every client for those tens or hundreds of milliseconds. The frightening part is that the consequences amplify: client timeouts → retries → more pressure, rolling into a cascading failure. The shared spirit of the fixes is "don't do it all at once; do it in batches, and yield" — the SCAN family's cursor batches instead of KEYS/HGETALL grabbing everything, and UNLINK to free big keys asynchronously

The practical landmine list is worth memorising: KEYS * (scans the whole DB), HGETALL/SMEMBERS/LRANGE 0 -1 on big collections (fetches the whole thing), SORT on big collections, DEL on a giant key with millions of elements (just freeing the memory is O(N)), and Lua scripts that run too long. What they share is O(N) and done in one go, and in a single-threaded world, “done in one go” means “nobody else gets to use it meanwhile”.

Ops commands: avoid O(N), catch slow commands

Turning the pits above into actual practice — these lines are the daily muscle memory of operating Redis:

# ✗ never fire these full O(N) scans in Production; one line blocks everyone
KEYS *                              # scans the whole DB
SMEMBERS bigset                     # pulls a whole big set at once
# ✓ use cursor batches instead, non-blocking
SCAN 0 MATCH user:* COUNT 100       # keep calling with the returned cursor until it returns 0
# find the culprit
SLOWLOG GET 10                      # the last 10 slow commands (with duration and arguments)
redis-cli --bigkeys                 # scan for big keys hogging memory
OBJECT ENCODING mykey               # see the underlying encoding; giant structures are the usual culprit
UNLINK bigkey                       # ✓ non-blocking delete (DEL on a big key freezes everyone)

The rule in one line: under a single thread, every O(N) is a tax on the whole room. SCAN instead of KEYS, SLOWLOG / --bigkeys to catch the culprit, UNLINK instead of DEL for big keys — those three cover a good half of Redis performance incidents.

Reflections

The single thread is a classic trade of “simplicity for predictability”

Redis’s single-threaded design is my favourite example for explaining “engineering trade-offs”. Most people’s intuition is “multithreaded = fast = good”, but Redis proves the reverse: when your bottleneck isn’t the CPU, the parallel benefit of multithreading is small and the cost of locks and races is large — at which point “single-threaded” is the faster, simpler, more predictable choice. It taught me that performance design isn’t “add everything that could speed things up”; it’s find the bottleneck first, then decide where to invest. Pile on parallel capabilities you don’t use and all you get is bugs you do. Optimising the wrong bottleneck is the most expensive kind of wasted effort.

A slow command freezing the room is the easiest Redis pit to fall into, with the worst consequences

I’ve watched too many people fall into the KEYS * pit — lightning fast on a laptop with little data, and then in Production with millions of keys, one KEYS * freezes the whole Redis for hundreds of milliseconds and every dependent service times out at once. The insidious part is that it’s perfectly fine normally, exploding only once the data has grown and someone’s itchy fingers fire a full scan. Since then I’ve set two rules: ban KEYS in Production (use SCAN), and before any operation that touches “the whole collection”, ask “can this collection grow large”. The single thread’s advantage is predictability, but that predictability has a precondition — that you never put a command that won’t finish into the only queue there is.

Checking a command’s complexity is basic craft for any in-memory system

Redis forced a good habit on me: before using a command, look up its time complexity. The Redis docs are considerate and mark every command’s complexity — GET is O(1), ZADD is O(log N), SMEMBERS is O(N), SINTERSTORE depends on the smallest set. These aren’t academic details; they’re the direct indicator of “will this line take the service down at 3am”. My habit now: whenever I see O(N) or worse, I automatically think one step further — “how big will this N get, will it blow up”. The habit isn’t just for Redis — estimating the complexity and worst case of any operation before putting it on the hot path is the basic craft that keeps “usually fast, occasionally explodes”, the hardest class of problem to diagnose, from reaching Production.