Skip to main content

Redis, Inside-Out

Editorial

The sentence everyone says, and what it fails to explain

Ask why Redis is fast and you get the same seven words: it's fast because it's in memory. That is true, and it is close to useless — because it predicts nothing. It does not tell you why one thread is enough, why INCR is safe without a lock, why a single DEL can take your whole service down for a third of a second, or why version 6 added threads and deliberately kept them away from your commands.

This page is the chain that does explain all of that, and it starts from one measurement you can take yourself.

Scope: You have already met most of Redis's surface in this course, and none of it is repeated here: eviction and maxmemory-policy are Cache Eviction: LRU, LFU, TTL, FIFO; the sorted set's skip list is Skip Lists & Merkle Trees; the 16,384 fixed hash slots are Distributed Caching + Consistent Hashing and Consistent Hashing Deep-Dive; batching round trips is Write Batching & Buffering; the lock recipe and its famous argument are Distributed Locks & Leases; the probabilistic types belong to Bloom Filters, HyperLogLog and Count-Min Sketch; GEOADD is Geohash; fire-and-forget delivery is Pub/Sub: Broadcasting Events; and you already cached a slow endpoint with it in Codelab: Cache a Slow Endpoint with Redis. Choosing between Redis and Memcached is its own lesson later, in the decision part of this course. What was left for this page — in Key-Value Stores' own words — is "the single-threaded event loop and why it's still that fast."

Where the time actually goes

Here is the experiment. Ask a live Redis for one key, twenty thousand times, and record two numbers: what Redis says the command cost it, and what the caller actually waited. Redis keeps the first for you in INFO commandstats.

Measured against Redis 7.4.9 over loopback — the friendliest possible network:

Redis executes the GET            0.40 us
the client waits (median)        52.82 us
the client waits (p99)           82.25 us
The measurement the whole Redis lesson rests on, drawn to scale. The top of the figure shows one GET request as a single wide bar representing the 52.82 microseconds the caller actually waits, measured over twenty thousand samples against a live Redis 7.4.9 over loopback. Inside that bar, at true scale, sits a red sliver only a few pixels wide: the 0.40 microseconds Redis itself spends executing the command. A leader line is needed to label it because at honest scale it is too narrow to write on, which is the point of the picture — executing the command is eight tenths of one percent of what the caller waits, and the rest is the round trip. Beneath it a second, thinner bar repeats the measurement with a C client on a single connection with no batching, where the round trip is exactly the reciprocal of throughput: fifty-seven microseconds, and execution is still seven tenths of one percent, so the ratio is not an artifact of the measuring client. Below that, three green bars show the consequence that sounds wrong until it is measured: at ten bytes, one hundred bytes and one thousand bytes of payload, throughput is 42.3k, 41.7k and 41.1k operations per second — a hundred times the payload costs only 2.8 percent of the throughput, because what you were paying for was never the bytes, it was the trip. A closing band states what all of this forces: a second command-executing thread could only ever split the sliver, and it would charge a lock on every data structure in the process to do it — which is why Redis executes commands on exactly one thread.

⭐⭐⭐ Executing the command is 0.8% of what the caller waits. Fifty-two microseconds out of every fifty-three are not Redis thinking.

Because that ratio is the entire lesson, it is worth defending against the obvious objection — you measured your client, not the server. So the same thing was measured again with redis-benchmark, a C client using hiredis, on one connection with no batching, where the round trip is exactly the reciprocal of throughput: 57 µs per operation at 17,519 ops/s — and execution is still 0.7% of it.

And there is a consequence that sounds wrong until you see the number. If you are paying for the trip rather than the bytes, then making the bytes bigger should barely matter. It barely matters:

value sizeops/sec
10 bytes42,254
100 bytes41,696
1,000 bytes41,068

A hundred times the payload costs 2.8% of the throughput. Redis's own documentation says the same thing from the other end: "in many real world scenarios, Redis throughput is limited by the network well before being limited by the CPU."

So one thread is not a limitation. It is the answer

Now put a second command-executing thread into that picture and ask what it buys.

It can only parallelise the 0.40 microseconds. It cannot parallelise the round trip, which is the other 99%. And to earn that fraction it would have to make every data structure in the process thread-safe — a lock or an atomic on every hash table bucket, every list node, every sorted set — paid on every single operation, including the overwhelming majority that were never contended.

⭐⭐⭐ Redis is single-threaded because it is in memory. Being in memory is what makes the CPU cheap; and once the CPU is cheap, the thread that would have hidden disk waits has nothing to hide, and the locks it would cost are pure loss.

