Caching
Part 4 of 8 · Redis cacheSingle-Flight Locking for Cache Fills
Coalesce concurrent misses with SET NX PX. Lock TTL vs load p99. Token + Lua unlock. Waiters poll the data key. Combine with in-process singleflight.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Overview
Jitter stops many keys from expiring together. It does not split one key. This lesson is the mutex that makes "200 concurrent misses" become one DB hit.
By the end of this lesson you should be able to:
- Acquire with
SET key token NX PX msin one command - Size lock TTL from loader p99, not from a guessed 1 second
- Release only if you still own the lock (Lua
GET+DEL) - Make waiters poll the cache, with a timeout policy that is not "everyone loads"
- Combine Redis with in-process singleflight, and leave Redlock on the shelf unless you have a real multi-master reason
Eight pods miss the same profile
Prefer
SET NX PX + token Lua unlock; waiters poll GET
One loader. Seven waiters. db_hits === 1. Unlock cannot steal the next owner's lock after your TTL fires.
- Atomic acquire: NX and PX in one SET. Crash cannot leave a lock without TTL.
- Waiters never fall through to the DB as their first move.
- In-process singleflight sits in front so one pod does not take eight Redis locks.
Alternative
Every miss loads, then DEL the lock when done
Simple, and it is how stampedes are born. Bare DEL after expiry deletes someone else's mutex.
- N identical SQL queries; pool saturation; p99 cascade.
- SETNX then EXPIRE as two commands: crash in between = immortal lock.
- Bare DEL after your TTL elapsed unlocks the next loader mid-query.
Miss path with a mutex
Vertical path. Same protocol as the mermaid and the playground.
- 1
GET the data key
Hit → return. This is the common path; the lock is not involved. - 2
Miss → SET lock token NX PX
Lock TTL ≥ p99 load, with margin. Only one caller gets OK. - 3
Winner double-checks the data key
Another worker may have filled it. Then load the primary, SET with jittered TTL. - 4
Unlock with token-checked Lua
GET lock == token then DEL. Never bare DEL. - 5
Waiters poll GET on the data key
Backoff. On timeout: serve stale if you have a soft TTL, else one degraded load or 503 — not N loads.
Why coalescing exists
When a popular key expires under concurrency:
- N request handlers all see miss
- All N hit Postgres/MySQL for the same row
- DB CPU and the connection pool saturate → latency cascade → retries → worse
That is a cache stampede (thundering herd on a miss). Single-flight / request coalescing means: only the first request performs the expensive load; the rest wait for the result. Single-flight caps DB load at one loader per key, but at TTL expiry every other request still waits or polls until the fill lands, so latency spikes; a soft TTL or early recompute refreshes before expiry so readers keep getting the old value.
It does not prevent a herd when many distinct keys expire together — that is jitter. It does not prevent a herd when the lock TTL is shorter than the load — two winners. It does not make Redis the source of truth.
Sequence
- 1
Pod A → Redis
Step 1 GET data:42 - miss
- 2
Pod A → Redis
Step 2 SET lock:42 tokenA NX PX 1500
- 3
Redis → Pod A
OK - A is the loader
- 4
Pod B → Redis
Step 3 SET lock:42 tokenB NX PX 1500
- 5
Redis → Pod B
nil - lock busy, B becomes a waiter
- 6
Pod A → Primary DB
Step 4 Load row 42
- 7
Pod B → Redis
GET data:42
- 8
Pod A → Redis
Step 6 SET data:42 value EX 30
- 9
Pod A → Redis
Step 7 Lua - DEL lock:42 only if it still holds tokenA
- 10
Pod B → Redis
Step 8 GET data:42 - hit, return
- 11
Pod A
Failure path - load outlives PX 1500, lock expires, a second loader starts
Lesson map
Single-Flight Locking for Cache Fills
Coalesce concurrent misses with SET NX PX. Lock TTL vs load p99. Token + Lua unlock. Waiters poll the data key. Combine with in-process singleflight.
Architecture. Pod A Ready. Pod B Ready. Redis Ready. Primary DB Ready. Failure path - load outlives PX 1500, lock expires, a second loader starts Ready
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB A["Pod A Ready"] B["Pod B Ready"] R["Redis Ready"] DB["Primary DB Ready"] fail["Failure path - load outlives PX 1500, lock expires, a second loader starts Ready"] A -->|Step 1 GET data:42 - miss| R A -->|Step 2 SET lock:42 tokenA NX PX 1500| R R -->|OK - A is the loader| A B -->|Step 3 SET lock:42 tokenB NX PX 1500| R R -->|nil - lock busy, B becomes a waiter| B A -->|Step 4 Load row 42| DB B -->|Step 5 GET data:42| R A -->|Step 6 SET data:42 value EX 30| R A -->|Step 7 Lua - DEL lock:42 only if it still holds tokenA| R B -->|Step 8 GET data:42 - hit, return| R A -->|load outlives PX 1500| fail
The Redis mutex
Acquire atomically:
SET lock:{key} {random_token} NX PX {lock_ttl_ms}NX— only if the lock key does not existPX— millisecond TTL so a dead loader cannot hold it forevertoken— a random UUID per acquire, not a constant"1"
Rules interviewers love:
- One command.
SETNXthenEXPIREis a bug: a crash between them leaves an immortal lock. AlwaysSET key val NX PX ms. - Lock TTL ≥ p99 loader latency, with a safety factor (1.5–2×), and a floor. Too short: the lock expires mid-load, a second loader starts, you paid for a stampede with extra code. Too long: a crashed loader blocks fills until TTL. Dynamic
max(p99_recent, min_ttl)adapts when the DB slows down. - Re-check the data key after acquire. The previous winner may have SET and unlocked between your miss and your NX.
- Waiters poll the data key, not the DB, not the lock key as a substitute for the value. Interval 10–50 ms with backoff; cap the wait. Optional
PUBLISHon fill for extremely hot keys — extra moving parts. - Release only if you still own it. Release the lock with a Lua script that deletes lock_key only if it still holds your token (a plain DEL can remove another worker's lock).
-- release: delete only if the token still matches
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
endBare DEL on unlock can delete another worker's lock after your TTL expired. That is how a "safe" mutex turns back into a stampede. The playground shows it.
After wait timeout, one degraded load is still better than all N loading immediately. If a soft TTL exists, prefer serving stale over a 503 or a herd.
In-process singleflight + Redis
A Redis lock is across pods. Inside one process, 80 concurrent requests for user:1 would still issue 80 SET NX calls, and 79 waiters would still poll Redis.
Language-level coalescing (Go singleflight.Group, similar patterns in other runtimes) makes one in-process loader. Combine them:
- In-process group keyed by the cache key
- That one goroutine / task talks to Redis (GET, then NX lock if needed)
- Redis lock coalesces across instances
In-process alone is not enough at 20 replicas. Redis alone works but wastes chatter. Both is the production shape.
Per-key locks (lock:<key>) maximize concurrency. Bucketed locks (lock:<hash-bucket>) shrink keyspace and create false contention. Start per-key.
Redlock, briefly
Redlock is an algorithm that takes a lock on a majority of independent Redis masters, with clock-drift assumptions. It is aimed at distributed locking where correctness of the lock is the product.
For cache fills, use single-node SET NX PX with a token; skip Redlock. A duplicate fill only costs one extra DB read, so a best-effort lock is enough; if correctness depends on the lock, use fencing tokens or a consensus store, not Redlock. You are optimizing load, not linearizing a bank transfer.
For cache fills a single-node SET NX PX is enough even across regions (a rare double load is harmless); Redlock's timing assumptions are disputed, so use fencing tokens or a consensus store when the lock guards correctness. Do not oversell Redlock in this interview. Mention it only to say: single-node SET NX PX with a token is the cache mutex; Redlock is a different tool.
Deep dive · What waiters should do when the winner dies
The lock TTL exists so this is recoverable. When PX fires, a waiter’s next NX succeeds and becomes the new loader. That is why TTL must exceed a healthy load but not an infinite hang. If you also store a soft-TTL value, waiters can return stale immediately and not wait at all. A 503 after timeout is valid when stale does not exist and the product would rather error than stampede. A retry-as-loader after timeout should still take the mutex — not skip it.
Failure handling
| Event | Prefer |
|---|---|
| Winner loads OK | SET data + jittered TTL; Lua unlock |
| Winner crashes | Lock expires; another waiter acquires |
| Wait timeout, soft value exists | Return stale; optionally trigger background refresh |
| Wait timeout, nothing cached | 503 or one load under the mutex; circuit-break the DB |
| Redis down | Same fail-open / fail-closed policy as the hub. Fail-open without a QPS cap is a self-DDoS |
| Lock contention metric climbs | Hot key: local L1, key sharding, or XFetch so you miss less |
Observability is part of the design: acquire rate, acquire failures, wait timeouts, duplicate-load count (should be ~0), rebuild delta, DB QPS on that key.
Working sketches
The TypeScript client below is a complete getOrLoad: SET NX PX with a token, a cache re-check after the lock, token-checked Lua release, and bounded backoff. It needs ioredis. The Python sketch shows the same re-check, and _publish_result stores the ttl argument. The sandbox runs that protocol on a dict.
# Sketch — redis-py shape. Not runnable here (needs a server).
import uuid
CACHE_TTL_SEC = 30
LOCK_TTL_MS = 1500
RELEASE = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
"""
def _publish_result(r, key, value, ttl):
# Honor ttl. Hard-coding CACHE_TTL_SEC here dropped the caller's TTL.
r.set(key, value, ex=ttl)
def get_or_load(r, key, loader, ttl=CACHE_TTL_SEC):
cached = r.get(key)
if cached is not None:
return cached
token = str(uuid.uuid4())
lock_key = "lock:" + key
if r.set(lock_key, token, nx=True, px=LOCK_TTL_MS):
try:
again = r.get(key) # re-check the cache after acquiring the lock
if again is not None:
return again
fresh = loader()
_publish_result(r, key, fresh, ttl)
return fresh
finally:
r.eval(RELEASE, 1, lock_key, token)
return None # waiter: poll GET with backoff until timeout/*
* singleFlightRedis.ts
* Redis-backed single-flight cache fill in Node.js.
* Public API: getOrLoad(key, loader, ttlSec?) -> Promise<T>
* Dependencies: npm i ioredis
*/
import Redis from "ioredis";
import { randomUUID } from "node:crypto";
const redis = new Redis({ host: "127.0.0.1", port: 6379 });
const LOCK_TTL_MS = 1500; // must exceed p99 load latency
const POLL_BASE_MS = 20;
const MAX_RETRIES = 25;
const CACHE_TTL_SEC = 30;
const RELEASE_LUA = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end`;
/** Try to become the loader; returns the token on success, null if the lock is busy. */
async function acquireLock(lockKey: string): Promise<string | null> {
const token = randomUUID();
const ok = await redis.set(lockKey, token, "PX", LOCK_TTL_MS, "NX");
return ok === "OK" ? token : null;
}
/** Release only if we still own the lock (token check + DEL in one Lua call). */
async function releaseLock(lockKey: string, token: string): Promise<void> {
await redis.eval(RELEASE_LUA, 1, lockKey, token);
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function getOrLoad<T>(key: string, loader: () => Promise<T>, ttlSec = CACHE_TTL_SEC): Promise<T> {
const lockKey = `lock:${key}`;
const dataKey = `data:${key}`;
const cached = await redis.get(dataKey);
if (cached !== null) return JSON.parse(cached) as T;
const token = await acquireLock(lockKey);
if (token) {
try {
const again = await redis.get(dataKey); // another pod may have filled it
if (again !== null) return JSON.parse(again) as T;
const fresh = await loader();
await redis.set(dataKey, JSON.stringify(fresh), "EX", ttlSec);
return fresh;
} finally {
await releaseLock(lockKey, token);
}
}
let backoff = POLL_BASE_MS;
for (let i = 0; i < MAX_RETRIES; i++) {
await sleep(backoff);
const polled = await redis.get(dataKey);
if (polled !== null) return JSON.parse(polled) as T;
backoff = Math.min(backoff * 1.5, 500);
}
throw new Error(`cache fill timeout for ${key}`); // or serve stale / 503
}In-memory Redis (run this)
Dict-backed GET / SET / SET NX PX / Lua-style token unlock. Eight concurrent misses. Bare DEL vs token unlock.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Read the last four lines: bare DEL stole lock; owner-3 NX True vs lua unlock expired owner 0 and third NX False. That is the whole unlock lesson.
Per-process single-flight (run this)
Combine this map with Redis SET NX in multi-instance deploys. Alone, it only coalesces inside one process. 200 pods with only this map still issue 200 origin loads.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Interview Q&A
How do you coalesce concurrent cache misses in Redis?
Answer
SET lock:<key> <uuid> NX PX <lock_ttl_ms>. The winner loads and SETs the data key, then unlocks with token-checked Lua. Losers poll GET on the data key. In-process singleflight sits in front so one pod does not stampede the lock key.
How do you pick lock TTL?
Answer
Greater than p99 (or p99 × 1.5–2) of the loader, with a minimum. Too short: overlapping loaders. Too long: a dead loader blocks fills. Prefer a histogram-based value over a hardcoded 1 s.
Why is SETNX then EXPIRE wrong?
Answer
Two round trips. A crash after SETNX before EXPIRE leaves a lock with no TTL. The next miss never acquires. Always SET with NX and PX together.
Why not bare DEL to unlock?
Answer
If your lock TTL expired, another worker may own the lock. DEL deletes their mutex. Token Lua: delete only when GET equals your token. The playground's owner-3 NX is True after bare DEL and False after Lua.
What do waiters do?
Answer
Poll the data key with backoff. They must not all hit the DB. On timeout: stale if a soft TTL exists, else 503 or a single degraded load that still takes the lock.
Do you need Redlock here?
Answer
No. Single-node SET NX PX with a token; skip Redlock for cache fills. A duplicate fill only costs one extra DB read, so a best-effort lock is enough; if correctness depends on the lock, use fencing tokens or a consensus store, not Redlock. Redlock's timing assumptions are disputed, and a rare double load across regions is harmless.
How does this combine with XFetch?
Answer
XFetch refreshes before hard expiry so true misses are rare. Single-flight still wraps the hard miss and any refresh you want to coalesce. They stack; they are not substitutes.
In-process singleflight without Redis?
Answer
Coalesces inside one replica only. Twenty pods still issue twenty loads. Use both: process-local group, then Redis NX for the winner of each group.
What if the winner dies after SET data but before unlock?
Answer
Waiters already see the data key and return. The lock expires on PX and the next miss is a cheap no-op acquire. Unlock is an optimization for lock reuse, not the publish path — publish the data key first.
What metrics prove it works?
Answer
db_hits per hot key under a load test should be ~1. Track lock acquires, wait timeouts, duplicate loads, rebuild delta, and DB QPS. The production join is origin QPS + miss ratio + lock waits. If duplicate loads climb, lock TTL is too short or waiters are falling through.
Pitfalls
Draw eight arrows at GET cache:user:1 after expiry. Assign one NX win and seven polls. Then move the lock TTL to 50 ms and the load to 200 ms — how many winners? Run the playground, change stampede(8, True) to a version where the lock TTL is 0 so _alive expires immediately, and watch db_hits climb.
Implement (on paper) Lua unlock and a waiter timeout that returns stale. Name the Go type you would wrap this in (singleflight.Group).
Go Deeper
Official docs and the paper
In this cluster
- Cache TTL design and jitter
- Next: Negative caching
- Hub: Redis cache-aside, stampede
- HTTP cousins: request collapsing · SWR