notes~/ notes/redis-hot-keys
~/ cat notes/redis-hot-keys.md

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.

1 command at a time, per nodeNX PX is one atomic commandLua for multi-step atomicityevery like is a real event

00Three problems, not one

READ-HOT KEYone existing value, millions of GETsL1 in-process cache firstthen salted physical copiesread replicas · CDN for whole responsesCACHE STAMPEDEvalue expires, everyone misses at onceper-pod coalescing (one leader)+ distributed SET NX PX winnerone rebuild, everyone reuses itWRITE-HOT KEYmillions of real mutations, one countershard the counter into bucketsdurable stream + batch + aggregateevery event matters — cannot collapsethe one-line testif every operation produces the same state it is duplicate work — collapse it. if each one is a distinct business event, it is real load.
Diagnose which of the three you have first. The fixes do not transfer between them.
the test that separates them

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

hot:key expires · 1,000,000 requests misslevel 1 — per-pod coalescingin-flight map: 1 leader, 9,999 await the same promise1M → ~100 (one per pod)level 2 — SET lock:key token NX PX 5000exactly one pod wins the rebuild~100 → 1one DB read · one SET hot:keythe losers do notreceive the value —they wait brieflyand re-read the cache
Two levels, because each one alone leaves the other multiplier in place.

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 detail that catches people

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

t=0st=5st=8spod A holds the lock — TTL 5spod A is still doing the DB work — 8spod B takes a fresh lock and repeats the worklease expires herefixes: renew the lease while working · pick a TTL above worst-case · fence with a version if a stale writer must never winfor a pure cache rebuild, a little duplicate work is often the cheapest answer
The owner token stops A deleting B's lock. It does not stop the duplicate work.

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 0

GET 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

QuestionRedisDragonfly
Two different keys, concurrently?No — dataset execution is serialised per nodeYes, if they map to different shard-threads
The same key, concurrently?SerialisedSerialised — same key, same owner shard-thread
One command spanning shards?N/A on one nodeNeeds cross-shard transaction coordination
How you get parallelismMore nodes / shardsThreads 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.

FixHow it helpsCost
L1 in-process cacheMost requests never reach Redis at allStaleness — needs a short TTL or invalidation
Salted physical copieshot:key:0…9, reader picks by hash — spreads across shardsWrite amplification and keeping copies consistent
Read replicasMore read capacity for the same dataReplication lag
CDNStops shared HTTP responses reaching the appOnly for client-facing responses — a pod should not call a CDN for internal data

06Write-hot keys

Cache stampedeWrite-hot key
What is happeningMany callers repeat the same rebuildMany real mutations to one logical key
Can you collapse the work?Yes — one rebuild sufficesNo — every event matters
Example1M misses querying the same product row1M users each adding +1 like
FixCoalescing + one distributed winnerShard 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

like requestbucket = hash(user) % Nkey = video_id + ":" + bucket→ Kafkapartition 0partition 1partition 2consumer → one Lua scriptif offset applied → skipelse INCR bucket + record offsetlikes:0likes:1likes:2likes:3…one key per bucket, spread across slotsaggregator, every ~1sSET total = sum(buckets)GET totalone read for the UISET an absolute total, never INCRBY a delta — a retried aggregation must not double-countand never INCR a likes:total on every like, or you have rebuilt the hot key you just removed
Kafka spreads consumption, bucket keys spread the writes, Lua makes the redelivery safe.

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
Redis Cluster detail worth knowing

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

OptionRead costTrade-off
Read-time sum of bucketsO(buckets) per readSimplest; cross-slot means parallel GETs, not one MGET
Periodic materialised totalOne GETEventually consistent by the aggregation interval
Stream aggregationOne GETAnother 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

TermOne line
Read-hot keyOne existing value read enormously often → L1 cache, salted copies.
Cache stampedeA value expires and everyone rebuilds it → coalesce to one rebuild.
Write-hot keyMany real mutations on one key → shard, stream, aggregate.
Request coalescingOne leader does the work; everyone else awaits its result.
SET NX PXSet-if-absent with a TTL, as one atomic command.
Owner tokenA value proving you still hold the lock, so you cannot delete someone else's.
Lua scriptMultiple Redis commands executed as one atomic region on a node.
Hash tag{...} forcing related keys into the same cluster slot.
Dragonfly shard-threadEach key has one owner thread; different shards run in parallel.
Materialised totalA periodically recomputed sum so reads cost one GET.

Diagnose which of the three you have before reaching for a fix.

← all notes