That is not a rationalisation after the fact — it is visible in the hardware advice Redis gives. Most databases want many cores. Redis's documentation says it "favors fast CPUs with large caches and not many cores", and that if you want to use the other cores, you run more Redis instances, not more threads. A design that asks for fewer cores is telling you where it thinks the bottleneck is.

Two things follow. One is a gift and you have already spent it three times. The other is a bill, and it is the reason most Redis incidents look the way they do.

The gift: you have already relied on this three times

If exactly one thread ever touches the data, then every command is atomic by construction. Not atomic because someone implemented a lock — atomic because there is no second thread for it to race against. Redis never had to write the mutual exclusion; the architecture is the mutual exclusion.

Sixteen connections, two thousand INCR each, all on the same key, no lock and no retry anywhere:

16 concurrent connections x 2000 INCR on the SAME key
expected 32,000   ->   actual 32,000   EXACT   (0.86s)

Now look back at three designs this course already had you build, and notice that all three were quietly standing on that one fact:

  • The rate limiter. Rate Limiting: Token Bucket, Leaky Bucket, Windows had you count with INCR plus EXPIRE, and reach for a script when the decision needed to be one indivisible step. It works because no other command can interleave with yours.
  • The lock. Distributed Locks & Leases opened with a single SET with NX and an expiry. Check whether it exists, and set it if it doesn't is two operations in your head and one uninterruptible operation in Redis.
  • The contended counter. Flash Sale: Inventory Under Contention measured the atomic conditional write as the fastest of four strategies, and Pattern: Dealing with Contention explained the ceiling as roughly one over the hold time. Redis's hold time is the command itself — microseconds, inside one thread, with no round trip inside the critical section.

⭐⭐ This is the interview-grade version of "Redis is single-threaded." Anyone can say it. Saying what it buys you — that atomicity is free, so counters, limiters and locks need no coordination — is the sentence that reads as someone who has actually thought about it.

The bill: in Redis, your latency is not yours

One thread means one queue. Every client in the system is standing in it, and the thread serves them strictly one at a time. So the moment any client asks for something slow, every other client is behind it — not slowed down, stopped.

The experiment: one perfectly innocent client runs GET in a loop and records what it waits. Halfway through, a different client on a different connection deletes a three-million-element set. Nothing about the innocent client changes.

The central law of the Redis lesson, drawn as what it actually is — a Gantt chart of one server. The figure holds two timelines of the same single thread, to the same scale. In the upper panel, outlined in red, one client issues DEL on a set holding three million members; that command occupies the only thread for two hundred and ninety-one milliseconds, drawn as a long solid red block filling most of the track. Beneath it, three more lanes show clients two, three and four, each of which sent nothing but an ordinary GET. Their bars are drawn as long dashed waiting spans that last as long as the red block does, ending in a small solid marker where they are finally served, and annotated as waiting despite having sent only GET. In the lower panel, outlined in green, the identical deletion is issued as UNLINK instead: the block on the thread shrinks to a sliver of eight hundredths of a millisecond because the memory is reclaimed on a background thread, the thread is immediately free again, and all three innocent clients are served straight away with no waiting span at all. A closing band gives the measured numbers behind the drawing: the innocent clients waited three hundred and six and a half milliseconds for a command they never ran on a key they never touched, which is six thousand one hundred and twenty-two times their own normal latency, and with UNLINK the same work cost them four tenths of a millisecond.
other client ran     it took   innocent p50   innocent p99   innocent WORST
DEL                  306.6ms       0.050ms        0.085ms         306.5ms
UNLINK                 0.3ms       0.051ms        0.082ms           0.4ms

⭐⭐⭐ A command it never ran, on a key it never touched, cost the innocent client 307 milliseconds — 6,122 times its own normal latency.

That single number reframes how you have to reason about Redis. Your p99 is not a property of your queries. It is a property of the worst query anyone sends. A team two floors away can ruin your latency budget with one line of code, and nothing in your own metrics will explain it.

The second row is the fix and it is one word. UNLINK removes the key from the keyspace immediately and reclaims the memory on a background thread — so the work still happens, it just stops happening in the queue everybody is standing in. Same deletion, 307 ms down to 0.4 ms for everyone else.

And because every command shares that one thread, it is worth knowing what each one actually costs it. Measured on the same instance, against a 300,000-key keyspace with a 3-million-member set, a 1-million-element list and a 500,000-field hash:

commandthread time
GET one key0.051 ms
SCAN / HSCAN, one bite0.124 / 0.169 ms
KEYS * over 300k keys197 ms
DEL a 3M-member set291 ms
LRANGE list 0 -1, 1M elements531 ms
HGETALL a 500k-field hash616 ms
UNLINK the same 3M-member set0.084 ms

⭐⭐ Twelve thousand times between the cheapest and the dearest — and one thread serves all of them, in the order they arrived. Note which one is worst: HGETALL is not an exotic command, it is what everybody writes the first time they store a hash.

The family of these is small enough to memorise, and knowing it is worth a lot in a room:

the command that stallswhat it does on the one threadreach for
KEYS *walks the entire keyspaceSCAN — a cursor, in small bites
DEL bigkeyfrees every element inlineUNLINK — free it in the background
HGETALL / SMEMBERS on a huge collectionserialises the whole thing at onceHSCAN / SSCAN
FLUSHALLdrops everything inlineFLUSHALL ASYNC
a Lua script with a loop in itruns to completion, uninterruptedkeep scripts tiny and bounded

⭐ Notice the shape they share. None of these is slow because Redis is slow. Each one is a small amount of work multiplied by a collection that grew quietly over months. The big key is the bug; the command is only where it surfaces.

Drive it: four clients, one thread

Reading that DEL froze an unrelated client is not the same as watching it happen. Below, four clients share one Redis exactly as they do in production: you choose what each one sends, and the queue in front of the single thread is drawn as it really behaves.

Try this first. Set all four to ordinary GETs and watch the queue stay empty and every latency stay flat. Then change one client — just one — to DEL on a big key, and watch what happens to the other three, who did nothing. Then switch that same client to UNLINK and watch the damage disappear without the work disappearing.

Four clients, one thread. Choose what each one sends and watch the queue — the moment somebody runs a slow command, everyone else is behind it.

The toggle worth playing with last is io-threads, and it is there to make a point rather than to be a fix: turning it on speeds up the reading and writing of sockets and does nothing at all for the client stuck behind a slow command — because the slow command is not socket work. That is the next section, and the drill will have already shown you the answer.

The other stall: persistence has to fork

There is a second way the one thread gets taken away from you, and it is the one people are least ready for, because it happens on a schedule nobody chose.

To write a snapshot without stopping the world, Redis calls fork(). The child gets a frozen view of memory and writes it to disk at its leisure; the parent carries on serving. That is copy-on-write and it is genuinely clever. But fork() itself is not free — the kernel has to build the child's page tables, and the parent is stopped for the whole of it. Redis's own documentation is blunt: fork() "can be time consuming if the dataset is big, and may result in Redis stopping serving clients for some milliseconds or even for one second if the dataset is very big."

Measured, on the same instance, as the data grows:

The second way the single Redis thread is taken away from you, measured. To write a snapshot without stopping the world Redis calls fork, and the parent process is halted for the whole of the fork itself while the kernel builds the child’s page tables. The figure is a horizontal bar chart of how long that halt lasts as the dataset grows, on a logarithmic scale so the small measurements remain visible beside the large ones. Three solid amber bars are measurements taken against a live Redis: a seventeen megabyte dataset forks in one millisecond, eighty-one megabytes in two point three milliseconds, and two hundred and fifty-seven megabytes in four point six milliseconds. Two further bars are drawn hollow with dashed outlines in red, and labelled as extrapolations rather than measurements, using the rate at the largest measured point of about eighteen milliseconds per gigabyte: a sixteen gigabyte instance stalls for roughly three hundred milliseconds and a sixty-four gigabyte instance for roughly one point two seconds. The distinction between solid and hollow is the honesty of the figure — it shows exactly where the evidence stops and the projection begins. A closing band records that the projection lands on the figure Redis documents itself, that a fork may stop it serving clients for some milliseconds or even for one second if the dataset is very big, and adds the consequence people forget: copy-on-write means the process needs memory headroom above its resident size while the snapshot runs.
       keys      dataset    fork took     per GB
    200,000         17MB        1.0ms       61ms
  1,000,000         81MB        2.3ms       29ms
  3,000,000        257MB        4.6ms       18ms

The per-gigabyte cost improves with size — a fixed setup cost amortising — so extrapolate from the largest point, which is the conservative choice: at 18 ms per gigabyte, a 16 GB instance stalls for about 0.3 s and a 64 GB instance for about 1.2 s. Which lands exactly on the figure Redis documents, arrived at from a measurement rather than from the manual.

