Skip to content
SeeItFlow
System Design · 11 chapters · 30 incidents · ~63 min read

Distributed Cache Failure Scenarios: Stampedes, Hot Keys & Recovery

Thirty hypothetical incidents for a fictional storefront's distributed cache, from node loss and cold clusters to stale refill races, hot keys, lost writes on failover, tenant leaks and regional staleness. Each is told as a trigger, timeline, symptom, root cause, detection, containment, recovery, prevention, the guarantee that still holds and the trade-off you accepted, with a note on whether the data can simply be rebuilt.

Mangesh MandlikLast updated: How lessons are made
Overview

The Failure Map: What Breaks in a Cache, and Who Decides

A cache fails in two directions at once: it can disappear and push all its traffic onto the database, or it can stay up and quietly say something false. Thirty incidents, grouped by mechanism, walk through both.

Everything here is a hypothetical scenario on a fictional storefront, Meridian Commerce. The Product API runs as 20 pods against a relational product database; cache shards A, B and C (D after resharding) have asynchronous replicas A′, B′ and C′; two regions, EU and US; tenants Cedar (large, sometimes noisy) and Birch (well-behaved); the hot product is product:4821, a $99 item at record version v5 that becomes $109 at v6. Nothing describes a real outage at a real company, and every number is a planning input or a derivation from one, not a measurement. An interactive Visual Design Journey with 44 scenes sits on the Complete Design page if you want to watch the machinery before watching it break.

Each incident is told as a short story and then pinned down in a table: trigger, timeline, visible symptom, root cause, detection, containment, recovery, prevention, the guarantee that survives, and the trade-off you accepted. One more row matters in a cache more than in most systems: whether the data lost or served wrongly can simply be rebuilt from the product database, or whether somebody treated the cache as the only copy. In most incidents the answer is 'rebuild it'; the exceptions are the ones where the cache was trusted to remember something (F16, when it held the only copy) or to authorize something (F20, F22), and there the damage is a business or security error, not a cold cache.

Load shifted versus truth shifted

Failures that remove the cache (a dead shard, a cold start, a retry storm) are mostly load failures: the answers are still correct, they just arrive from the database, which was sized assuming the cache absorbed 72% of the read work. Failures where the cache stays healthy but wrong (a stale refill, a lost invalidation, a collided key, a lagging region) are truth failures: latency looks excellent, hit rate looks excellent, and the dashboard stays green. The two need different instruments. Load failures show up in database queries per second and queueing; truth failures show up only when you compare cached content with the authority, or when a customer notices.

Baseline numbers used throughout

Peak traffic is 60,000 requests per second, 80% of them cache-eligible (48,000 per second) with a 90% hit rate, so 4,800 eligible misses plus 12,000 non-eligible requests give the database 16,800 reads per second. The hypothetical database read capacity is 24,000 per second (70% utilised at peak, 19,200 as the 80% operating ceiling). Four shards share the 48,000 GETs, 12,000 each against a hypothetical 25,000 ops/s per-shard budget. A hit costs about 3 ms and a miss about 85 ms, so the mean is 11.2 ms while p99 sits near the miss latency. These are the same figures the other four pages use.

A few terms are used from here on without further introduction. Cache-aside means the Product API checks the cache first and, on a miss, reads the database and writes the result back. TTL (time to live) is the lifetime after which an entry expires. Stale-while-revalidate serves an expired value for a short extra period while one request refreshes it in the background. L1 is a small process-local cache inside each API pod, in front of the shared cache. Cached data falls into four classes with different rules: C1 descriptive content (title, images; TTL 15 minutes, stale-while-revalidate allowed), C2 the price shown on the product card (event invalidation plus a TTL of 300 s ± 20%; always revalidated at checkout), C3 the availability badge (display only, TTL 30 s) and C4 data that must never be served from a TTL-only cache: inventory reservation, payment secrets and authorization decisions.

IDIncident (hypothetical)CategoryWhat breaksData at stake
F01Cache node dies during peak hitsOutage & cold startShard's hits become DB readsRebuildable
F02Cold start after a rolling deployOutage & cold startWarm-up misses on one shardRebuildable
F03Whole cluster cold after a regional restartOutage & cold startEvery eligible read reaches the DBRebuildable
F04Synchronized TTL expiryStampedeThousands of keys expire togetherRebuildable
F05Single-flight leader crashes mid-fillStampedeWaiters stranded on a dead leaderRebuildable
F06Lease expires during a slow DB fetchStampedeDuplicate refills and out-of-order writesRebuildable
F07Delayed fill overwrites a fresh invalidationFreshnessStale value repopulated after the writeRebuildable
F08Invalidation event lost, replayed or reorderedFreshnessSubscriber state diverges from the DBRebuildable
F09Repeated cache outage becomes a retry stormOutage & cold startRetries and reconnects exhaust poolsRebuildable
F10Hot key concentrates CPU on one shardHot keys & skewOne shard saturatesRebuildable
F11Trending SKU overloads its shard and stampedesHot keys & skewSkew plus synchronized refillRebuildable
F12LRU thrash when the working set outgrows RAMMemory & evictionHit rate collapsesRebuildable
F13Never-expire data starves memoryMemory & evictionEvictable space disappearsRebuildable
F14Oversized or compressed-bomb payloadMemory & evictionBandwidth, GC and memory pressureRebuildable
F15Negative-cache poisoning and random-key missesTenancy & abuseMisses bypass the cache; false 'not found'Rebuildable
F16Failover promotes a lagging replicaReplicationAcknowledged writes and deletes vanishRebuildable, or a business error if the cache was the only copy
F17Partition and stale client routingRoutingClients disagree on the ownerRebuildable
F18Resharding concurrent with writesRoutingMissed invalidation or duplicate copiesRebuildable
F19One tenant dominates shared memory and QPSTenancy & abuseNeighbors get evicted and throttledRebuildable
F20Cross-tenant key collisionTenancy & abuseWrong tenant's content servedData exposure — a security incident
F21Stale price after a DB updateFreshnessCard price differs from the DBRebuildable; a business error if checkout trusts it
F22Revoked permission served from cacheFreshnessUnauthorised access continuesSecurity incident
F23Regional lag and residencyFreshnessRegion serves old or out-of-region dataRebuildable; residency breach is a compliance error
F24Cache and DB slow togetherOutage & cold startLatency compounds into saturationRebuildable
F25Skewed distribution from a weak shard hashHot keys & skewKeys pile onto one shardRebuildable
F26Eviction quality collapses on a loop access patternMemory & evictionNear-zero hit rateRebuildable
F27Replica catch-up pressure after failoverReplicationResync starves the primaryRebuildable
F28Service-discovery outage with warm clientsOutage & cold startTopology cannot be updatedRebuildable
F29Metrics outage hides a hit-rate collapseObservability & changeNobody sees the failureRebuildable
F30Key-schema migration with incompatible serializersObservability & changeOld and new app versions cannot read each otherRebuildable
All 30 incidents. 'Rebuildable' means the authority can regenerate every lost or wrong entry.
Swipe to pan
Availability versus consistency by data classDecide Per Data Class: What Fails Open, What Fails Closedthere is no single cache policy; the data's cost of being wrong sets itData classFreshness budgetWhen the cache is down or staleLeans towardC1 Descriptivecontenttitle, description, imagesTTL 15 min ± 20%stale-while-revalidate ≤ 5 minServe stale within the window;else DB, inside the fallback capAvailabilityC2 Priceon the cardnon-binding displayevent invalidation + TTL 300 s ± 20%worst case ≈ 360 sDB under the 7,200/s cap, shed rest;checkout always revalidatesBounded stalenessC3 Availabilitybadgedisplay only, reserves nothingTTL 30 sHide or show a generic badge;never block the pageAvailabilityC4 Reservation,payment, permissionsnever TTL-only cachedno TTL-only copyrevocation epoch or re-checkAsk the authority; if unreachable,fail closed (deny / retry)ConsistencyA cache entry never reserves stock or decides ownership; the authoritative service does.
What survives which failure: the matrix every containment step on this page is trying to preserve.
F01, F02, F03, F09, F24, F28

Outages, Cold Starts and Fallback Failures

When cache capacity vanishes, the database inherits the read traffic it was never sized for. The interesting work is deciding who gets served, who waits, and who is told no.

One piece of arithmetic recurs in this chapter. At peak the database has 24,000 − 16,800 = 7,200 reads per second of spare capacity before it hits its limit, but the planned 80% ceiling (19,200) leaves only 19,200 − 16,800 = 2,400 per second of headroom above normal operation. Every outage below is a question of what happens to the demand that exceeds that headroom.

F01 — A cache node dies in the middle of peak traffic

At 20:14 shard B, which owns a quarter of the key space, stops answering. Its replica B′ is also in the failed rack, so there is nothing to promote. (If instead the replicas sit in a different zone from their primaries, a zone loss costs only the failure-detection gap plus the F16 loss window, because a replica is promoted rather than a cold shard rebuilt; the fallback arithmetic below then applies to that short gap rather than to the whole recovery.) Clients see connection errors within a timeout or two, and the Product API's cache-aside code does what it was written to do: treat an error as a miss and read the database. Of the 43,200 hits per second at peak, a quarter, 10,800, were B's. They are now database reads on top of the 16,800 already flowing, 27,600 in total, 115% of the 24,000 capacity (derived, assuming uniform distribution).

Swipe to pan
Zone failure: replicas in another zone are promoted instead of rebuilding cold shardsZone 1 Fails: Promote Replicas, Do Not Rebuildprimaries in zone 1, replicas in zone 2 · detection gap of a few seconds is hypotheticalProduct APIPrimaries (zone 1)Replicas (zone 2)Database (capped)normal GETs and SETsasync replication (lag)zone 1 losttimeout → treat as missread database, admission-cappeddetector promotes replicasafter the detection gapGETs now served, still warmCost of the failure: the detection gap plus the replication-lag window.Writes acknowledged by a primary but not yet applied on its replica are lost; the database is the source of truth.If replicas shared the primaries' zone, nothing would survive and the full working set would refill cold.Keep admission caps on the fallback path: the gap is when the database is exposed.
Zone loss with replicas in another zone: clients fall back briefly, then the replicas are promoted without a cold refill.

Unbounded fallback turns a quarter-cache outage into a full database incident. With a 2,400/s admission budget for the extra misses, the API admits those and sheds or degrades the other 8,400 (78% of the lost hits) by class: stale-while-revalidate content for C1 descriptive data, last-known badge for C3, an honest 'price temporarily unavailable, confirm at checkout' for C2, and refusal rather than guessing for anything in C4. Shedding is chosen on purpose rather than discovered when the database falls over.

