Hot keys, stampedes, and three fixes that don't transfer.
Three completely different problems all present as “Redis is on fire because of one key”. Reading one value constantly, everyone rebuilding an expired value at once, and millions of real writes to one counter need three different answers — and applying the wrong one usually makes it worse.
00Three problems, not one
If every operation produces the same state, it is duplicate work — collapse it into one. If every operation is a distinct business event, it is real load and must be spread, never merged. A cache stampede is the first. A likes counter is the second.
01Redis atomicity, precisely
Redis can use extra threads for networking and parsing, but the useful model is that dataset command execution on one node is serialised. That is why a single Redis command is inherently atomic from the application's point of view, and why Redis gets real data parallelism from multiple nodes rather than multiple threads.
SET lock:key token-A NX PX 5000 → SUCCESS SET lock:key token-B NX PX 5000 → FAIL (NX: only if absent) SET lock:key token-C NX PX 5000 → FAIL existence check + set + TTL are ONE command. EXISTS followed by SET is two, with a race between them.
When one logical operation needs several commands to behave as one unit, that is what Lua is for: while a script runs on a node, no other client command executes in the middle of it.
Where requests wait while Redis is busy
client → OS TCP receive buffer raw bytes can wait here → Redis client query buffer bytes Redis has read can wait here → event-loop readiness which sockets need attention → command execution → output buffer → client observed latency ≈ wait/scheduling + execution + network
There is no single global FIFO queue. This is also why a long Lua script makes cheap GETs look slow — the GET is still cheap, it just waited behind the script.
02Cache stampede
Two levels, because either one alone leaves the other multiplier intact. Per-pod coalescing turns 10,000 concurrent local requests into one leader and 9,999 waiters on the same promise. The distributed lock then turns ~100 pod leaders into one rebuild.
The pods that lose the lock do not automatically receive the winner's value. They must wait briefly and re-read the cache, or use some other completion signal. A lock coordinates who rebuilds; it does not distribute the result.
03Locks, TTLs and why the token matters
The lock key is not the cached data. feed:user:123 is the value; lock:feed:user:123 only says who is currently allowed to rebuild it. The winner should release the lock as soon as it has populated the cache — the TTL is the crash-safety fallback, not the normal path.
-- never do this
DEL lock:key
-- because if your lease expired, you may be deleting
-- the NEW owner's lock
-- do this, atomically, in Lua
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0GET then DEL from application code is two commands with a race between them. Inside one small Lua script it is one atomic region.
04Dragonfly's different model
| Question | Redis | Dragonfly |
|---|---|---|
| Two different keys, concurrently? | No — dataset execution is serialised per node | Yes, if they map to different shard-threads |
| The same key, concurrently? | Serialised | Serialised — same key, same owner shard-thread |
| One command spanning shards? | N/A on one node | Needs cross-shard transaction coordination |
| How you get parallelism | More nodes / shards | Threads within one process, plus more nodes |
Dragonfly partitions its keyspace into shards with a dedicated thread owning each, so different keys really do execute in parallel. The cost is the complexity that multi-key commands now need coordination across shard-threads — which is exactly the complexity Redis avoids by serialising.
05Read-hot keys
One million GETs/sec against a key that already exists. There is no rebuild to deduplicate here, so coalescing is the wrong tool — and putting SET NX PX in front of every read would turn a hot data key into a hot lock key, which is strictly worse because a lock is a write.
| Fix | How it helps | Cost |
|---|---|---|
| L1 in-process cache | Most requests never reach Redis at all | Staleness — needs a short TTL or invalidation |
| Salted physical copies | hot:key:0…9, reader picks by hash — spreads across shards | Write amplification and keeping copies consistent |
| Read replicas | More read capacity for the same data | Replication lag |
| CDN | Stops shared HTTP responses reaching the app | Only for client-facing responses — a pod should not call a CDN for internal data |
06Write-hot keys
| Cache stampede | Write-hot key | |
|---|---|---|
| What is happening | Many callers repeat the same rebuild | Many real mutations to one logical key |
| Can you collapse the work? | Yes — one rebuild suffices | No — every event matters |
| Example | 1M misses querying the same product row | 1M users each adding +1 like |
| Fix | Coalescing + one distributed winner | Shard the counter, durable stream, batch, aggregate |
You cannot collapse a million likes into one increment. That is the whole distinction, and it is the first thing to say when someone describes a “hot key” without specifying which kind.
07LLD — likes for a viral post
Why Kafka, and not just in-memory batching
Pod-local batching is fast and loses data: a crash before flush drops those likes with no record they existed. Kafka makes the events durable first, and batching becomes an optimisation on top of durability rather than a substitute for it.
The idempotency problem, and the only clean fix
unsafe: mark IN_PROGRESS → INCR → crash on retry, IN_PROGRESS cannot tell you whether INCR happened safe — one Lua script, one atomic region: if offset already applied: return DUPLICATE INCR video:123:likes:7 record offset as applied return APPLIED then commit the Kafka offset
If one Lua script touches both the counter and its offset key, those keys must be in the same hash slot. Use a shared hash tag: {video:123:bucket:7}:likes and {video:123:bucket:7}:last_offset.
Aggregation, and why SET beats INCRBY
| Option | Read cost | Trade-off |
|---|---|---|
| Read-time sum of buckets | O(buckets) per read | Simplest; cross-slot means parallel GETs, not one MGET |
| Periodic materialised total | One GET | Eventually consistent by the aggregation interval |
| Stream aggregation | One GET | Another system to run; may replace the bucket counters entirely |
absolute (retry-safe) delta (not retry-safe) sum buckets = 100500 delta = +500 SET total 100500 INCRBY total 500 retry → SET total 100500 crash, retry → INCRBY total 500 still 100500 now 101000 ✗
One more edge: two aggregators can read different snapshots, and an older one must not overwrite a newer total. Either keep one owner per video, or attach a monotonically increasing version and only write if yours is newer.
08Question drill
A key expires at peak and a million requests miss. What do you do?
Coalesce at two levels. Inside each pod, one leader request proceeds and the rest wait on the same promise. Across pods, SET lock:key token NX PX 5000 so exactly one wins. The winner reads the DB and repopulates Redis; the losers wait briefly and re-read the cache.
The DB call takes 8 seconds but the lock TTL is 5. What breaks?
The lease expires while pod A is still working, pod B acquires a fresh lock and starts the same rebuild. The owner token stops A from deleting B's lock, but it does not prevent the duplicate work. Options: renew the lease, choose a safer TTL, or fence with a version if a stale writer must never win.
Why is GET-then-DEL unsafe for releasing a lock?
They are two separate commands and another client can change the key between them — so you can delete a lock you no longer own. Put GET, compare and DEL in one Lua script.
While a Lua script runs, can another client GET a different key?
No. The script is one atomic execution region on that node. Other commands arrive and wait in buffers, but none execute mid-script.
Redis has I/O threads. Doesn't that mean parallel key mutation?
No. Networking and parsing can use extra threads; dataset command execution is still serialised on the node. Redis gets real data parallelism from multiple nodes, not multiple threads.
Three clients run SET lock:key NX PX at once. Who wins?
The first one Redis executes. NX means set only if absent, each command is atomic, so the first creates the key and the others fail while it exists.
Dragonfly: two different keys on different shards — parallel?
Yes. Each shard has a dedicated owner thread, so different shards execute concurrently. The same key still serialises on its owner thread, and a command spanning shards needs cross-shard coordination.
One key gets a million GETs/sec. Why doesn't adding shards help?
One logical key hashes to one slot on one shard, however many shards exist. This is a read-hot key, not a stampede — fix it with an L1 cache, salted copies, replicas, or a CDN for whole responses.
Would you put SET NX PX in front of every GET for a hot key?
No. Every request still hits Redis, and a lock is a write — you would convert a hot read key into a hot write key. Coalescing is for rebuilds on miss, not for sustained reads of an existing value.
A million writes to one likes counter. Same problem as a stampede?
No — and this is the distinction that matters. A stampede is repeated identical work you can collapse to one. Every like is a unique business event that must be preserved. You shard it; you never merge it.
How do you partition the likes write path?
bucket = hash(user_id) % N, Kafka key = video_id:bucket, and one Redis key per bucket. Keying on video_id alone would put every like for a viral video on one partition.
The consumer increments Redis then crashes before committing the offset.
On redelivery, an IN_PROGRESS marker cannot tell you whether the INCR happened. Make dedupe-check + INCR + mark-applied one Lua script, so the replay sees the offset already applied and skips it.
Why not also INCR a likes:total on every like?
Because likes:total immediately becomes the hot write key you just removed. Update the total periodically from the buckets instead.
Why SET an absolute total rather than INCRBY a delta?
SET is naturally retry-friendly — writing 100500 twice still gives 100500. An INCRBY that is retried after a crash double-counts. Also guard against an older aggregator overwriting a newer total.
09Cheat sheet
| Term | One line |
|---|---|
| Read-hot key | One existing value read enormously often → L1 cache, salted copies. |
| Cache stampede | A value expires and everyone rebuilds it → coalesce to one rebuild. |
| Write-hot key | Many real mutations on one key → shard, stream, aggregate. |
| Request coalescing | One leader does the work; everyone else awaits its result. |
| SET NX PX | Set-if-absent with a TTL, as one atomic command. |
| Owner token | A value proving you still hold the lock, so you cannot delete someone else's. |
| Lua script | Multiple Redis commands executed as one atomic region on a node. |
| Hash tag | {...} forcing related keys into the same cluster slot. |
| Dragonfly shard-thread | Each key has one owner thread; different shards run in parallel. |
| Materialised total | A periodically recomputed sum so reads cost one GET. |
Diagnose which of the three you have before reaching for a fix.
← all notes