Three consequences that are worth saying out loud:

  • A snapshot is a latency event, not just a disk event. If your p99 has a spike every few minutes with no traffic pattern behind it, look at your save points before you look at your queries.
  • Copy-on-write means memory can grow while the child runs. Every page the parent writes to during the snapshot has to be duplicated. A write-heavy instance mid-snapshot needs headroom above its resident size — which is why "size the box to the dataset" is not enough.
  • Replicas fork too. A replica syncing from scratch triggers a snapshot on the primary, so adding a replica during an incident is not the free operation it looks like.

What actually survives a crash

Key-Value Stores told you the honest edge — a bare Redis "can lose acknowledged writes in a crash window." Here is the window, and how you choose its size.

There are two mechanisms and they answer different questions.

What actually survives a Redis crash, drawn as time you can lose rather than described as a configuration value. Four horizontal bars sit on a logarithmic scale, because the options are five orders of magnitude apart. The first, in green, is appendfsync always, which flushes to disk before replying to the client and therefore risks essentially nothing — annotated with the honest caveat that Redis calls this very slow in practice. The second row is highlighted in amber and marked THE DEFAULT: appendfsync everysec, which flushes once a second on a background thread and therefore puts up to one full second of already-acknowledged writes at risk. The third, in red, is appendfsync no, which leaves flushing entirely to the operating system and typically means around thirty seconds of exposure. The fourth, also red, is running with snapshots alone, where the window is however long it has been since the last save point — commonly minutes. The highlighted middle row is the point of the whole figure: a plain, untouched Redis will acknowledge a write and can still lose it. A closing band states the judgement that follows — a one-second window is a completely reasonable trade for a session, a cache entry or a leaderboard score, it is not a reasonable trade for money, and asynchronous replication stacks a further loss window on top of this one.

Snapshots (RDB) are a point-in-time copy of the whole dataset, produced by the fork above. Compact, fast to load, ideal to ship somewhere else as a backup. The window is however long ago the last snapshot was — commonly minutes.

The append-only file (AOF) logs every write command as it arrives and replays them at startup. Bigger than a snapshot, slower to load, and much narrower a window — but how narrow is a setting, and this is the setting to know:

appendfsyncwhat it doeswhat you lose in a crash
alwaysflushes to disk before replyingessentially nothing — and it is "very slow in practice"
everysec (default)flushes once a second, on a background threadup to one second of acknowledged writes
noleaves it to the operating systemwhatever the kernel had not flushed — typically ~30 s

⭐⭐⭐ The default is everysec, so the honest sentence about a default Redis is: it can acknowledge your write and lose it, up to a second's worth. That is a completely reasonable trade for a session, a cache entry, or a leaderboard score. It is not a reasonable trade for money, and saying so unprompted is one of the strongest things you can do with this technology in an interview.

Three details that separate a real answer from a recited one:

  • Redis discourages AOF alone — not for durability, but because a snapshot is what gives you backups, fast restarts, and a fallback if the log engine itself has a bug. The recommended posture when you care is both.
  • Since 7.0 the AOF is multi-part: a base file plus incremental files tracked by a manifest, so a rewrite no longer has to buffer every concurrent write in memory and write it twice. If someone quotes the old memory-spike warning, that is the version it belongs to.
  • Persistence is not replication, and neither is a backup. fsync protects you from the process dying. It does nothing about the disk dying, and replication — being asynchronous — has a loss window of its own that stacks on top of this one.

Then why did version 6 add threads?

This is the question that catches people, because it sounds like it contradicts everything above. It does the opposite — it confirms it.

Look again at where the time went. If 99% of the wait is getting bytes on and off sockets, then the thing worth parallelising is getting bytes on and off sockets. So that is precisely, and only, what was threaded:

  • an I/O thread reads from the client socket and parses the command;
  • the main thread executes it — still alone, still holding the only reference to the data;
  • an I/O thread writes the reply back.

⭐⭐ io-threads never execute commands. They do the reading, the parsing and the writing. Command execution has never been parallel and is not parallel now — which is why everything above about atomicity and about the shared queue is still true on a modern instance.

The payoff is exactly what the measurement predicted: Redis reports up to 112% more throughput with eight I/O threads on a multi-core machine, after the model was rebuilt in 8.0. Roughly double, from threading the part that was 99% of the time — and zero help for the client stuck behind a big DEL, because that was never socket work.

⭐ If an interviewer says "but Redis is multi-threaded now", that distinction is the whole answer: threaded at the sockets, single-threaded at the data, on purpose.

Why you would pick it, in one breath

The version you say out loud when Redis is the right call, in about twenty seconds:

"I'd put Redis here because the access is key-shaped and the working set fits in RAM. A command costs under a microsecond of CPU, so the whole latency is the round trip — which is why a single node does on the order of a hundred thousand operations a second and why it's single-threaded. That single thread also makes every command atomic, so the counter and the lock need no coordination. The costs I'd watch are that one slow command blocks every client, so no unbounded KEYS or DEL on big keys, and that with the default persistence I can lose about a second of acknowledged writes — fine for this data, and if it weren't, this isn't the store."

And when to say no, which is the half candidates skip:

  • The working set does not fit in memory. Redis is not a disk database with a cache in front; the data is the memory. Once you are evicting things you needed, you have bought an expensive way to miss.
  • It is the system of record for something that must not be lost. Between the persistence window and asynchronous replication, an acknowledged write can vanish.
  • The questions vary. The moment you want "every order from restaurant 7 last month", you are asking a query, and you are now the query planner — which Key-Value Stores already showed you the price of.
  • You need durable, replayable history with long retention. Streams are real, and they are still only as durable as the settings above.

Follow-up ammo

Three questions an interviewer reaches for once you name Redis, and what a strong answer sounds like. Commit to your own answer before reading each one.

1 · "You said it's single-threaded. Isn't that a bottleneck?"

"Not for throughput — a command is well under a microsecond of CPU and the round trip is fifty-plus, so the network saturates long before the core does. It's a bottleneck for tail latency, and in a specific way: because there's one queue, one slow command blocks every client. I'd keep an eye on big keys, use SCAN and UNLINK rather than KEYS and DEL, and if I genuinely needed more cores I'd run more instances rather than expect more threads."

2 · "What happens when this Redis dies?"

"Two separate questions — what's lost, and what's broken. Lost: with the default appendfsync everysec I can lose about a second of acknowledged writes, and replication is asynchronous so a failover can lose a bit more. Broken: whatever I put behind it now takes the full load, so I'd want the cache miss path to survive that — a time budget on the call so a hanging Redis doesn't hang my request, and single-flight so the rebuild isn't a stampede."

3 · "Why not just add a second thread and double the throughput?"

"Because the thread would be idle. The measured split is under a microsecond of execution inside a fifty-microsecond round trip, so a second command thread parallelises the 1% and charges a lock on every data structure for the privilege. That's exactly why io-threads — which is real and does help — was applied to socket reads and writes and never to command execution."

Take it into the room

  • ⭐⭐⭐ A command is 0.40 µs of CPU inside a ~52 µs round trip — 0.8%. Cross-checked with a C client at 57 µs and 0.7%. The CPU was never the bottleneck, which is the whole reason one thread is enough.
  • ⭐⭐⭐ Redis is single-threaded because it is in memory — being in memory is what makes the CPU cheap, and once it is cheap a second command thread buys 1% and costs a lock on every structure. Redis asks for fast cores, not many; extra cores get extra instances.
  • ⭐⭐⭐ Atomicity is free, and you already spent it three times — the rate limiter's INCR, the lock's SET NX, and the flash sale's conditional write are all correct because no second thread exists to race them.
  • ⭐⭐⭐ Your latency is not yours. Measured: DEL on a 3M-element set froze an unrelated client for 307 ms, 6,122× its normal latency. UNLINK — same deletion, background reclaim — left it at 0.4 ms. Learn the family: SCAN over KEYS, UNLINK over DEL, HSCAN/SSCAN over HGETALL/SMEMBERS, FLUSHALL ASYNC.
  • ⭐⭐ The big key is the bug, not the command. These stalls are ordinary work multiplied by a collection that grew quietly.
  • ⭐⭐ Persistence forks, and the fork stalls the same thread. Measured 1.0 / 2.3 / 4.6 ms at 17 / 81 / 257 MB — about 18 ms per GB at the top end, so roughly 1.2 s on a 64 GB instance, matching Redis's own warning. Copy-on-write also needs headroom, and a replica's first sync forks the primary.
  • ⭐⭐⭐ The default durability window is one second (appendfsync everysec). always is safe and slow; no leaves you at the kernel's mercy, roughly 30 s. Redis discourages AOF alone — keep snapshots for backups and fast restarts.
  • ⭐⭐ io-threads never execute commands — sockets and parsing only, up to 112% more throughput on eight threads. "Redis is multi-threaded now" is half a sentence; the other half is at the sockets, not at the data.
  • Payload size barely matters below the packet size — 100× the bytes cost 2.8% of the throughput, because you were paying for the trip.

Next, the store that answers the opposite question — where the data is on disk, the questions vary, and a planner does the thinking for you: PostgreSQL, Inside-Out.