FacetF01 at a glance
TriggerShard B and its replica are lost together at peak (60,000 req/s).
Timelinet = 0 shard unreachable; t ≈ 1 s clients time out and fall back; t ≈ 5 s database reads rise from 16,800 to a demanded 27,600/s; t ≈ 10 s admission limit engages; t ≈ minutes B is replaced and refills.
Visible symptomPages slow for B's keys (miss path, ~85 ms or worse), degraded fields on some responses, error-rate bump on the cache client.
Root causeA quarter of the working set was held on one failure domain with no surviving copy, and the fallback path had no admission cap.
DetectionCache client error rate per shard, hit rate falling by about 22 points, database read QPS crossing 19,200, fallback-admission counter and shed counter.
ContainmentCap fallback at the planned headroom; serve stale only for classes that allow it; open the circuit breaker so requests stop waiting on a dead shard.
RecoveryReplace B, let it refill by demand (see F02), then lift the cap gradually as hit rate recovers.
PreventionReplicas in a different failure domain, per-class fallback rules written down before the outage, load-tested admission limits.
Guarantee that still holdsEvery response is either correct from the authority, explicitly stale from a permitted class, or an error. Nothing is invented.
Trade-offKeeping a standby replica per shard doubles the memory bill (15.27 → 30.5 GiB in the sample model) to avoid this cold refill.
Rebuildable or business errorRebuildable. The product database holds every entry.

F02 — Cold start after a rolling deployment

A platform engineer restarts the cache shards one at a time to pick up a configuration change, and the shards keep their data only in memory. When shard A comes back empty, its quarter of the traffic turns into misses. Hot product 4821 refills with a single fetch; the long tail of millions of objects warms only as shoppers ask for them, so the hit rate on A climbs from 0% along the popularity curve rather than in one step.

Timing decides whether this matters. At the 12,000 req/s average, A sees 2,400 eligible GETs per second and, fully cold, sends all 2,400 to the database: 3,360 + 2,160 extra = 5,520 reads per second, about 23% of capacity (the 90% of A's traffic that was previously hit; derived). At peak the same restart adds 10,800 per second and pushes the database to 27,600, the same 115% as F01. The restart was safe at 03:00 and an incident at 20:00. Restarting a replica first and promoting it, or restoring a snapshot, avoids the empty start altogether.

FacetF02 at a glance
TriggerA cache shard restarts empty during a rolling change.
Timelinet = 0 shard A empty; first seconds the hottest keys refill; minutes to tens of minutes the hit rate on A climbs; the other shards stay warm.
Visible symptomHit rate on A near 0%, mean latency up (at 100% miss it is 85 ms for A's slice versus 11.2 ms overall), database reads up in proportion to traffic at that hour.
Root causeIn-memory state lost on restart and a deploy plan that ignored the time of day.
DetectionPer-shard hit rate and key count (key count near zero is the giveaway), database QPS annotated with deploy events.
ContainmentPause the roll; if at peak, stop the deploy and let A warm before touching B; apply the F01 fallback cap.
RecoveryLet demand refill, or pre-warm only the top few thousand keys from the database; do not scan the full 8,000,000.
PreventionRoll caches off-peak, one shard at a time, promote a warmed replica when you have one, gate the next step on hit rate, not on elapsed time.
Guarantee that still holdsReads stay correct; only latency and database load change.
Trade-offSlow, gated rollouts lengthen maintenance windows; eager warming spends database capacity you were trying to save.
Rebuildable or business errorRebuildable.

F03 — Whole cluster cold after a regional restart

After a power event takes down the whole EU cache tier, every shard returns empty at 19:30, just as evening traffic reaches its peak. The cache's job was to absorb 72% of read work; with nothing cached, the eligible 48,000 requests per second all become database reads, 60,000 per second in total, 2.5× the 24,000 capacity. Connections queue, miss latency climbs from 85 ms to seconds, the API's own thread pools fill with waiters, and the cache cannot warm because every fill requires a database read that is now timing out. A cache that is cold at peak does not recover by waiting.

Swipe to pan
Cache outage: the database cannot absorb the redirected readsCold or Dead Cache: Most Reads Must Be Shed or Served Stalepeak, cache fully down · DB ceiling 80% of a hypothetical 24,000 reads/sEligible reads redirected to the backend: 48,000/s (2,880,000 per one-minute outage)7,20040,800/s (85.0%) cannot be served from the DBadmitted: 19,200 ceiling − 12,000 non-eligible = 7,200/s fallback cap48,000 / 7,200 = 6.7× oversubscribedMitigations, in the order a request meets them1 Local L1 / stale copyServe a stale value for classes that allow it (C1 content, C3 badge); price onlywithin its bound.2 Single-flight + capCollapse duplicate misses; admit at most 7,200/s to the DB, with per-requestdeadlines.3 Shed and degradeReject or degrade the rest: hide the badge, show a placeholder, return 503 withRetry-After for the lowest priority.4 Fail closed for C4Permissions, payment data and stock reservations never come from a TTL-only cache:ask the authority or deny.Warm-up is the second half of recovery: refill gradually (jitter, single-flight), or the restart becomes a second stampede.
A cold cluster: demand, a bounded fallback path, and the admission cap that keeps the database alive.

Admission control is the only way out. The database planned ceiling minus non-eligible traffic leaves 7,200 per second for cache-eligible fallback, so 40,800 per second (85%) of eligible demand has to be shed, served stale from an allowed class, or degraded. A practical sequence: admit about 15% of regional traffic (7,200 ÷ 48,000) with priority to the hottest keys, let those fill the cache, then ramp. If every admitted miss were a distinct key, 1,000,000 keys would take about 139 seconds to fill at 7,200 per second (illustrative); because popularity is skewed, the hit rate recovers much faster than a uniform count suggests, but it is a ramp, not a switch.

FacetF03 at a glance
TriggerAll cache shards in a region restart empty at peak.
Timelinet = 0 cache empty; t ≈ seconds database saturates; t ≈ 1–2 min queueing and timeouts spread to the API; t ≈ minutes with admission control, hit rate rebuilds hot keys first and traffic is ramped back.
Visible symptomSlow or failed pages across the region, database CPU and connection pool pinned, retry traffic rising.
Root causeDependence on a warm cache for capacity, with no cold-start plan and no admission limit.
DetectionHit rate near zero and key count near zero, database QPS at or above capacity, queue depth and timeouts; the cache itself may look 'up'.
ContainmentShed to the fallback cap (7,200/s), serve stale only where allowed, route a fraction of traffic, protect writes and checkout first.
RecoveryWarm hot keys in popularity order, ramp admitted traffic in steps keyed to database headroom, then relax shedding.
PreventionTreat cold cache as a first-class load case; rehearse it; keep a persistent snapshot or warm replica for the hot set; load test the database at 2.5× before launch.
Guarantee that still holdsNo incorrect answers; capacity, not correctness, is what degrades.
Trade-offShedding 85% of eligible reads for a few minutes is a visible degradation that you choose over a database collapse that lasts much longer.
Rebuildable or business errorRebuildable.

F09 — Repeated cache outage becomes a retry storm

Shard C flaps: up for ten seconds, down for ten. The client library retries each failed GET three times with no backoff, and every retry that fails still holds a request thread for the 50 ms timeout. A request that would have taken 3 ms now spends up to 200 ms stuck on the cache before it even reaches the database. The service's concurrency is spent waiting on a dependency that was supposed to be optional.

The recoveries are worse than the failures. When C returns, 20 pods reopen a hypothetical 50 connections each, 1,000 at once, onto a freshly started process that is simultaneously rebuilding its memory. Meanwhile the fallback database reads from the previous flap are still draining. The loop feeds itself: flap, retry, queue, flap. The repairs are standard but must all be present: a circuit breaker that stops calling an unhealthy shard, jittered exponential backoff with a retry budget (for example no more than 10% extra attempts), connection ramp-up, and a short cache-client deadline so the miss path starts early.

FacetF09 at a glance
TriggerA shard oscillates between healthy and unreachable; clients retry immediately.
Timelinet = 0 first failure; seconds retries multiply attempts; t ≈ 10 s shard returns and a reconnect burst arrives; t ≈ 20 s next flap.
Visible symptomRising API latency and thread-pool saturation without a matching rise in traffic; connection-count spikes on the cache; database pool pressure.
Root causeUnbounded, synchronized retries and reconnects against an intermittently failing dependency.
DetectionAttempts per request, retry ratio, cache connection churn, API in-flight requests, breaker state.
ContainmentOpen breakers, drop retries to zero for the affected shard, take the shard out of rotation, apply the fallback cap.
RecoveryStabilize the shard first, then close breakers gradually (half-open probing) and ramp connections.
PreventionRetry budgets, jitter, short deadlines, connection pooling limits, circuit breakers and the retry-storm practices.
Guarantee that still holdsPer-request correctness is unchanged; only the delay and failure rate worsen.
Trade-offAggressive breaking means a briefly healthy shard may be avoided for a while, giving up some hits to avoid a storm.
Rebuildable or business errorRebuildable.

F24 — Cache and database are slow at the same time

A noisy neighbor on the cache hosts pushes hit latency from 3 ms to 40 ms while a long-running report pushes database miss latency from 85 ms to 400 ms. Nothing is down, so nothing alerts. Little's law shows why the service falls over anyway: at the 60,000 requests per second peak, with eligible reads averaging 0.9 × 40 ms + 0.1 × 400 ms = 76 ms and the other 20% (assumed to read the database) taking 400 ms, the mean request time becomes 0.8 × 76 + 0.2 × 400 ≈ 141 ms. Multiplied by the arrival rate, about 8,450 requests are in flight, roughly 420 per pod across 20 pods, against a hypothetical concurrency limit of 200 per pod (the healthy mean of about 26 ms needs only about 78 per pod). The pods saturate, queueing begins, and callers retry, which raises the arrival rate on an already slow system.

Every layer needs a bound: a 50 ms deadline on the cache call, a database deadline derived from the caller's remaining budget, and a breaker on each. Past that, the service sheds by class. C1 and C3 can go stale for a while; C2 price falls back to 'confirm at checkout'; C4 stays strict. A degraded page that arrives in 150 ms is a better product than a complete page that arrives never. The cascading-failure and load-shedding lessons cover the same ground for general services.

FacetF24 at a glance
TriggerCache latency rises to 40 ms and DB miss latency to 400 ms (both hypothetical) with no hard failure.
Timelinet = 0 both slow; t ≈ 30 s in-flight requests climb toward ~8,450 (about 420 per pod); t ≈ 1–2 min pod queues fill; retries amplify.
Visible symptomTimeouts and elevated p99 with healthy-looking error budgets at first; pods report saturation.
Root causeLatency without failure defeats a design that only reacts to errors; concurrency is unbounded relative to deadline.
Detectionp99 latency per dependency, in-flight requests per pod, queue depth, saturation, deadline-exceeded counts.
ContainmentTighten per-call deadlines, open breakers on the slow dependency, shed by data class, drop optional page parts.
RecoveryRemove the cause (move the report, relieve the host), then restore deadlines and retries gradually.
PreventionDeadlines everywhere, concurrency limits per dependency (bulkheads), load tests that inject latency, not just failures.
Guarantee that still holdsResponses that are returned are correct for their class; some are stale or reduced by design.
Trade-offA tight cache deadline will occasionally treat a slow-but-healthy shard as a miss.
Rebuildable or business errorRebuildable.

F28 — Service-discovery outage while clients are warm

The control plane that publishes the cache topology (DNS records or a discovery service) goes down for twenty minutes. Nothing breaks at first: running pods keep their connections and their last-known topology, hits keep flowing, and the dashboards look normal. Then a primary fails during the same window. Failover happens inside the cache cluster, but the clients that would normally learn the new address cannot, so they keep sending to the old one. A shard outage (F01) arrives through a side door. (This is the case for clients that take their topology only from the discovery service; a cluster-aware Redis client also learns new owners from -MOVED replies, which is one reason to prefer one.)

Worse, the autoscaler adds three pods for the evening spike. They start with no topology, cannot find any cache, and either fail readiness or run with an all-miss cache that hammers the database. The fix is to make discovery a soft dependency: pods persist a last-known-good topology snapshot and a static seed list, refuse to downgrade to an empty map, and recover topology from the cluster itself when it supports it (a Redis Cluster client may query any node for its slot map). Deploys and scale-ups are paused while discovery is unhealthy.

FacetF28 at a glance
TriggerDiscovery or DNS is unavailable and a topology change (failover or scale-up) happens in the same window.
Timelinet = 0 discovery down, no visible impact; t ≈ 8 min a primary fails and a replica takes over; t ≈ 10 min new pods start without topology.
Visible symptomErrors or timeouts for one shard's keys from existing pods; new pods unhealthy or cache-less; no change in discovery-independent hit rate for other shards.
Root causeClients depend on a control plane for topology changes, with no fallback source.
DetectionDiscovery health and resolution failures, topology-version skew between pods, client MOVED or connection errors by shard.
ContainmentFreeze deploys and autoscaling; push a manual topology update; apply the F01 fallback rules.
RecoveryRestore discovery, confirm all pods converge on one topology version, resume rollouts.
PreventionPersisted last-known-good topology, seeds from config, cluster-side slot discovery, game days that disable discovery during a failover drill.
Guarantee that still holdsReads remain correct; routing may be wrong (misses or errors), not the data.
Trade-offCaching topology locally means pods act on old information for a short time; the alternative is to stop serving whenever the control plane blinks.
Rebuildable or business errorRebuildable.
F04, F05, F06

Expiry and Stampede Failures

Expiry is how a cache stays honest, and it is also how a cache arranges for hundreds of identical database queries to arrive in the same instant.

A stampede (or thundering herd) is a burst of simultaneous misses for the same data. For a single key the size of the burst is easy to estimate: key 4821 at 8,640 requests per second with an 85 ms miss path sees 8,640 × 0.085 ≈ 734 requests arrive before the first refill finishes. Single-flight (request coalescing) answers that by letting one caller fetch while the others wait for its result. The three incidents below are about how the protection itself fails.

F04 — Synchronized TTL expiry across many keys

A catalog sync job finishes at 02:00 and warms 6,000 popular product entries in a single pass, all with a fixed 300-second TTL. Five minutes later all 6,000 expire at once. Single-flight does not help: it coalesces requests for one key, and these are 6,000 different keys, each needing its own fetch. If all 6,000 refills land inside one 85 ms miss window, the instantaneous demand is 6,000 ÷ 0.085 ≈ 70,600 reads per second against a 24,000 capacity, nearly 3× (derived, illustrative), and then it repeats every five minutes because the new entries are also synchronized.

Swipe to pan
A cache stampede on one expired keyExpiry Turns One Fetch into ThousandsHot key product:4821 · DB reads caused by this one key (flash-sale variant) · illustrativereads02,5005,000hits: DB sees ~0 for this key5,000retries, slow DBt0: TTL expires≥ 85 ms until the first refill landsTime →What goes wrong1 useful read, 4,999 identical ones.DB queue grows, so the refill is slower,so more requests miss and time out.Callers retry: the load multiplies.Steady state, same key: 8,640 req/s ×0.085 s ≈ 734 requests per refill gap.Collateral: other tenants share this DB.Many keys expiring together6,000 keys warmed at onceexpire in one instantWith ±20% jitter: ~50 keys/s over 120 s6,000 / 120 s = 50 per secondWhat jitter and locks do not fixJitter helps many keys, not one hot key.It cannot help a mass purge or a cold cache.Remedies: single-flight, stale-while-revalidate, early refresh, load shedding.
Without jitter 6,000 refills land together; with ±20% jitter they spread across 120 seconds.

Jitter spreads the expiries of a synchronized cohort, the pattern often called a cache avalanche. With TTL = 300 s ± 20%, each key expires uniformly between 240 and 360 seconds, a 120-second spread, so the 6,000 keys expire at about 50 per second rather than 6,000 at one instant. Jitter has limits that are worth stating: it does nothing for one hot key, for a mass purge that deletes everything together, or for a cold cluster, since those synchronize on something other than the TTL.

FacetF04 at a glance
TriggerBulk warm with a fixed TTL; 6,000 keys expire together.
Timelinet = 0 warm; t = 300 s all expire; t ≈ 300–300.1 s a miss burst; the burst re-synchronises the next cycle.
Visible symptomPeriodic database spikes at the TTL interval, p99 spikes on a schedule.
Root causeIdentical expiry times, so independent keys become a single event.
DetectionDatabase QPS with a period equal to the TTL; expiry-time histogram showing a spike; miss rate in bursts.
ContainmentAdd jitter to new writes now; stagger the warmer; temporarily rate-limit refill concurrency.
RecoveryThe existing synchronized cohort dissolves after one jittered cycle.
PreventionJitter on every TTL (±20% here), warm in batches, refill concurrency limits, stale-while-revalidate for C1.
Guarantee that still holdsCorrectness is unchanged; entries are as fresh as their TTL says.
Trade-offJitter makes the maximum staleness longer (360 s instead of 300 s) and expiry less predictable.
Rebuildable or business errorRebuildable.

F05 — The single-flight leader crashes mid-fill

In a flash sale, 5,000 concurrent requests miss on the same expired key. Each of the 20 pods runs a per-process single-flight, so about 250 waiters per pod queue behind one local fetch and the database sees at most 20 fetches instead of 5,000. If you want one fetch for the whole fleet, a distributed lease adds a second layer: the pod that holds the lease fetches, and everyone else waits or polls for the filled value.

Swipe to pan
Single-flight: one loader, many waiters, lease recoveryOne Loader, Everyone Else Waits (Briefly)5,000 concurrent misses on one expired key · 20 pods · lease = short-TTL key set only if absentPod A (leader)Pod B (waiter)Cache + leaseProduct DB1 miss → SET lease NX 250 ms: won2 miss → lease held: wait3 read row (≈ 85 ms)4 SET value, drop lease5 poll → hit, before deadlineFailure variant: leader dies after step 16 poll: still empty7 lease expires (250 ms)8 SET lease NX: won9 read row, fill, releaseDB fetches for the keyno protection: 5,000per-pod coalescing:≤ 20 (~250 waiters/pod)distributed lease: 1+ races at lease expiryWaiters need an exitWait with a deadline (e.g. a few hundred ms), then servestale-while-revalidate data, a default, or fail fast.Never queue forever behind a slow or dead leader.LimitsLease TTL shorter than the load can cause a secondloader; a lock is not a fencing token. It does nothelp many different keys expiring at once.
One fetch, many waiters, and what happens to the waiters when the leader never finishes.

Now the leader dies between taking the lease and writing the fill. A local single-flight is the simple case: the process and its waiters die together, and other pods run their own flights. With a distributed lease the other pods' waiters are looking at a lease that nobody is working on. They must not wait forever. Every waiter carries a deadline (for example, a few multiples of the 85 ms miss time), and when it fires the waiter either serves a stale copy if the class allows, fails fast, or, after the lease expires, one waiter takes over as the new owner. At 8,640 arrivals per second on the key, a 250 ms hypothetical lease means up to 8,640 × 0.25 ≈ 2,160 requests have to be handled by the deadline and stale paths while the system waits for the lease to expire. (The diagram above draws a 2-second lease; 250 ms is used here only to keep the arithmetic small, and a lease that long would mean 8,640 × 2 = 17,280 requests to absorb.)

FacetF05 at a glance
TriggerThe pod holding the refill lease is killed (OOM, deploy, node loss) mid-fetch.
Timelinet = 0 lease acquired; t = 40 ms leader dies; t ≈ 250 ms lease expires; a waiter becomes the new owner and refills.
Visible symptomA short burst of slow or stale responses for one key; no database spike if waiters release correctly.
Root causeWaiters tied to the liveness of a leader they cannot observe.
DetectionLease-acquire rate, waiters timing out, lease age beyond the p99 fetch time, orphaned lease counter.
ContainmentWaiter deadlines, stale-while-revalidate where allowed, automatic takeover at lease expiry.
RecoveryNew owner fills the key; lease state clears by TTL.
PreventionShort leases with TTL, deadlines on every waiter, tests that kill the leader at each step, a per-process flight in front of the distributed one.
Guarantee that still holdsWaiters either get the value the authority returned or a deliberate stale/error response; none hang.
Trade-offA short lease risks the F06 duplicate; a long lease lengthens the outage when the leader dies.
Rebuildable or business errorRebuildable.

F06 — The lease expires during a slow database fetch

The lease is 250 ms (hypothetical) and the database fetch usually takes 85 ms. A slow query takes 1,000 ms. At 250 ms the lease expires while the original owner is still waiting; a second caller takes it and begins a second fetch; at 500 ms and 750 ms two more do the same. The database now runs four identical slow queries, which makes it slower, which makes more leases expire: positive feedback with the database as the victim.

Two further problems hide in the same trace. The earlier fetch may finish after the later one and overwrite it with data read earlier. And a lock without a fencing check cannot tell the cache that its holder lost ownership. The practical defense is layered: lease length comfortably above the p99 fetch time, one renewal at most, a cap on concurrent fetches per key, and writes that are versioned so that an older read cannot replace a newer one (the same conditional fill that fixes F07). See distributed locks for why a lease cannot, by itself, provide mutual exclusion.

FacetF06 at a glance
TriggerDatabase latency rises above the lease duration.
Timelinet = 0 owner A starts; t = 250 ms lease expires, B starts; t = 500 ms C starts; t ≈ 1 s the earliest finishes last and writes.
Visible symptomDatabase query count per key above one; slower database; occasional older value overwriting a newer one.
Root causeTime-based ownership without a guarantee the holder finishes in time; unfenced writes.
DetectionConcurrent fetches per key, fills-per-lease ratio above 1, version regressions on fill.
ContainmentCap concurrent refills per key, lengthen the lease temporarily, shed optional reads.
RecoverySlow queries drain; entries settle at the latest version.
PreventionLease > p99 fetch time, fencing or versioned conditional writes, stale-while-revalidate to keep waiters off the critical path.
Guarantee that still holdsWith versioned fills, the cache never moves backward in record version; duplicates only cost load.
Trade-offA long lease protects the database but leaves waiters stuck longer when an owner genuinely dies (F05).
Rebuildable or business errorRebuildable.
F07, F08, F21, F22, F23

Freshness, Invalidation and Authorization Failures

These are the incidents where the cache is healthy, fast, and wrong. Nothing alerts on a hit that returns the previous price.

Invalidation means removing or replacing a cached entry because its source changed. Meridian publishes a post-commit event from a transactional outbox into an invalidation stream, and a subscriber with authority deletes or refreshes the key. The event does not edit caches itself. For C2 (the product-card price) this is paired with a TTL backstop of 300 seconds ± 20%, so the worst case when an event is lost is about 360 seconds, and checkout always revalidates against the authoritative service.

F07 — A delayed fill overwrites a fresh invalidation

Reader A misses on key 4821 at t = 0 and reads the database: $99, version v5. Before A's cache write, the merchant commits $109, version v6 at t = 5 ms, and the outbox event reaches the subscriber, which deletes the cache key. There is nothing to delete, because A has not written yet. At t = 30 ms A finishes and SETs v5 into the cache with a fresh TTL. The invalidation arrived, was honoured, and was then undone by a slower request. The cache now holds v5 for up to 240–360 seconds while the database says v6.

Swipe to pan
The stale-fill raceInvalidate Ran, Stale Value Still WinsInterleaving of one slow reader and one writer on product:4821Reader pod AProduct DBWriter pathCache1 GET key → miss2 SELECT 48213 row $99 · v55 UPDATE $109 · v6, commit6 DEL key (nothing there)8 SET $99 · v5, TTL 300 s4 reader pausesGC / slow network7 resumes withits old v5 rowcache: $99 · v5DB says $109 · v6Result: stale for up to the TTLNo event arrives later, so nothing repairs the entry before expiry(≤ ~360 s with ±20% jitter). Each step looked correct on its own.Fixes: versioned fill, short TTL, or deleting again after a delay.
The exact interleaving: read v5, commit v6, delete, then a late fill of v5.

Deleting twice or after a delay narrows the window but does not close it. The reliable fix is a versioned conditional fill: the value carries recordVersion from the authority, and a fill succeeds only if the cached version is lower than the incoming one. There is a trap: if the invalidation simply deletes the key, the key is absent and the late v5 fill is accepted anyway. So the invalidation must leave a tombstone, a small marker that records v6 and lives for a short time (longer than the slowest plausible fill), and the conditional fill compares against it. In Redis the compare-and-set is an atomic script on the owning node. Memcached has no compare-by-version command, so the version is embedded in the value and a gets/cas loop does the comparison (cas succeeds only if the item is unchanged since it was read), with the same tombstone idea. Versions must come from the authority (a database row version or commit sequence); timestamps from application servers can disagree. Alternatively accept the bounded staleness explicitly and rely on the TTL, which is a legitimate choice for non-critical content but must be a choice.

FacetF07 at a glance
TriggerA cache-aside fill is delayed past a concurrent write plus invalidation.
Timelinet = 0 read v5; t = 5 ms commit v6, invalidate (no-op); t = 30 ms fill v5; until TTL expiry readers get v5.
Visible symptomShoppers see $99 while the database holds $109; no errors anywhere; hit rate normal.
Root causeFill and invalidate are not ordered; the fill carries a value older than the delete that preceded it.
DetectionSampled shadow reads comparing cache record versions with the database, version-lag metric, rare customer reports.
ContainmentDelete the key again; shorten TTL for the affected class; if widespread, bump a namespace epoch.
RecoveryTTL expiry or a re-invalidation repairs the entry.
PreventionVersion-aware conditional fills with a versioned tombstone left by the invalidation, versions from the authority, invalidate-after-commit through the outbox, checkout revalidation.
Guarantee that still holdsCheckout price and availability come from the authority; staleness is bounded by ≤ ~360 s.
Trade-offConditional writes add a script or CAS round trip, require every writer to carry a version, and tombstones cost a little memory until they expire.
Rebuildable or business errorRebuildable; a business error only if checkout trusts the cached price.

F08 — Invalidation event lost, replayed or reordered

The invalidation stream is at-least-once at best, and nothing in it is exactly-once. A lost event means the key keeps its old value until TTL. A replayed event for v5 arriving after the cache already refilled v6 deletes a fresh entry, which costs one extra miss and nothing else. The dangerous case is reordering when the subscriber updates in place instead of deleting: an update for v5 arriving after v6 overwrites the newer value.

The design consequences are cheap but must be chosen: prefer delete-on-invalidate, ideally leaving a short-lived versioned tombstone (idempotent and safe to replay), keep a per-key version so any in-place write is conditional, track consumer lag, and keep the TTL backstop for the lost-event case. A periodic reconciler that samples keys and compares versions turns 'hopefully no events were lost' into a measured rate.

FacetF08 at a glance
TriggerThe invalidation consumer crashes before acknowledging, a broker redelivers, or two partitions interleave a key's updates.
Timelinet = 0 v6 commits and an event is emitted; the event is lost, duplicated or delayed; stale or redundant behavior follows.
Visible symptomLost: stale until TTL. Duplicate: an extra miss. Reordered in-place update: older version after newer.
Root causeNo ordering or delivery guarantee strong enough to treat the event as the only freshness mechanism.
DetectionConsumer lag, dead-letter counts, version-lag sampling, spikes in redundant invalidations.
ContainmentReplay the stream from the last good offset; delete the affected keys; reduce TTL for C2 temporarily.
RecoveryReplay plus TTL expiry restores consistency.
PreventionIdempotent deletes, versions on writes, outbox for reliable emission, TTL backstop, reconciliation job. See outbox pattern.
Guarantee that still holdsStaleness is bounded by the TTL backstop; replays never corrupt data when writes are versioned.
Trade-offDelete-only invalidation costs a miss per change; update-in-place saves it but needs the version discipline.
Rebuildable or business errorRebuildable.

F21 — A stale price after a database update; checkout must revalidate

At 11:00 Meridian drops 4821 from $99 to $109 for a supplier cost change. The invalidation is lost to a consumer restart. Product cards keep showing $99 until the TTL expires, between 240 and 360 seconds later. A shopper adds the item to the cart at $99. At checkout the authoritative service computes $109 and the shopper sees the difference before paying. That step is the real protection: the card price is a display, and the order total is computed by the authority.

The cost is trust, not correctness. A shopper who sees a price change at checkout may leave, and some jurisdictions have rules about advertised prices; Meridian's legal and product teams, not the cache design, decide the acceptable window. For items where even a few minutes is too long (flash prices, regulated pricing), shorten the TTL, push updates through a refresh rather than a delete, or exclude price from the card cache altogether and pay the latency.

FacetF21 at a glance
TriggerA price change commits at v6 while the cached v5 card is not invalidated.
Timelinet = 0 commit $109; t = seconds normal propagation, or ≤ ~360 s with the event lost; checkout recomputes at any time.
Visible symptomCard says $99, cart or checkout says $109; support contacts rise.
Root causeCached display value outlived the authority's value.
DetectionPrice-mismatch rate between card and checkout, version-lag sampling, event-consumer lag.
ContainmentDelete the key manually or by epoch; shorten the C2 TTL during the incident.
RecoveryTTL expiry or successful invalidation; no data repair needed.
PreventionEvent invalidation plus TTL 300 s ± 20%, checkout revalidation, 'price confirmed at checkout' messaging for sensitive categories.
Guarantee that still holdsNo order is charged from a cached price; the authority sets the total.
Trade-offA shorter TTL raises database load; a longer one raises mismatch exposure.
Rebuildable or business errorRebuildable. It would be a business error only if checkout used the cached price.

F22 — A revoked permission is served from cache

A support agent's access to Cedar's order data is revoked at 15:00. The authorization service's answer 'allowed' was cached for 300 seconds with the rest of the session context. For five minutes the agent can still read orders. This is why authorization decisions belong to class C4: they are never served from a TTL-only cache. Staleness here is not a freshness inconvenience, it is a security failure.

Two safe patterns exist. Re-check the authority on each sensitive action (the cache then only saves work on non-sensitive reads), or cache decisions against a revocation epoch: each cached grant records the epoch it was issued under, the caller reads the current epoch from a small, highly available authoritative source, and a mismatch forces a re-check. If the authority is unreachable, the system must fail safe for sensitive actions: deny or require re-authentication, never 'serve the stale permission'. Stale-while-revalidate does not apply here.

FacetF22 at a glance
TriggerA permission is revoked while a cached 'allowed' decision is live.
Timelinet = 0 revoke; t < 300 s cached allow still honoured; t = TTL expiry denial.
Visible symptomAccess that the policy says is gone still works; audit logs show actions after revocation.
Root causeA TTL used as if it were revocation.
DetectionAudit reconciliation (actions after revoke time), revocation-lag metric, authorization decision source (cache vs authority).
ContainmentInvalidate the principal's decisions by epoch bump, force re-authentication, restrict sensitive actions.
RecoveryReview actions in the gap, notify as policy requires.
PreventionNo TTL-only authorization cache, revocation epochs, fail-safe on authority outage, short caches only for low-risk reads.
Guarantee that still holdsWith an epoch check, a revoked grant is rejected as soon as the epoch propagates.
Trade-offChecking the authority on sensitive paths costs latency and makes them depend on authority availability.
Rebuildable or business errorSecurity incident, not something to rebuild: the authority is the only safe source.

F23 — Regional lag and residency restrictions

EU and US each run their own cache tier in front of one product database. A price change commits in the EU. The EU invalidation lands in seconds; the US invalidation crosses a link that is lagging at about 40 seconds (hypothetical), then the US consumer retries a failed delivery. For that window a US shopper sees v5. If the cross-region link is down, the TTL backstop (≤ ~360 s) still bounds it per region, which is why every region keeps its own TTL instead of depending on the event.

Swipe to pan
Multi-region cache: a second region is stale until the invalidation arrivesRegional Caches Converge After the Invalidation, Not Instantlyprice $99 (v5) → $109 (v6) committed in the EU primary databaseDB + outboxInvalidation streamEU cacheUS cacheUS shoppercommit v6, eventdelete keyEU freshGET$99 (v5) — staledelete keycross-region delayUS freshGET → miss → refill v6The staleness budget, stated plainly• Normal case: seconds between the commit and the US delete.• Event lost or delayed for good: the TTL backstop (300 s ± 20%, so 240–360 s) caps it at about 360 s.• Checkout revalidates price and stock against the authoritative service, so a stale card never charges the stale price.• Regions share no cache state; each runs its own cache, so the invalidation stream must reach every region.
EU commits v6; the US region serves v5 until the invalidation or the TTL catches up.

A second failure mode is residency. If US pods fill their cache from an EU-only dataset (merchant tax data, say) because a read path was written for one region, restricted content is now stored where it is not permitted. Region must be part of the key and the fill rule, not a deployment detail. 'Instantly consistent across regions' is not a property the design offers, and a design that claims it needs a very different cost and latency contract.

FacetF23 at a glance
TriggerCross-region invalidation is delayed or dropped; or a fill path ignores residency.
Timelinet = 0 EU commit; t ≈ seconds EU fresh; t ≈ 40 s US still stale (hypothetical lag); TTL bounds the worst case.
Visible symptomDifferent prices in EU and US for a short time; or restricted data found in the wrong region's cache.
Root causeAsynchronous cross-region propagation and region-agnostic fill logic.
DetectionPer-region invalidation lag, cross-region version comparison, residency audit of key namespaces.
ContainmentPurge the affected namespace in the wrong region; replay invalidations; shorten TTL for volatile classes.
RecoveryReplay and TTL expiry; for residency, purge and report.
PreventionRegion in the key schema, per-region TTL backstop, explicit residency policy for cacheable classes.
Guarantee that still holdsEach region is bounded by its own TTL; checkout revalidates against the authority.
Trade-offPer-region caches duplicate memory and fills; a single global cache would make every read cross an ocean.
Rebuildable or business errorRebuildable; a residency breach is a compliance incident.
F12, F13, F14, F26

Memory, Eviction and Payload Failures

A cache has a fixed amount of memory and an unbounded appetite for data. Eviction decides what to forget, and each policy has an access pattern it handles badly.

Eviction removes entries when memory is full. LRU (least recently used) removes what has gone longest without access; LFU (least frequently used) removes what is accessed least often. Redis implements LRU and LFU as approximations (LRU by sampling keys; LFU with a probabilistic counter that decays), so the behavior below is a tendency, not an exact algorithm. Remember the memory worksheet: 1,536 bytes of payload per object becomes about 1,640 bytes with key and metadata, and 15.27 GiB across the cluster once a 1.25 allocator factor is applied (all overheads assumed). The sample cluster is 4 × 8 GiB = 32 GiB nominal.

F12 — LRU thrash when the working set outgrows memory

A sale multiplies the number of active variants per product, and the active working set grows to 2.5× the planned 15.27 GiB, about 38.2 GiB, against 32 GiB of nominal memory. Under LRU, entries are evicted a moment before they are requested again, and hit rate decays from 90% toward 70% or lower. At 70%, eligible misses are 14,400 per second, database reads 26,400, which is 110% of capacity; mean latency moves from 11.2 ms to about 27.6 ms (0.7 × 3 + 0.3 × 85) and p99 stays near the miss latency.

The cache did not break; the model did. The capacity plan assumed an 8,000,000-object working set. Practical responses are to add shards (a resharding exercise in itself), shrink payloads through compression or smaller views, shorten TTL on cold content, and be selective about what is cached at all: a key that is read once costs memory without saving a read. Hit rate, eviction rate and working-set growth should be watched together.

FacetF12 at a glance
TriggerThe working set grows to roughly 2.5× the planned size during a promotion.
Timelinet = 0 traffic mix changes; t ≈ minutes evictions rise and hit rate slides; t ≈ 10–30 min database nears 100% utilization.
Visible symptomGradual hit-rate decline, rising eviction rate, higher mean and p99 latency, rising database load.
Root causeCapacity planned for a smaller working set; no alert on working-set growth.
DetectionEvictions per second, eviction-age of evicted keys (short means thrash), hit rate by class, memory per shard.
ContainmentCache less (skip one-touch classes), cut TTLs for cold content, shed fallback beyond the cap.
RecoveryAdd capacity or reduce item size; allow warm-up.
PreventionCapacity worksheet with headroom, alerts on eviction rate, per-class caching rules, load tests with a realistic working set.
Guarantee that still holdsReads remain correct; cost moves to the database.
Trade-offLarger memory costs money; selective caching costs hit rate on the excluded classes.
Rebuildable or business errorRebuildable.

F13 — Never-expire data starves the evictable space

A batch job writes enrichment data with no TTL, about 3.6 GiB per shard on its first run (derived: it takes the 8 − 4.39 GiB the hottest shard had left), and each later run adds more under new keys. With an allkeys-* policy this is survivable, because every key, including the job's, is a candidate for eviction. With a volatile-* policy it is not: those policies consider only keys that have an expiry. Every normal fill is then paid for by evicting the cache's own entries, so the cache shrinks to whatever the job leaves, hit rate sinks, and once the non-expiring data alone reaches the memory limit there is nothing evictable, the policy behaves like noeviction, and writes fail with an out-of-memory error. The cluster looks full of data nobody remembers writing.

Two defences are cheap. Make a TTL mandatory in the cache client (reject or default a missing TTL), and monitor the share of keys without an expiry. In memcached an exptime of 0 means never expires, but the protocol notes such an item may still be removed to make room, so the exposure differs by engine and version; check the documentation for the one you run.

FacetF13 at a glance
TriggerData without an expiry is written under a volatile-only eviction policy.
Timelinet = 0 job starts; t ≈ hour shard memory approaches the limit; t ≈ later fills begin to error.
Visible symptomWrite errors on fill, memory at the limit, hit rate flat or falling.
Root causeEvictable key population shrank to nothing; policy and TTL practice disagree.
DetectionShare of keys with no TTL, memory per shard, write error rate, eviction rate near zero while memory is full.
ContainmentDelete or expire the offending keyset; switch policy if appropriate and safe.
RecoveryRemove the job's keys and let normal fills resume.
PreventionMandatory TTL, key-prefix budgets, review of batch writers, alerts on 'no expire' counts.
Guarantee that still holdsNo incorrect data is served; the cache simply stops accepting new entries.
Trade-offA mandatory TTL occasionally forces a re-fetch of truly static data.
Rebuildable or business errorRebuildable.

F14 — Oversized values and compressed payload bombs

A new feature caches the full product page, images inlined, at about 1 MiB per entry, instead of the 1.5 KiB card. Meridian's policy rejects values over 1 MiB, so values of exactly that size slip through. At 8,640 requests per second for the hot key, one shard must send 8,640 MiB per second, about 8.4 GiB per second, over a hypothetical 10 Gbit/s link of about 1.16 GiB per second: 7.2 times the capacity of the NIC. A single hot key is enough to saturate a node's network before CPU or memory notice. Large values also block other commands on a single-threaded engine while they are serialized, and they inflate garbage collection and memory in the pods that decode them.

The second hazard is compressed content: a small stored value that expands to something enormous when the client decompresses it. The wrapper Meridian stores around every cached payload (its envelope: schema version, record version, expiry, compression flag, bytes) includes a compression field and the envelope should also record the uncompressed length so clients can refuse to inflate beyond a bound. Protect at the boundary: cap sizes on write, cap decompressed sizes on read, and never put the large body in the shared cache when a reference or a CDN can carry it.

FacetF14 at a glance
TriggerA release caches ~1 MiB values, or a malicious/buggy writer stores a high-ratio compressed value.
Timelinet = 0 deploy; t ≈ minutes shard network and pod memory climb; t ≈ later GC pauses and timeouts.
Visible symptomShard network saturation, high p99 for unrelated keys, pod memory spikes and GC pauses.
Root causeNo effective size guard, and no bound on decompressed size.
DetectionValue-size histogram, bytes in/out per shard, per-pod heap and GC, decode time.
ContainmentRoll back the feature, delete the oversized keys, lower the write limit.
RecoveryRestore normal sizes and let memory settle.
PreventionEnforced item cap (1 MiB here, check your engine's own limit), recorded uncompressed size, size alerts, big payloads in object storage or a CDN.
Guarantee that still holdsCorrectness is unchanged; capacity and tail latency suffer.
Trade-offCompression saves memory and bandwidth but costs CPU and creates the bomb risk.
Rebuildable or business errorRebuildable.

F26 — Eviction quality collapses under a looping access pattern

A nightly job walks the product catalog in id order through the cache-aside path, and a downstream consumer does the same every fifteen minutes. A loop over slightly more items than fit is the classic worst case for strict LRU: each item is evicted just before the loop comes back to it, so the hit rate approaches zero for that traffic while the loop also pushes out the hot set that real shoppers need.

Different policies fail on different patterns. LFU keeps frequently used keys through a one-time scan but is slow to forget keys that were popular earlier, which is why it has a decay setting. Random eviction ignores access order and does tolerably on near-uniform access. The most effective single measure is to keep scans out of the cache: batch consumers read the database or a replica directly, or mark their reads as non-cacheable. Choose a policy against a real trace of your access pattern, not against folklore.

FacetF26 at a glance
TriggerA cyclic scan over more items than fit in memory runs through the cache.
Timelinet = 0 scan begins; t ≈ minutes the hot set is evicted; t ≈ later hit rate on scan traffic ≈ 0 and shoppers' hit rate dips.
Visible symptomHit-rate dip at fixed times; eviction rate spike aligned with a job.
Root causeA pattern the eviction policy cannot retain (LRU on loops) and a batch job sharing the interactive cache.
DetectionHit rate by caller or class, evictions by time, job schedule overlays.
ContainmentPause the job or route it to the database; temporarily raise sampling or change policy.
RecoveryHot set refills by demand.
PreventionSeparate batch and interactive paths, admission control for one-touch keys, evaluating policy on traces.
Guarantee that still holdsReads stay correct; only the hit rate drops.
Trade-offFrequency-aware policies add bookkeeping and tuning; separating batch traffic adds a second read path to maintain.
Rebuildable or business errorRebuildable.
F10, F11, F25

Hot-Key and Skew Failures

Sharding spreads keys, not requests. One very popular key lives on exactly one shard, however many shards you add.

A hot key is a key whose request rate is far above its peers. The planning budget is 25,000 operations per second per shard (hypothetical); an even spread of the 48,000 peak GETs puts 12,000 on each of four shards, 48% utilised. The incidents below break that comfortable arithmetic.

F10 — A hot key concentrates request CPU on one shard

The top product takes 18% of eligible peak traffic, 8,640 requests per second, all addressed to one key and therefore one shard. The other 39,360 eligible GETs spread over four shards at 9,840 each, so the hot shard carries 8,640 + 9,840 = 18,480 per second (73.9% of budget) while the others sit at 9,840, a 1.88× skew. It still fits, but a trigger that was acceptable at 73.9% has no room for a failover, a snapshot or a deploy on that node. (The 8,640 here is the hot-key rate, unrelated to the 8,640 hits/s at average load; the numbers coincide.)

Swipe to pan
Hot key: one key skews a whole shardA Hot Key Overloads One Shard, Not the ClusterPeak 48,000 eligible GETs/s over 4 shards · hypothetical budget 25,000 ops/s per shardTop product = 18% of eligible reads (8,640/s on one key)Shard 1 (owns key)18,480/sShard 29,840/sShard 39,840/sShard 49,840/sTrending SKU = 45% of eligible reads (21,600/s on one key)Shard 1 (owns key)28,200/sShards 2–4 (each)6,600/sbudget 25,000/sthe hot keyeverything else on that shard (spread evenly)Relief: per-pod local cache (L1) for this key, TTL 2 s, 20 pods → at most 20 / 2 = 10 shard GETs/sCost: this key can be up to 2 s stale, and invalidation must also reach L1. Adding shards does not help: one key still lives on one shard.
Shard load with one key at 8,640 req/s: sharding evens the rest, but not the hot key.
Swipe to pan
Hot key: a 2-second local copy cuts shard reads from 8,640 to at most 10 per secondHot Key: Serve It From the Pod for 2 Secondsone product key · 8,640 req/s · 20 pods · local TTL 2 s is a hypothetical valueClient requestsPod local copyOwning shardDatabaset=0.0 s GET (local miss)fetch from shardvalue, v6t=0.5 s GET: local hitt=1.5 s GET: local hitt=2.0 s local TTL expirest=2.1 s GET: local missone refetchShard load for this key: 20 pods ÷ 2 s = at most 10 reads/s, down from 8,640 reads/s.Cost: a pod may serve the previous value for up to 2 s after the shard copy changes.Use it for display data such as a price shown on a listing; revalidate at checkout.
Timeline for one hot key with a 2-second local copy: one refetch per pod every two seconds instead of every request.

Adding shards changes nothing for the hot key. What helps is serving it from somewhere other than its owning shard: a tiny per-pod local cache (L1, a process-local cache in front of the shared one) with a 2-second TTL means 20 pods each fetch the key at most every two seconds, 20 ÷ 2 = at most 10 shard GETs per second for that key instead of 8,640. The price is up to two seconds of staleness on that key and an invalidation path that must reach every pod's L1. Acceptable for a display price with checkout revalidation; not acceptable for anything in C4.

FacetF10 at a glance
TriggerOne product receives 18% of eligible peak traffic.
Timelinet = 0 promotion begins; t ≈ minutes request share on one key rises; the owning shard's ops/s stays above its neighbors.
Visible symptomOne shard with 1.88× the ops/s and CPU of the others; its p99 rises first.
Root causeKey placement is by key, but demand is concentrated on a single key.
DetectionPer-shard ops/s and CPU, top-keys sampling, p99 by shard.
ContainmentEnable a local L1 for the hot key (2 s TTL), raise the shard's headroom, spread other load away if possible.
RecoveryRemove the override once the promotion ends.
PreventionHot-key detection with automatic L1 or replicated copies, headroom for the hottest shard, capacity based on peak per-key rate, not shard average.
Guarantee that still holdsStaleness on the key is ≤ 2 s on top of the usual invalidation path.
Trade-offL1 adds staleness and a second invalidation target for a throughput gain.
Rebuildable or business errorRebuildable.

A viral video sends 45% of eligible peak traffic to one product: 21,600 requests per second. Its shard now serves 21,600 + (48,000 − 21,600) ÷ 4 = 28,200 operations per second, above the 25,000 budget, while the other three shards idle at 6,600. Latency on the hot shard rises, other keys on that shard suffer alongside the trending one, and clients that time out retry, which adds load.

The overload alone is bad. Add expiry and it gets worse: at 21,600 requests per second and an 85 ms refill, about 1,836 requests arrive before the first refill finishes, and if the owner of the refill is itself slow they pile up. Layered defense works best here: the L1 from F10, single-flight per pod (at most 20 fetches fleet-wide), a distributed lease if the database cost of 20 is still too much, and stale-while-revalidate for descriptive content so the value never looks absent. Price on the card can use the L1 as well, with checkout still revalidating.

FacetF11 at a glance
TriggerOne product reaches 45% of eligible peak traffic.
Timelinet = 0 viral link; t ≈ seconds hot shard exceeds 25,000 ops/s; t ≈ next TTL expiry a refill burst of about 1,836.
Visible symptomHot shard saturated while others idle; timeouts and retries on keys co-located with the hot one; miss burst at expiry.
Root causeSkewed demand beyond one shard's budget, with synchronized refills on the same key.
DetectionTop-key rate, shard ops/s versus budget, concurrent fetches per key.
ContainmentTurn on L1 and single-flight for the key, serve stale for C1, shed non-essential reads on the hot shard.
RecoveryTraffic subsides; remove overrides.
PreventionAutomated hot-key detection, replicated read copies (all copies must be invalidated), SWR for allowed classes, load tests at the trending-SKU rate.
Guarantee that still holdsResponses are the authority's value or a bounded-stale copy of an allowed class.
Trade-offReplicated hot-key copies multiply invalidation work and memory; every extra copy is another place to be stale.
Rebuildable or business errorRebuildable.

F25 — Skewed distribution from a poor shard hash

An engineer 'optimizes' shard selection to keep a tenant's keys together by hashing only the tenant prefix. Cedar, 70% of traffic in this hypothetical, now maps entirely to one shard: 0.7 × 48,000 = 33,600 GETs per second against the 25,000 budget. This is the same hazard as a Redis hash tag such as {tenant:cedar}, which forces every key with that tag into one slot (860 in the sample), and it is the right trade when you need multi-key operations on a tenant and the wrong one when the tenant is hot.

Distribution problems also come from weak hash functions (hashing only the last digits of numeric ids, or using key length) and from too few virtual nodes on a ring, where one node can own noticeably more than its share (a toy ring of 100 positions with one token per shard can end uneven: ownership of 35/15/30/20 across A, B, C and D after adding D). The test is mechanical: hash a sample of real keys offline and chart per-shard counts and per-shard request rates before deploying a new scheme.

FacetF25 at a glance
TriggerA hashing or tagging change concentrates keys, or a ring has too few virtual nodes.
Timelinet = 0 deploy of the new key function; t ≈ minutes one shard's load rises while others fall.
Visible symptomOne shard hot, high eviction and latency; others idle; keys per shard visibly uneven.
Root causeThe distribution function does not spread the actual keys.
DetectionPer-shard key count and ops/s, hash-uniformity test on production key samples.
ContainmentRoll back the key function; if impossible, move the heavy range to a dedicated shard.
RecoveryRebalance by slot migration or ring change (a planned operation, see F18).
PreventionHash the full key, test uniformity on real keys, many virtual nodes, review any hash-tag rule for hot tenants.
Guarantee that still holdsPlacement is deterministic, so correctness is unaffected; capacity is.
Trade-offSpreading a tenant across shards gives up cheap multi-key operations on that tenant.
Rebuildable or business errorRebuildable.
F15, F19, F20

Tenancy, Isolation and Abuse Failures

A shared cache is shared memory, shared CPU and a shared namespace. Each of those can be consumed or confused by a tenant who did not mean to.

Isolation in a cache is made from three separate things: a key namespace that cannot collide, resource limits that cannot be exhausted by one tenant, and access control tied to verified identity. Naming conventions alone provide none of the three.

F15 — Negative-cache poisoning and random-key misses

A negative cache stores 'not found' for 30 seconds so repeated lookups for a missing product do not reach the database. Two attacks work against it. In the first, a scraper (a cache penetration attack) requests random product ids at 5,000 per second (hypothetical). Every id is new, so every request misses, the negative cache never gets a repeat, and 5,000 reads per second pass straight to the database. The 5,000 × 30 = 150,000 negative entries that accumulate are a smaller problem for memory than the reads are for the database (even at the full 1,640-byte footprint they are about 0.23 GiB, 1.5% of the 15.27 GiB plan, and a real 'not found' marker is smaller), but at a higher rate or a longer negative TTL they begin to displace useful entries.

In the second, poisoning, a product is created at 10:00:05 but a negative entry written at 10:00:02 says it does not exist, so shoppers see 'not found' until the 30-second TTL ends. And if a 403 or 401 is cached as 'not found' across identities, one user's lack of permission becomes everyone's missing product. Defences: validate id shape and existence cheaply (for example against a bloom filter or id range) before the database, rate-limit by caller, cap negative entries per tenant, delete a negative entry when the product is created, and key negative entries by tenant and, for authorization failures, by principal.

FacetF15 at a glance
TriggerA scraper requests random ids; or a create races with a cached 'not found'; or a 403 is stored as a miss.
Timelinet = 0 scan begins; t ≈ seconds database reads rise by the attack rate; t ≈ 30 s negative entries begin to expire.
Visible symptomElevated misses with low hit rate on one tenant, database load from nonexistent keys; or users seeing 'not found' for new items.
Root causeThe negative cache protects against repeats, not against uniqueness, and was keyed without identity.
DetectionNegative-entry count per tenant, miss rate by key-existence, requests per client, 404 ratio.
ContainmentRate limit the source (rate limiting), enforce the per-tenant cap, purge poisoned entries.
RecoveryEntries expire in 30 s; purge on create for the affected ids.
PreventionId validation, per-tenant negative caps, invalidation on create, no cross-identity caching of 401/403.
Guarantee that still holdsA cached 'not found' lasts at most 30 s and never stands in for an authorization answer.
Trade-offExistence filters need maintenance and can have false positives that send a harmless request to the database.
Rebuildable or business errorRebuildable.

F19 — One tenant dominates shared memory and operations

Cedar imports its full catalog and a scraper touches four million objects once each. At 1,640 bytes and a 1.25 allocator factor each, that is about 7.6 GiB, half of the 15.27 GiB planned for the whole cluster (derived). Under LRU, Birch's moderately warm entries are older than Cedar's fresh inserts and are evicted first. Birch's hit rate falls, its latency rises, and Birch did nothing.

A single engine instance has one memory pool, so per-tenant limits must be built from other pieces: accounting by key prefix in the client with a write quota, rate limits per tenant at the API, a cap on one-touch inserts, and, for tenants who justify it, a dedicated shard tier so their entries never share eviction with a neighbor. A shared pool is cheaper and more flexible, and its price is exactly this class of interference.

FacetF19 at a glance
TriggerCedar's import or scraper inserts millions of one-touch entries.
Timelinet = 0 import starts; t ≈ minutes Birch's entries are evicted; t ≈ later Birch's hit rate and latency degrade.
Visible symptomNeighbor tenants suffer higher miss rate and latency; Cedar's keys dominate memory.
Root causeA shared eviction pool without per-tenant quotas or admission control.
DetectionMemory and ops per tenant prefix, hit rate by tenant, eviction by tenant.
ContainmentThrottle or pause Cedar's import, apply an insert quota, move Cedar to a dedicated shard.
RecoveryBirch's entries refill by demand.
PreventionPer-tenant quotas, admission for one-touch keys, dedicated tiers for large tenants, per-tenant dashboards.
Guarantee that still holdsBirch's reads stay correct; only their cache hit rate pays.
Trade-offDedicated tiers improve isolation and waste capacity when idle.
Rebuildable or business errorRebuildable.

F20 — A cross-tenant key collision serves the wrong tenant's content

A new endpoint builds its cache key as product:4821 instead of the schema's v2:<tenant>:product:4821. Cedar and Birch both have a product 4821, and they are different products. Cedar's shopper loads the page first and fills the key. Birch's shopper hits the same key and is shown Cedar's product, price, and perhaps private supplier notes. The cache did exactly what it was told; the name had no tenant in it. The unscoped key also hashes to a different Redis Cluster slot (13,107) than the simplified tenant-scoped form tenant:cedar:product:4821 (8,298; the full v2:-prefixed key would land on a third slot), so the leaked entry lives apart from the correct one.

Swipe to pan
Tenant isolation: key schema, quotas and authorizationTenant in the Key, Quota per Tenant, Authorization Before the CacheCedar = large merchant that can get noisy · Birch = well-behavedUnsafe: key has no tenantCedarBirchshared cacheproduct:4821 = Cedar1 SET2 GET (hit)Birch is servedCedar's datacross-tenant leakSafe: key v2:<tenant>:<type>:<id>CedarBirchshared cachev2:cedar:product:4821v2:birch:product:4821separate keys, separate missesControls that make the key schema holdTenant from identityDerive the tenant from the authenticated caller,never from a request field the caller controls.Per-tenant quotasCap memory share and ops/s per tenant so a Cedarflood cannot evict or starve Birch.Capped negative cacheNot-found entries live 30 s with a per-tenant cap,limiting enumeration and poisoning.Authorize firstNever cache 401/403 as "not found" across identities;permission and payment data skip TTL-only caches.
A missing tenant in the key makes two tenants share one entry.

This is a security incident, not a performance bug. The content is no longer private to its owner and the exposure window is the TTL of every colliding entry. Prevention is structural: one key-builder function is the only way to produce keys, it takes the tenant from the authenticated identity rather than from a request header, a CI check rejects direct cache calls with literal keys, and tests run two tenants with the same ids. Where responses depend on the caller (permissions, personalised content), the key must include whatever the response varies by. Access control lists on the cache itself restrict which service can touch which prefix; naming conventions do not.

FacetF20 at a glance
TriggerA code path builds keys without the tenant component.
Timelinet = 0 deploy; t ≈ first cross-tenant hit; exposure continues until TTL, purge or rollback.
Visible symptomA tenant sees another tenant's data; usually reported by a customer, not an alert.
Root causeNamespace not enforced at the only place that builds keys.
DetectionCanary requests alternating two test tenants with identical ids, content-tenant assertions, customer reports, key-pattern audits.
ContainmentRoll back; purge the unscoped keys; if unsure, bump the namespace epoch for the whole schema.
RecoveryIncident response: scope the exposure, notify as required by policy, add tests.
PreventionSingle key builder, tenant from verified identity, per-prefix ACLs, schema version in the key, cross-tenant tests in CI.
Guarantee that still holdsAfter purge, the authority is unaffected and every entry is regenerated under the right key.
Trade-offA global epoch bump or purge is a deliberate cold start (see F03) to remove the leak.
Rebuildable or business errorData exposure: rebuildable as cache entries, not reversible as a disclosure.
F16, F27

Replication and Failover Failures

A replica gives you a warm standby and read capacity. It does not give you a promise that the standby has everything the primary acknowledged.

Redis replication is asynchronous by default: the primary answers the client without waiting for replicas, and the replicas acknowledge the offset they have processed in the background. The Redis Cluster documentation says plainly that acknowledged writes can occasionally be lost, and that the windows are larger for clients in a minority partition. WAIT can block for replica acknowledgements, but it lowers the probability of loss; it does not make the system strongly consistent.

F16 — Failover promotes a lagging replica

The primary of shard C acknowledges SET product:4821 $109 v6 and fails before the write reaches the replica. The replica is promoted, holds v5 ($99), and the acknowledged write is gone. In a cache this is usually ordinary: with replica lag of 120 ms (hypothetical) and about 1,200 fills or deletes per shard per second at peak (4,800 eligible misses over 4 shards), roughly 144 operations sit inside the window at most: the replica keeps whatever had already reached it, so the real loss is whatever was still unsent when the primary died. The subtle ones are the deletes. A lost invalidation DEL means the stale value that the primary removed is back on the promoted replica, and no further event will remove it.

Swipe to pan
Async replication: an acknowledged write can be lost in failoverFailover Can Lose an Acknowledged Cache WriteRedis replication is asynchronous by default · lag of 120 ms is a hypothetical valueApp clientPrimary CReplica C'Database (truth)SET 4821 = $109 v6OK (ack at t)replicate (in flight)✕not applied yetcrashpromoted to primarystill holds v5 / $99GET product:4821$99 (v5) — v6 ack is goneholds v6 / $109invalidate → refetch v6Rebuildable cache: the stale price lasts seconds with an event, at most ~360 s via TTL; checkout revalidates.If the cache were the only copy of this write, the loss would be a business error, not a cache miss.
An acknowledged write that never reached the replica before promotion.

For a cache that is acceptable only because the authority can repair it. After a failover, replay the invalidation stream from before the failure time minus the lag plus a safety margin; the TTL backstop covers anything missed. Versions in the envelope let replays be idempotent. The picture changes completely if the cache held the only copy of something, such as a cart or a session. Then those 144 operations are lost business data, which is why such state belongs in a durable store, and why write-behind caches need explicit durability design. Do not describe the design as 'no acknowledged-write loss'.

FacetF16 at a glance
TriggerA primary fails with acknowledged writes not yet on its replica.
Timelinet = 0 write acknowledged; replica applies about 120 ms behind the primary (hypothetical); t ≈ 50 ms primary dies before the write has been sent; after failure detection the replica is promoted at v5.
Visible symptomOld values or resurrected entries after failover; no errors anywhere.
Root causeAsynchronous replication: acknowledgement does not imply replica receipt.
DetectionReplication offset gap at failover, failover events correlated with version-lag samples.
ContainmentReplay invalidations covering the lag window; delete affected keys.
RecoveryReplay plus TTL expiry; re-verify with the version sampler.
PreventionVersion envelope, replayable invalidation stream, never use the cache as a sole copy (the stream, not the cache, is the record of what changed).
Guarantee that still holdsThe authority holds all committed data; checkout revalidates.
Trade-offSynchronous waits reduce loss at the cost of write latency and still do not guarantee it.
Rebuildable or business errorRebuildable. If the cache was the only copy of a cart, session or write-behind buffer, it is a business error.

F27 — Replica catch-up pressure after failover or restore

After a failover, a fresh replica must attach to the new primary. If the replication backlog still covers the replica's position, a partial resync (using the replication ID and offset) is cheap. If not, a full resync is needed: the primary produces a snapshot, ships it, and then streams the changes made meanwhile. On a primary at 4.39 GiB of an 8 GiB budget (54.9%) that already serves peak traffic, snapshot creation, copy-on-write memory growth during heavy writes, and replication buffers all compete for the remaining headroom, and a primary that runs out of memory during resync becomes the next outage.

A restore after the F03 restart has the same shape on a larger scale. Rebuild replicas one at a time, off the shard's busiest hours when possible, leave memory reserved for buffers (the Redis eviction documentation recommends leaving room for replication and persistence), and accept running a shard without a replica for a short, visible period instead of risking the primary. Choosing between 'redundancy sooner' and 'primary safer' is an operator decision that a runbook should settle beforehand.

FacetF27 at a glance
TriggerA replica must full-resync from a loaded primary after a failover or restore.
Timelinet = 0 replica attaches; t ≈ minutes snapshot and transfer; memory and buffers grow; t ≈ later catches up.
Visible symptomPrimary memory spike, latency rise, possible eviction or out-of-memory during resync.
Root causeResync work competes with serving traffic inside a fixed memory budget.
DetectionReplication state, buffer sizes, primary memory and fork/snapshot activity.
ContainmentPause additional resyncs; serialize replica rebuilds; relieve primary load.
RecoveryReplica catches up; restore redundancy.
PreventionHeadroom reserved for buffers, staggered rebuilds, larger backlog where partial resync is likely, off-peak maintenance.
Guarantee that still holdsReads continue from the primary; data correctness is untouched.
Trade-offRunning briefly without a replica lowers availability for that shard in exchange for protecting the primary.
Rebuildable or business errorRebuildable.
F17, F18

Routing, Partition and Resharding Failures

Every request must find the shard that owns its key. When the clients' idea of ownership drifts from the cluster's, reads miss and, worse, invalidations go to the wrong place.

Modulo hashing, consistent hashing and Redis Cluster's 16,384 fixed hash slots are different mechanisms. Moving from N to N + 1 nodes with modulo changes the owner of N ÷ (N + 1) of keys (75% from 3 to 4, 80% from 4 to 5), while a balanced ring moves roughly 1 ÷ (N + 1). Redis Cluster uses a slot table with explicit migration, answering -MOVED (permanent: update your slot map) or -ASK (one-time during migration).

F17 — Partition and stale client routing

Pods are being deployed. Half run a client configured with a 4-node server list; half run a new config listing 5. Under modulo placement, 80% of keys have a different owner depending on which pod asks. A pod on the new list refills key 4821 on the fifth node; the invalidation consumer, still on the old list, deletes it from the fourth. The fifth node keeps v5 until its TTL, and nothing is wrong from any single pod's point of view.

A real network partition adds the Redis Cluster case: a primary stranded with a minority of the cluster keeps accepting writes from clients that can still reach it until it notices the partition (after the node timeout) and refuses them, while the majority side promotes a replica. Writes in that window are lost. With a cluster-aware client, MOVED responses repair the map and the client should refresh it; with static client-side hashing, nothing repairs anything. Treat topology as a versioned object: one source, a version in every client, and a skew metric across pods.

FacetF17 at a glance
TriggerClients hold different topology versions during a rollout, or a partition isolates a primary with clients.
Timelinet = 0 mixed config rollout; t ≈ seconds keys appear on two owners; invalidations reach only one; t = TTL stale copies expire.
Visible symptomUnexplained misses, stale entries that survive invalidation, MOVED storms, duplicate copies.
Root causeNo single authoritative, versioned view of topology; delivery of invalidations depends on it.
DetectionTopology-version skew across pods, MOVED/redirect rates, duplicate-key sampling across shards, minority-side write acceptance.
ContainmentRoll all pods to one config; refresh slot maps; stop a minority primary from taking writes where possible.
RecoveryPurge the duplicated keys or wait for the TTL; replay invalidations.
PreventionVersioned topology, cluster-aware clients, atomic config rollout, node-timeout and quorum settings reviewed, TTL backstop.
Guarantee that still holdsThe authority is unaffected; cached copies are bounded by TTL.
Trade-offCluster-aware clients are heavier; proxy-based routing hides topology but adds a hop and its own failure domain.
Rebuildable or business errorRebuildable.

F18 — Resharding concurrent with writes

Slots 12288–13653 are migrating from C to the new shard D; key 4821, in slot 13107, is inside the range. During migration the source answers for keys it still holds, and for keys already moved it replies -ASK, which the client follows once by sending ASKING followed by the command to the target. An invalidation consumer that treats any non-OK reply as a failure and gives up, or an old client that does not handle ASK at all, silently skips the DEL. The key then lands on D with v5 while the database holds v6.

Swipe to pan
Resharding one slot: a state machineResharding Is Slot-by-Slot, and Reads Keep Workingone slot (13107, holding product:4821) moving from C to the new shard D1 StableC owns slot13107read of the keyC serves2 MarkC: MIGRATINGD: IMPORTINGread of the keyC serves keys it has3 Move keyskey by keyC → Dread of the keymoved key → -ASK D4 Flip ownerslot assignedto Dread of the keyC replies -MOVED D5 StableD owns slot13107read of the keyD servesWhat can go wrong• Stages 2–3 interrupted: the slot is left half-moved. Finish or roll it back before starting another resize.• Clients with a stale map pay one extra hop (-MOVED / -ASK) until they refresh their slot map.• Moving data competes with live traffic for the nodes.Sizing the move (3 → 4 primaries)A gives 1,365 · B gives 1,365 · C gives 1,366= 4,096 slots = 25% of 16,384 → DFor a cache there is a second option: let D startcold and accept the misses. Then the database mustabsorb the refill, so migration is the gentler path.
A slot moving from C to D: the states in which an invalidation can be lost.

With a client-side ring and dual-write migration the problem is duplicates: both old and new owners hold a copy, and an invalidation that reaches only one leaves the other to be read after a rollback or a boundary shift. The rules are the same either way: invalidation consumers must follow redirects and treat an unconfirmed delete as a retry, a migration must be observable (slots moved, keys remaining, errors), and the TTL backstop plus versioned fills handle whatever slips through. A key never teleports: after a slot moves, exactly one node owns it.

FacetF18 at a glance
TriggerInvalidation or fill traffic crosses a slot that is mid-migration.
Timelinet = 0 migration of slots starts; t ≈ minutes some keys moved, some not; an unredirected DEL is lost; t = TTL stale copy expires.
Visible symptomStale entries after migration, ASK/MOVED errors in client logs, a few mismatches in version sampling.
Root causeClients or consumers that do not follow redirects, or a design with two live copies.
DetectionRedirect counts, error rates by command during migration, version sampling on migrating slots.
ContainmentPause the migration; run invalidation through a redirect-aware client; replay invalidations for moved slots.
RecoveryFinish migration, replay, verify with sampling.
PreventionRedirect-aware clients for all writers including consumers, migration runbook with checks, migrate off-peak in small slot batches, versioned fills.
Guarantee that still holdsAfter migration each key has one owner; versions stop old data from replacing new.
Trade-offSlow, batched migrations take longer and keep the cluster in a transitional state for hours.
Rebuildable or business errorRebuildable.
F29, F30

Observability and Change-Management Failures

The last two incidents are about the systems around the cache: the telemetry that should reveal a failure, and the deploys that cause one.

F29 — A metrics outage hides a hit-rate collapse

A key-builder change ships at 19:00 with a bug that sends part of the lookups to the wrong key, and the hit rate drops from 90% to 60%. At peak, misses become 19,200 per second, database reads 19,200 + 12,000 = 31,200, which is 130% of capacity, and mean latency becomes 0.6 × 3 + 0.4 × 85 = 35.8 ms. The metrics pipeline is down for an unrelated reason, so the dashboard that would show the hit rate is blank, and the alert based on it cannot fire. A blank panel looks calm.

Detect it independently of the thing that failed. The database reports its own read rate, the load balancer reports request latency, and a synthetic probe asks for a canary key and a known-missing key every few seconds and records hit/miss from the response. 'No data' on a critical alert should itself page, because silence is also a signal. The principle for a cache is that its health must be provable from a vantage point outside it.

FacetF29 at a glance
TriggerA change collapses hit rate while the metrics path is unavailable.
Timelinet = 0 deploy; t ≈ minutes database load rises; no alert; t ≈ 20–30 min customer-visible slowness.
Visible symptomSlower pages and higher database load with no cache-side alerts; blank dashboards.
Root causeDetection depends on the same telemetry path that failed.
DetectionDatabase-side QPS and latency, load-balancer p99, synthetic canary hit/miss probes, 'no data' alerts.
ContainmentRoll back the change, apply fallback caps, restore metrics.
RecoveryHit rate rebuilds after rollback.
PreventionIndependent vantage points, alert on missing data, canary probes, deploy annotations and automatic rollback on hit-rate regression.
Guarantee that still holdsAnswers remain correct; load and latency are the casualties.
Trade-offSynthetic probes and independent monitors cost money and add their own noise.
Rebuildable or business errorRebuildable.

F30 — Key-schema migration with incompatible serializers

Meridian changes the value encoding and bumps the key schema version from v1: to v2: for a rolling deployment. Because the version is the first component of the key, new pods read only v2: keys and old pods read only v1: keys: neither can deserialise the other's values. The price is a gradual cold start: the v2: space is empty at the beginning, so each upgraded pod starts with misses, and in a fast rollout at peak the F02 arithmetic applies. Had both versions shared one key space, the failure would be worse: exceptions when an old pod tries to decode a new envelope, or silent corruption.

Rollout safely in two steps. First ship a reader that understands both versions and treats an unknown schemaVersion as a miss; then, once all pods run it, switch writes to v2:. Both key spaces then coexist until the v1: entries expire, so in the worst case the hot set is held twice, 15.27 × 2 = 30.5 GiB, 95% of the 32 GiB nominal: very little headroom, so watch evictions during the overlap or migrate in stages. A rollback is clean because the old keys are still there until their TTL.

FacetF30 at a glance
TriggerA serializer or schema change is deployed to a fleet that runs mixed versions.
Timelinet = 0 first new pod; t ≈ rollout the v2 space warms; v1 and v2 data coexist until v1 expires.
Visible symptomHit-rate dip during the rollout, deserialisation errors if keys were shared, memory pressure while both versions live.
Root causeProducers and consumers of the same cached value changed at different times.
DetectionHit rate and deserialisation error rate by app version, memory by key-schema version.
ContainmentPause the rollout, roll back writers, stay on v1 reads.
RecoveryFinish the rollout in the safe order and let v1 entries expire.
PreventionSchema version first in the key, tolerant readers, two-phase read-then-write rollouts, canary pods, memory headroom for the overlap.
Guarantee that still holdsNo misinterpreted value is served; a mismatch becomes a miss.
Trade-offVersioned key spaces cost temporary memory and a cold start; shared keys cost correctness risk.
Rebuildable or business errorRebuildable.
Operations

How to Run This Cache: Alerts, Signals and Game Days

The incidents repeat the same few questions: is the load shifting to the database, is the cache telling the truth, and can a failure grow beyond the shard it started in? An operating setup is the set of signals that answer them.

SignalWhat it tells youWhere it matters
Hit rate, per shard and per data classWhether the cache is absorbing the 72% of reads it was sized forF01, F02, F12, F26, F29, F30
Database read QPS against the 19,200 ceilingWhether load has moved to the authorityF01, F03, F09, F24
Shard ops/s and CPU versus the 25,000 budgetHot keys and skewF10, F11, F25
Eviction rate and age of evicted keysWorking-set pressure and thrashF12, F13, F26
Value-size histogram and bytes per shardOversized payloadsF14
Version-lag from sampled shadow readsWhether cached content matches the authorityF07, F08, F16, F18, F21
Invalidation consumer lag and replay countsDelivery of freshness eventsF08, F23
Topology-version skew, MOVED/ASK ratesRouting agreementF17, F18, F28
Replication offset gap and resync stateFailover loss window and catch-up costF16, F27
Per-tenant memory, ops and hit rateNoisy neighbors and leakageF15, F19, F20
Cross-tenant canary resultsNamespace isolationF20
Breaker state, retry ratio, in-flight per podRetry storms and latency cascadesF09, F24
Database-side and load-balancer metrics, synthetic probesDetection independent of cache telemetryF29
Operating signals mapped to the incidents they would have caught.
  • Kill one shard and its replica at peak load in a test environment and confirm the fallback cap holds the database at or below 19,200 reads per second while excess demand is shed by class (F01).
  • Restart the whole tier empty at peak and rehearse the ramp: admit ~15%, warm hot keys first, then raise admission in steps (F03).
  • Replay the F07 interleaving with a delayed fill and confirm the conditional fill rejects v5 after v6.
  • Fail over a primary with simulated lag and verify the invalidation replay window covers it (F16).
  • Run two tenants with identical product ids through every cache path and assert no cross-tenant hits (F20).
  • Disable service discovery during a failover drill and watch whether pods keep their last-known topology (F28).
  • Turn off the metrics pipeline and check that independent signals still page someone (F29).
  • Treating a cache error as a plain miss with no admission limit.
  • Using a TTL as the only mechanism for permissions, inventory ownership or anything in C4.
  • Believing a hit means fresh, complete or authorized.
  • Calling an asynchronous replica 'durable' or claiming the design never loses an acknowledged write.
  • Retrying cache calls immediately and without a budget.
  • Letting a shared pool run without per-tenant visibility.
Key takeaways
  1. 01Cache failures come in two kinds: load moves to the database (bound it with an admission cap and shedding by class) or truth drifts from the authority (bound it with versions, TTL backstops and checkout revalidation).
  2. 02Every protection has its own failure: single-flight leaders die, leases expire, invalidations are lost, replicas lag, topology drifts. Name the bound for each.
  3. 03Sharding spreads keys, not requests: a hot key needs L1 or replicated reads and a stated staleness limit.
  4. 04Isolation needs tenant-scoped keys from verified identity, quotas and ACLs, not naming conventions.
  5. 05Failover can lose acknowledged writes and resurrect deleted entries; that is acceptable only when the cache can be rebuilt.
  6. 06Detect cache health from outside the cache: database load, balancer latency and synthetic probes.