Skip to content
SeeItFlow
System Design · 15 chapters · 4 pseudocode walkthroughs · ~40 min read

Distributed Cache Architecture: Sharding, Eviction, Replication & Consistency

How a distributed cache works inside: the service contract, safe invalidation with version-aware fills, expiry and eviction, hash slots versus consistent hashing, routing and redirects, replication and failover, stampede and hot-key mechanics, sizing, security, and the guarantees it cannot make. All figures use the fictional Meridian Commerce example and are illustrative.

Mangesh MandlikLast updated: How lessons are made
Chapter 01

The Service API: What a Cache Promises Its Callers

A cache service looks trivial from the outside — get a value, put a value, delete a value — but each verb hides a decision about what the caller may assume. Writing the contract down first is what keeps later chapters honest.

Every name here belongs to a fictional teaching company, Meridian Commerce, a B2C storefront. Its source of truth is a relational product database; its Product API runs as a hypothetical 20 pods; the hot item is product:4821, and the large merchant Cedar and well-behaved merchant Birch share the cache. All rates, sizes and latencies are illustrative inputs, not measurements. A *cache* is a fast, memory-resident, derived copy of data that lives authoritatively somewhere else; a *hit* means the copy was found, a *miss* means it was not and the caller must go to the authority.

Two real engines are referenced throughout as comparison points. Redis is an in-memory data-structure server that can run as a single node, as a primary with replicas, or as a sharded *Redis Cluster*; Memcached is a simpler in-memory key-value cache that does no replication or clustering of its own. They are examples, not a shared feature set. The HTTP surface below is deliberately illustrative: Redis speaks its own binary-safe protocol (RESP) and Memcached has text and binary protocols; neither exposes this REST shape. Teaching it as REST lets us discuss status codes, headers and limits without tying the lesson to one engine. A real deployment would put this contract in a client library that speaks the engine's protocol.

OperationRequestSuccessNotable errors
Read one keyGET /v1/cache/{namespace}/{key}200 with envelope and TTL remaining; 404 for a miss (not an error)401/403 unauthenticated or not allowed in this namespace; 414 key too long; 503 shard unavailable
Write one keyPUT /v1/cache/{namespace}/{key} with ttlSeconds and optional ifVersion204; or 409 when the supplied record version is older than the stored one413 value over the item cap; 400 bad TTL; 429 tenant over its write quota
Delete one keyDELETE /v1/cache/{namespace}/{key}204, whether or not the key existed (idempotent)403; 503
Batch readPOST /v1/cache/{namespace}/mget with up to a fixed number of keys200 with per-key hit/miss entriesPartial results are normal: a down shard must not fail the whole batch silently
AdministrativeGET /v1/admin/stats, POST /v1/admin/purge-prefixCounters by key class; purge acknowledged asynchronouslySeparate credentials; purge is rate limited and audited
An illustrative cache API. Status codes are a design choice, not an engine standard

Three details carry most of the weight. First, a miss is a normal answer, so it travels as a 404-style result that the caller must handle with a fallback rather than as an exception. Second, a write can carry ifVersion, the record version taken from the database; the cache refuses to replace v6 with v5 (chapter 4 shows why). Third, every response reports the remaining TTL, so a caller can decide whether the value is fresh enough for *its* purpose — a product card tolerates minutes, a checkout total tolerates none.

Size limits belong in the contract too. Meridian's illustrative policy is: reject values over 1 MiB and keys over 250 bytes. The key figure is borrowed from Memcached, whose protocol documentation limits keys to 250 characters; Memcached's maximum item size is configurable and by default about 1 MiB, but check the value for the version you run. A cache that accepts arbitrarily large values lets one buggy writer evict everyone else's data.

Chapter 02

Key Schema and the Value Envelope

A key is both a name and a routing instruction, and the value is more than the bytes: the metadata wrapped around it is what lets a reader decide whether it may use what it found.

textMeridian key layout. Prose and scenes use the display form tenant:cedar:product:4821
v2 : cedar : product : 4821
│    │       │         └─ record id
│    │       └─ key type (product, price, avail, neg)
│    └─ tenant namespace
└─ schema version of the value format

The schema version comes first because two deployments will overlap during a rollout: pods on the new serializer write values that pods on the old one cannot read, and the reverse. If the version is part of the key, the two generations simply never see each other's entries and the cold start of a new version is a deliberate, observable event rather than a deserialization storm. The cost is a one-time miss wave when the version bumps, which is why rollout plans warm or ramp traffic gradually.

The tenant segment gives every merchant its own namespace, which makes per-tenant quotas, purges and metrics possible. It is a naming convention, not a security boundary: chapter 13 explains why identity and access control must be enforced separately, and why the tenant segment must come from the verified caller, never from request input.

The envelope

jsonValue envelope used by every Meridian cache entry (illustrative)
{
  "schemaVersion": 2,
  "recordVersion": 6,            // version assigned by the database, not by the cache
  "createdAt":  "2026-11-07T07:12:50Z",
  "expiresAt":  "2026-11-07T07:17:50Z",
  "compression": "none",        // or a named codec; decided per value size
  "provenance": "product-api/v118",
  "bytes": "<opaque serialized product card, 1.5 KiB typical>"
}

recordVersion is the field that makes the rest of the design possible. It comes from the authority: a monotonically increasing number the database bumps on each committed change to the row (v5 is the $99 card, v6 is the $109 card). A cache must never mint it, because a cache cannot know the order in which the database committed things. createdAt and expiresAt are for debugging and for staleness metrics; the engine's own TTL still performs the actual expiry. provenance lets an incident responder say which build wrote a bad value.

Compression is chosen per value, not globally. Gains are workload dependent, and decompression is attacker-influenceable work, so Meridian caps the *decompressed* size as well as the stored size to avoid decompression bombs. Sensitive data is not welcome in the envelope at all: no payment instruments, no secrets, and no unredacted personal data in a store whose access model is a shared password. (Where an entry does need to be tied to an identity, store a reference or a redacted projection, and keep the personal fields in the database.)

  • Derive keys from verified identity and a fixed schema, never by concatenating raw request strings; reject keys over the length cap.
  • Store the authority's version, and compare versions rather than timestamps when two writers race.
  • Bound the decompressed size as well as the stored size.
  • Using wall-clock `createdAt` to decide which value is newer. Pods' clocks disagree.
  • Putting the tenant in the key and calling it isolation.
  • Caching serialized objects with language-native formats that execute code on load; use a plain data format.
Chapter 03

The Read Path: Cache-Aside with a Bounded Fetch

Cache-aside, also called lazy loading, puts the application in charge: look in the cache, and only on a miss ask the database and then store the answer. Everything difficult about it lives in the word *only* — what happens when many callers miss together, or when the database is slow.

Swipe to pan
Cache-aside: hit path and miss pathCache-Aside: The App Owns the CacheRead path for product:4821 · the cache never talks to the DB by itselfShopperProduct APICacheProduct DBHIT path ≈ 3 ms1 GET /products/48212 GET v2:cedar:product:48213 hit: $99 · v5MISS path ≈ 85 ms (illustrative)4 GET → miss5 SELECT product 48216 row: $99 · v57 SET value, TTL 300 s ± 20%8 200 OK · $99A failed cache write at step 7 must not fail the request: the shopper already has the data.Weakness: steps 5–7 are not atomic with writers (stale-fill race); every miss costs the full path.Variants: read-through (the cache loads for you), write-through, write-behind (risk of dirty data).
Cache-aside read: hit returns in about 3 ms; a miss pays the database and fills the cache

At the illustrative numbers, a hit costs 3 ms and a miss 85 ms. With a 90% hit rate on cache-eligible reads the mean is 0.9 × 3 + 0.1 × 85 = 11.2 ms, but the p99 (the latency that 99% of requests beat) sits near the miss cost, around 85 ms. The mean looks wonderful and still hides a tail that one in ten shoppers lives in. Design for the tail.

pythonRead path. Numbered lines are explained below; Python-like pseudocode, not a client API
def get_product(tenant, pid):
    key = f"v2:{tenant}:product:{pid}"
    env = cache.get(key, deadline_ms=20)          # 1
    if env is not None and not expired(env):
        return env.payload                         # hit: fresh enough by TTL
    if env is None and neg_cache.has(key):         # 2
        raise NotFound

    # miss or cache unreachable: bounded, coalesced fetch
    return single_flight(key, lambda: fill(tenant, pid, key), wait_ms=120)  # 3

def fill(tenant, pid, key):
    row = db.read_product(tenant, pid, deadline_ms=60, breaker=db_breaker)   # 4
    if row is None:
        neg_cache.put(key, ttl=30, per_tenant_cap=True)
        raise NotFound
    env = Envelope(payload=row.card, recordVersion=row.version)
    cache.put_if_newer(key, env, ttl=jitter(300), deadline_ms=20)            # 5
    return env.payload

(1) The cache call has its own short deadline. A cache that answers slowly is worse than one that is down, because every request would wait for it; on timeout the code treats the result as a miss and moves on. (2) A negative cache entry ("this product does not exist", TTL 30 s, capped per tenant) stops repeated lookups of ids that are not there, such as a crawler walking ids. It is a separate cache class from real products on purpose. (3) single_flight is the stampede brake. Callers that miss on the same key in the same process share one fetch instead of each running one; chapter 9 covers its limits. The wait is bounded: a caller that cannot get an answer in 120 ms returns an error or a stale-but-allowed value rather than hanging.

(4) The database read carries a deadline and passes through a circuit breaker, a switch that stops calls to a failing dependency so that it can recover. Without the bound, a slow database turns into a queue of waiting requests, then exhausted threads, then an outage that began as mere slowness. (5) The fill is conditional: put_if_newer refuses to overwrite a stored v6 with a v5 payload that was read earlier. That line fixes the race the next chapter walks through. The TTL is the 300 s base with ±20% jitter, spreading expiry across 240–360 s.

Chapter 04

Safe Writes: Commit First, Invalidate After, Fill with Versions

On a write the authoritative database is updated first and the cache is told afterwards. The reverse order can leave the cache holding a value the database never had. Even in the right order there is a race that every cache-aside system has to deal with.

Take Meridian's price change. A shopper sees $99 (record v5). An operator commits $109 (v6). The cache still holds v5 and will keep serving it until something removes it. The correct trigger is a *post-commit event*: the writing transaction also inserts a row into an outbox table, a relay publishes it to an invalidation stream, and a subscriber with the authority to do so deletes (or refreshes) the cache key. An event does not magically edit caches; the subscriber does, and it can fail, lag, or deliver twice.

Swipe to pan
Commit first, then invalidateInvalidate After Commit, Via an OutboxPrice change $99 → $109 (v5 → v6) for product:4821Pricing adminDB + outboxInvalidatorCache1 UPDATE $109 → v63 relay: event {4821, v6}4 DEL v2:cedar:product:48216 next read: miss → read v67 fill $109 · v62 commit ✔5 emptykey gonestale windowsecondsWhy after commitDelete before commit:a reader refills v5 andthe delete is wasted.Outbox: the event existsiff the write committed.At-least-once: repeat ok.Still not perfectA lost event leaves v5 until TTL (≤ ~360 s).A late reader can refill v5 after step 5 — see the stale-fill race.Checkout is the safety netDisplayed price is non-binding; checkoutrevalidates price from the authority.
Commit to the database first, then publish an invalidation from the outbox; the subscriber deletes the key
pythonSafe write and invalidation. The outbox makes 'row changed' and 'event exists' one atomic fact
def update_price(tenant, pid, new_price):
    with db.transaction() as tx:
        row = tx.update_product_price(pid, new_price)       # version 5 -> 6
        tx.insert_outbox(topic="product.changed",
                         key=f"v2:{tenant}:product:{pid}",
                         record_version=row.version)         # same transaction
    # commit succeeded here; nothing is published if it rolled back

# relay process: reads outbox rows in commit order, publishes, marks sent
# subscriber (at-least-once delivery, so must tolerate duplicates):
def on_product_changed(ev):
    cache.delete_if_older(ev.key, ev.record_version, retries=5)  # idempotent
    # if all retries fail: TTL backstop (240-360 s) will still bound staleness

Why an outbox rather than calling the cache directly after the commit? Because a process can crash between the commit and the call, and the invalidation is then lost forever; the database says v6 and the cache says v5 with nothing pending to correct it. Writing the event row inside the same transaction removes that window. The relay might publish an event twice, so the subscriber's operation must be idempotent: deleting a key twice, or deleting only if the stored version is older than the event's, is harmless. See the outbox pattern for the mechanism in general.

The refill race

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.
A slow reader that fetched v5 before the commit writes it back after the invalidation

Now the subtle part. Reader R1 misses, reads v5 from the database and is delayed (garbage collection, a slow network). The operator commits v6; the subscriber deletes the key. R1 wakes up and stores its v5 payload. The cache now holds an old value *after* the invalidation that was supposed to remove it, and nothing will delete it again until its TTL. Delete-after-commit alone does not prevent this; TTL only bounds it (here up to about 360 s for the price).

Swipe to pan
Versioned fill rejects the stale writeCompare Versions Before FillingSame interleaving as the stale-fill race, with an atomic version check on fillReader pod AProduct DBWriter pathCache1 read row $99 · v53 commit $109 · v64 invalidate: floor = v65 fill $99 · v5 only if v ≥ floor2 reader pauses6 → rejected: 5 < 6entry stays empty7 later read: miss → fill $109 · v6Needs an atomic compare-and-sete.g. a server-side script or a CAS token; a plainGET-then-SET in the client reopens the race.Costs and limitsFloor/tombstone entries need a short TTL; they usememory and apply only when all writers carry versions.
Version-aware conditional fill: v5 is rejected because the cache already knows v6, or the delete left a tombstone

Two remedies exist. Version-aware fill: the cache stores recordVersion and refuses writes with a lower number. This needs the key to remember something after deletion, either a short-lived *tombstone* carrying the version or an invalidation that writes the new version in place of deleting. The version must come from the database; a cache-generated counter would just number the race. Accept bounded staleness: for a low-risk class, such as a product description, let the TTL cap the exposure and skip the machinery. Meridian does the first for C2 prices and the second for C1 descriptive content.

Key takeaways
  1. 01Commit to the authority first; derive the invalidation from the commit (outbox, or change data capture — reading the database's own commit log and turning row changes into events), never from a best-effort call after it.
  2. 02A delayed reader can refill an old value after an invalidation; the remedy is a version-conditional fill or an explicitly accepted TTL bound.
  3. 03Versions come from the database, and the subscriber must be idempotent.
Chapter 05

TTL, Expiry and Eviction: Two Ways an Entry Leaves

An entry leaves a cache for one of two reasons that are easy to confuse: its time ran out (expiry) or the memory ran out (eviction). They are driven by different policies, answer different questions, and fail differently.

Swipe to pan
TTL is a backstop, not the update mechanismFreshness Timeline for a Cached PriceBase TTL 300 s ± 20% jitter → expiry uniformly in [240, 360] s0 s100 s200 s300 s400 sexpiry window 240–360 sDB update (t = 90 s)A · event deliveredfresh $99stale a few seconds, then DEL → refill $109B · event loststale $99 until TTL (≈ 210 s here)expires → miss → $109C · worst case≤ ~360 sTTL does three jobsBounds staleness, reclaims memory of cold keys,spreads refills (jitter). It does not synchronize writes.Class-specificC1 content 15 min · C2 price 300 s + event · C3 badge 30 sNever TTL-only: stock reservation, secrets, permissions.
A TTL timeline: fresh, stale budget, expired, with jitter spreading expiry across 240–360 s

A *TTL* (time to live) is a per-entry lifetime after which the entry must not be returned. It bounds staleness; it does not synchronize anything with the database. Meridian assigns lifetimes by data class: C1 descriptive content 15 minutes ±20%, with stale-while-revalidate (serve the old value briefly while one caller refreshes it) allowed for up to 5 more minutes; C2 price 300 s ±20% plus event invalidation; C3 availability badge 30 s; negative entries 30 s. Reservations, payment instruments and authorization decisions are never served from a TTL-only cache.

Lazy and active expiry

In Redis, expiry has two mechanisms, both documented. Passive (lazy) expiry checks the TTL when a key is accessed and deletes it if it has elapsed. Active expiry periodically samples keys that have a TTL and deletes the expired ones, so keys nobody reads again do not sit in memory forever. The consequence is that an expired key may briefly occupy memory but is never returned. Redis also centralises expiry on the primary: it synthesises a delete command that goes to replicas and to the append-only file (AOF, its optional write log used for persistence), so replicas do not run their own TTL arithmetic. Memcached's protocol states that a non-zero expiry is guaranteed not retrievable after it elapses, and that an item with expiry 0 may still be removed to make room for other items. Treat TTL as an upper bound on how long a value is *served*, not a promise about when memory is freed.

Eviction when memory is the limit

Swipe to pan
Eviction policies on one access traceSame Trace, Three Eviction BehaviorsCapacity = 3 entries · P is the hot product, S1–S3 are a one-off scan · toy exampleAccess trace →PQPRPS1S2S3PLRUevict least recently usedevicts Q, then P, then Rcache after step 8:S1S2S3final P: MISS · 2 hits / 9A one-off scan flushed the hot key.LFUevict least frequently usedevicts Q, then R, then S1cache after step 8:PS2S3final P: HIT · 3 hits / 9Counts protect P; stale counts need decay.No evictionTTL only, memory fullrejects S1, S2, S3 (out of memory)cache after step 8:PQRfinal P: HIT · 3 hits / 9Old keys stay; new keys cannot be cached.In real enginesLRU/LFU are approximations (sampled keys, decayingcounters); policy names and defaults are version-specific.Eviction ≠ expiry ≠ invalidationEviction is memory pressure, expiry is time, invalidationis correctness. Mixing them up hides stale data.
LRU, LFU and random eviction on the same access trace: what each keeps and drops

Redis sets a maxmemory limit (by the documentation, no limit by default on 64-bit builds) and, when it is reached, applies a configured policy: noeviction (writes fail), allkeys-lru, allkeys-lfu, allkeys-random, and volatile-lru, volatile-lfu, volatile-random, volatile-ttl, where the volatile variants only consider keys that have a TTL — and with no such keys behave like noeviction. Redis 8.6 and later add least-recently-modified (allkeys-lrm, volatile-lrm) variants, which refresh a key's timestamp only on writes; check the version you run.

PolicyEvictsFitsWeakness
LRU (least recently used)The entry untouched for longestRecency-driven traffic, shifting working setsOne large scan of cold keys can flush the hot ones
LFU (least frequently used)The entry used least often, with decay over timeStable popularity skew, such as a few bestsellersSlow to adapt when popularity moves; needs counter decay
RandomA random entryRoughly uniform accessEvicts hot entries by chance
TTL-first (volatile-ttl)The entry closest to expiryShort-lived data with explicit TTLsIgnores usefulness; entries without TTL are untouchable
Eviction policies compared; the right one depends on the access pattern, so measure

Redis's LRU and LFU are approximations. LRU samples a handful of keys (maxmemory-samples, 5 in the documentation) and evicts the best candidate among them rather than keeping an exact global order, which would cost memory and time. LFU uses a small probabilistic logarithmic counter with a decay period (lfu-log-factor, lfu-decay-time). The documentation calls allkeys-lru a good default if you have no reason to prefer another. Walk a trace to see why the choice matters: with a bestseller read 100 times a minute and a crawler reading 10,000 distinct products once each, LRU lets the crawl push the bestseller out and LFU does not, whereas for a flash sale whose popularity appears abruptly LFU lags until counts build.

Memcached memory is organized differently

Memcached allocates items into slab classes, size buckets with pages assigned to each. An item lives in the smallest class that fits, which wastes some space inside the chunk but avoids general-purpose allocator fragmentation. Eviction is per slab class using an LRU, and the protocol exposes slabs reassign and slabs automove for moving pages between classes, documented as subject to change, plus an LRU crawler that reclaims expired items. The practical lesson: if object sizes shift (say, a new serializer makes values bigger), memory may sit in the wrong slab class and evictions can occur in one class while others have free pages. Redis instead uses a general allocator with configurable policies and has its own fragmentation to watch. Neither engine's behavior should be assumed from the other.

Chapter 06

Sharding: Modulo, Rings and Hash Slots Are Three Different Things

A *shard* is one slice of the keyspace owned by one node. The whole design question is how a key finds its owner, and what happens to the mapping when the number of nodes changes.

Modulo hashing

The obvious rule is owner = hash(key) mod N. It is deterministic and needs no table, but changing N reshuffles almost everything: going from N to N+1 changes the owner of N/(N+1) of keys, so 3 to 4 nodes remaps about 75%, 4 to 5 remaps 80%, and 10 to 11 remaps roughly 91%. Each remapped key is a miss on its new owner, so adding capacity triggers a stampede on the database at the worst moment.

Swipe to pan
Modulo hashing remaps most keys when a node is addedModulo Placement: Adding One Node Remaps Most Keysnode = hash mod N · 12 illustrative key hashes, not a real hash functionkey hashmod 3 (A–C)mod 4 (A–D)17CB22BC35CD41CB58BC64BA70BC83CD96AA101CB112BA120AA10 of 12 keys change owner (red) — a small sample; the expected share is 3/4 = 75%Each change is a cache miss on a cold node, so the database sees a wave of misses right after a routine resize.Share of keys that change owner when one node is added3 → 4 nodesmodulo 75.0%balanced ring or slots ≈ 25.0% (1 / (N+1))4 → 5 nodesmodulo 80.0%balanced ring or slots ≈ 20.0% (1 / (N+1))10 → 11 nodesmodulo 90.9%balanced ring or slots ≈ 9.1% (1 / (N+1))
Modulo hashing, 3→4 nodes: most keys change owner

A twelve-key illustration (hash values 17, 22, 35, 41, 58, 64, 70, 83, 96, 101, 112, 120) sees 10 of 12 change node when going from 3 to 4. That 83% is a small-sample figure; the expected value is 75%.

Consistent hashing with virtual nodes

*Consistent hashing* places nodes and keys on a ring; a key belongs to the first node position clockwise from its hash. Adding a node takes over only the arcs next to its positions, so about 1/(N+1) of keys move when the ring is balanced. Because one position per node gives lumpy arcs, real implementations give each node many virtual nodes. Here is a toy ring with hash space 0–99 and illustrative values, not a real hash function: tokens A{10, 60}, B{25, 80}, C{40, 95}. The key product:4821 hashes to 33, whose next token is 40, so it lives on C. Add D{20, 70}: D takes (10, 20] and (60, 70], both previously B's, so 20 of 100 positions move, all from B, and 4821 stays on C. The result is A 35, B 15, C 30, D 20 — uneven, which is exactly what few virtual nodes cause. See consistent hashing for more.

Swipe to pan
Consistent hash ring: adding a node moves only its own rangesConsistent Hash Ring: A New Node Takes Only Its Own Rangesillustrative hash space 0–99 · a key belongs to the first token clockwise (at or after its hash)A10D20B25C40A60D70B80C95key 33→ owner C (token 40)red outline = ranges moved to DPositions owned (of 100)Before: A{10,60} B{25,80} C{40,95}A 35B 35C 30After adding D{20,70}A 35B 15C 30D 20What movedD takes (10,20] and (60,70], both B's20 of 100 positions (20%) move, all from BA, C and key 33 (still C) are untouchedCaution: unevenB now owns 15, D 20: two tokens per node istoo coarse. Real rings give each node manyvirtual nodes to even this out.A balanced ring moves about 1/(N+1) of keys;modulo 3 → 4 moved 75% (modulo diagram).
A toy ring: adding D moves only 20 of 100 positions, all from B

Redis Cluster hash slots

Redis Cluster does something else. It has 16,384 fixed hash slots; the slot is CRC16(key) mod 16384 (the XMODEM CRC16 variant), and each primary owns a set of slots. Moving data means moving slots between nodes, explicitly. This is *not* consistent hashing, and the two are not interchangeable: the ring maps by position and moves arcs implicitly, while slots are a fixed table moved by an operator or planner.

Swipe to pan
Redis Cluster: 16,384 hash slots mapped to nodesFixed Slot Table: 16,384 Slots, Moved Range by RangeHASH_SLOT = CRC16(key) mod 16384 · illustrative even split, real planners choose their ownBefore — 3 primariesA 0–5,460B 5,461–10,922C 10,923–16,383After — D added by migrating 4,096 slots (25%)A 0–4,095D 1,365B 5,461–9,557D 1,365CD 1,366C 13,654–16,383slot 0slot 16383{tenant:cedar}:*slot 860 (hash tag): Atenant:cedar:product:4821slot 8298: B, staysproduct:4821slot 13107: C, then DWhy slots instead of a ring• A fixed slot→node table: moving data means moving named slots, one at a time, not shifting hash positions.• Hash tags: only the text inside {…} is hashed, so keys sharing a tag share a slot and may be used in one multi-key command.• Cost of the tag: a whole tenant on one slot is one shard. A hot tenant is a hot slot, and a slot cannot be split.
Sixteen thousand three hundred eighty-four slots, split across A, B and C; adding D moves 4,096 slots

With three primaries split evenly, A owns 0–5460, B 5461–10922 and C 10923–16383. The plain key product:4821 computes to slot 13107 and so lives on C; the tenant-scoped tenant:cedar:product:4821 computes to slot 8298 and lives on B. Adding D by migrating 4,096 slots (25%: 1,365 from A, 1,365 from B, 1,366 from C, an illustrative plan) moves product:4821 from C to D while the Cedar key stays on B.

Hash tags and their cost

If a key contains a non-empty {...} section, only the text inside the first braces is hashed. {tenant:cedar}:product:4821, {tenant:cedar}:product:4822 and {tenant:cedar}:price:4821 all land on slot 860, so multi-key commands over them are allowed, because Redis Cluster requires all keys of a multi-key command to be in the same slot; otherwise the node rejects the command with a cross-slot error. The price is concentration: a tag on the whole tenant puts the whole tenant on one slot and therefore one shard, so a hot tenant becomes a hot shard, and a slot is the smallest unit of migration.

Chapter 07

Routing, Redirects and Live Resharding

Once data is spread across shards, someone has to send each request to the right one. Two designs dominate, and either way the routing table is eventually stale.

A smart client keeps the topology (which node owns which slot or ring arc) and connects directly: one network hop, but every language client must implement discovery and redirects correctly. A proxy layer hides topology behind one endpoint: clients stay simple and topology changes are central, at the cost of an extra hop, a new tier to scale and keep available, and a new place for connection exhaustion. Redis Cluster's design has no proxies; a client may send a query to any node and be redirected. Memcached deployments typically rely on client-side hashing across servers, since the engine itself does not cluster or replicate (check the documentation for your version).

Swipe to pan
Smart client routing: MOVED versus ASKSlot-Aware Clients: MOVED Fixes the Map, ASK Is a One-Time Detourkey product:4821 = slot 13107 · slot moves from node C to new node DApp clientNode CNode DA. Migration finished; the client's slot map is staleGET product:4821map still says slot 13107 → C-MOVED 13107 Dslot is served by D nowmap: 13107 → DGET product:4821value (v6, $109)B. Migration in progress; C is MIGRATING, D is IMPORTINGGET product:4821key already moved off C-ASK 13107 DASKING, then GETD accepts only after ASKINGvaluemap not updated
A smart client refreshes its slot map on MOVED and follows ASK once

MOVED, ASK and the migration state machine

While Redis Cluster migrates a slot, the source node is in a *migrating* state and the target in *importing*. The source answers for keys it still has. For keys already moved it replies -ASK with the target address; the client sends an ASKING command followed by the original command to the target, once, without updating its table. The target accepts the key only after ASKING. When the migration finishes, a node that no longer owns the slot answers -MOVED <slot> <host:port>, which is the permanent signal: update the slot map. Clients should handle both.

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.
Live reshard: stable, migrating, importing with ASK, handed over with MOVED
pythonRouting with redirects, deadlines and idempotency. Pseudocode, caveated: real client libraries differ
def route(cmd, key, deadline):
    slot = crc16(key) % 16384
    node = slot_map[slot]
    for attempt in range(MAX_REDIRECTS + 1):          # bound redirect loops
        reply = node.send(cmd, timeout=deadline.remaining())
        if reply.kind == "MOVED":                       # permanent: refresh topology
            slot_map.refresh_from(reply.node); node = reply.node; continue
        if reply.kind == "ASK":                         # one-shot, do not update map
            reply = reply.node.send("ASKING", cmd)
            return reply
        if reply.kind == "TIMEOUT":
            if cmd.is_idempotent():                      # GET, DELETE, SET with same value
                return retry_once_after_backoff(cmd, node, deadline)
            raise AmbiguousOutcome(cmd)                  # INCR / CAS: did it apply?
        return reply
    raise RoutingLoop(key)

The timeout branch is the dangerous one. A read that times out can be retried. An INCR that times out may or may not have been applied on the node, and retrying double-counts; a compare-and-set (CAS, which writes only if the stored token still matches) can succeed on the first attempt and then report a conflict on the retry. Non-idempotent operations need a request token the server can deduplicate, or the caller must treat the outcome as *ambiguous* and resolve it by reading. Atomicity is per command, on one node; there is no global transaction across keys on different shards (see retry and backoff).

Three practical details sit under any routing design. Discovery: a client learns the topology at start-up from a seed node or a configuration service and must re-learn it when redirected or when connections fail. Connection pools: each pod keeps a small pool of persistent connections per node, so 20 pods talking to 8 nodes hold on the order of a few hundred sockets; opening a connection per request would spend more time on handshakes (and TLS) than on the lookup. Retries and deadlines: the request carries one overall deadline that the redirects and retries all draw from, and retries are capped (and ideally budgeted across the fleet), because a retry storm against a struggling shard turns a partial failure into a full one. Resharding also has a cost model. Every moved key is either migrated explicitly (slot migration copies it) or missed and refilled (ring changes). The first uses network and CPU on both nodes; the second pushes load onto the database. Plan it off-peak, throttle migration, and watch the miss rate while it runs.

Chapter 08

Replication and Failover: What an Acknowledgement Means

A single node is a single point of failure, so each primary gets a replica. The trap is in what the primary's "OK" tells the client.

Redis replication is asynchronous by default: the primary applies the write, replies to the client, and streams the change to replicas, which acknowledge processed offsets asynchronously; the primary does not wait. A replica can serve read-only queries, which may be stale. A reconnecting replica attempts a partial resync using the replication ID and offset, and falls back to a full resync from a snapshot when that is not possible.

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.
Async replication: the primary acks v6, crashes before the replica applies it, and the promoted replica holds v5

Follow one write. The primary acknowledges SET product:4821 $109 v6. The replica, 120 ms behind in this hypothetical, has not yet applied it. The primary dies and the replica is promoted: it holds v5 ($99). The acknowledged write is gone. The Redis Cluster specification says so directly: it tries, best-effort, to keep writes from clients connected to a majority of primaries, but there are windows where acknowledged writes can be lost, larger when a client is in a minority partition. It also uses asynchronous replication, last-failover-wins, and no merging of values. WAIT can make the client block for replica acknowledgements, which sharply reduces the loss window, yet it does not turn the system into a strongly consistent one.

A custom design could instead acknowledge only after one or more replicas confirm (synchronous or quorum replication). That closes most of the window, but every write now pays a network round trip to the slowest required replica, a slow or partitioned replica stalls writes (or forces a downgrade to async), and it still does not make a cache a database. Most caches accept the async window because reads dominate and the tail of recent writes is cheap to repair. For a cache this loss is acceptable only because the cache is rebuildable: the database still says v6, an invalidation or TTL eventually corrects the entry, and checkout revalidates. If the same cache were the only copy of the data — a session store, a counter that is billed, a write-behind buffer — the same event would be a business error. Do not call a cache durable because it has replicas.

Split brain, fencing and epochs

*Split brain* means two nodes both believe they are primary for the same data, typically after a partition. Redis Cluster limits the damage with majority voting and with minority-side primaries refusing writes after a node timeout. A custom design must provide the equivalent: a monotonically increasing epoch (a term number) attached to each primary, replicas that reject replication from a lower epoch, and fencing, so a deposed primary's writes are refused downstream. Without these, two primaries can each accept writes and diverge.

  • Say which loss you accept (the unreplicated tail at failover) and what repairs it (TTL, invalidation, revalidation).
  • Place a replica in a different failure zone from its primary, and size memory for the full copy: replicas double the bill, 15.27 GiB becoming about 30.5 GiB across eight nodes in the sample worksheet.
  • Rebuild gradually after failover. A promoted replica may be partially cold; ramp traffic.
  • Claiming synchronous guarantees from `WAIT`.
  • Reading from replicas for data that must be current, such as permission decisions.
  • Calling a cache with write-behind 'durable'.
Chapter 09

Stampede Mechanics: Single-Flight, Leases, Early Refresh, Jitter

A *stampede* happens when many callers miss on the same key at once and each goes to the database, because nobody has yet refilled it. The hot product 4821 at 8,640 requests per second with an 85 ms miss path sends 8,640 × 0.085 ≈ 734 requests to the database before the first refill lands.

The flash-sale version is starker: 5,000 concurrent misses on one expired key, spread over the 20 pods. With no coalescing every one of those requests issues its own fetch, and the database sees 5,000 identical reads for one row.

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.
Distributed single-flight: one pod wins a short lease and reads the database, the others wait with a deadline; if the leader dies, the lease expires and a waiter takes over

Single-flight and its scope

*Single-flight* (request coalescing) makes callers for the same key in the same process share one in-flight fetch. Across 20 pods that cuts 5,000 database fetches to at most 20, about 250 waiters per pod. A distributed lease — one pod acquires a short-lived lock in the cache or a coordination store, and the rest wait or serve stale — cuts it to 1, plus races when the lease expires. The costs: waiters need deadlines or they queue forever; if the owner crashes, the lease must expire and someone else must take over; and a lease that expires while its owner is merely slow lets two fetchers run (so the fill must still be version-conditional). See distributed locks for why a lock is advisory here, never a correctness mechanism.

pythonDistributed lease with an owner token, a short TTL and a stale or bounded-wait fallback
def fetch_with_lease(key):
    token = cache.set_if_absent(key + ":lease", owner_id, ttl_ms=2000)
    if token:                                   # we own the refill
        try:
            v = db.read(key, deadline_ms=60)
            cache.put_if_newer(key, v, ttl=jitter(300))
            return v
        finally:
            cache.delete_if_value(key + ":lease", owner_id)   # never delete another owner's lease
    stale = cache.get_stale(key)                # allowed only for classes that permit it
    if stale is not None: return stale
    return wait_for_fill(key, max_wait_ms=120) or fail_fast()

Early refresh and jitter

Probabilistic early refresh lets a caller occasionally refresh an entry shortly *before* it expires, with higher probability as expiry nears, so the key is rarely absent at all. Stale-while-revalidate serves the previous value while one caller refreshes. TTL jitter attacks a different shape of stampede: if 6,000 keys are warmed together with an identical 300 s TTL they all expire in the same instant. With ±20% jitter the lifetimes are uniform over 240–360 s, a spread of 120 s, so about 6,000 / 120 = 50 keys per second expire instead of 6,000 at once.

Chapter 10

Hot Keys: Sharding Does Not Divide One Key

Sharding spreads *different* keys across nodes. A single key is indivisible: all of its traffic lands on one shard no matter how many shards exist.

At peak the top product draws 18% of the 48,000 eligible requests per second, which is 8,640 on one key (not to be confused with the 8,640 hits per second of the average-load case; same number, different quantity). The other 39,360 spread over four shards at 9,840 each, so the hot shard carries 8,640 + 9,840 = 18,480/s, or 73.9% of a hypothetical 25,000 ops/s budget, 1.88× the others. A trending item at 45% (21,600/s) pushes its shard to 28,200/s, over budget, while the rest sit at 6,600.

Detection comes from sampled key-level counters or per-shard metrics: shard CPU skew, one key's share of traffic. Relief has two main forms. A tiny per-pod L1 (local, in-process) cache for just that key, with a 2 s TTL across 20 pods, limits the shard to at most 20 / 2 = 10 GETs per second for it. Alternatively, replicate the hot value as copies under several key names and spread reads across them. Both trade freshness: the key can be stale for up to the L1 TTL, and invalidation has to reach every L1 and every copy, so an invalidation message that is dropped costs you the 2 s bound, not the 360 s one.

  • Promote keys to L1 by measurement, with a short TTL and a size cap.
  • Send invalidations to local caches too, or accept the short TTL as the whole bound.
  • Keep hot-key policy per data class: a display card may be 2 s stale; a permission decision may not.
  • Adding shards for a single hot key.
  • Putting one tenant under a single hash tag and then being surprised that it is one slot.
Chapter 11

Capacity Sizing: Payload Is Not Memory

Sizing a cache starts with the number of objects times their size, and then every additional term the first estimate forgot.

Meridian's illustrative workload: average 12,000 requests per second, peak 60,000, 80% cache-eligible, baseline 90% hit rate, average serialized object 1.5 KiB, 8,000,000 potentially hot objects. Payload alone is 8,000,000 × 1.5 KiB = 12,000,000 KiB = 11.44 GiB (12.288 GB decimal; GiB means 2^30 bytes, GB 10^9, and mixing them is an easy 7% error).

TermAssumed valueRunning total
Payload1,536 B per item12.288 GB (11.44 GiB)
Key≈ 40 B—
Entry metadata, hash table, pointers≈ 64 B1,640 B per item → 12.22 GiB
Allocator / slab fragmentation× 1.2515.27 GiB total
Per shard (4 shards)÷ 43.82 GiB mean
Hottest shard× 1.15 uneven distribution4.39 GiB (54.9% of an 8 GiB budget)
With one replica per primary× 230.5 GiB across 8 nodes
Memory worksheet. Every overhead is assumed; measure with the engine's own tooling
Swipe to pan
Memory budget: raw payload is not what you provisionFrom 11.44 GiB of Payload to What You Provision8,000,000 objects × 1.5 KiB · every overhead is an ASSUMPTION to be measured on your engine0 GiB8 GiB16 GiB24 GiB32 GiBRaw payload11.44+ key & metadata12.22× 1.25 fragmentation15.27 totalPrimaries (4 × 8)47.7% used+ replicas (8 nodes)30.5 GiBPer-item arithmetic (assumed)payload 1,536 B + key ≈ 40 B + metadata ≈ 64 B = 1,640 B1,640 B × 8,000,000 = 12.22 GiB× 1.25 slab/allocator fragmentation = 15.27 GiB÷ 4 shards = 3.82 GiB mean per shardhottest shard × 1.15 skew = 4.39 GiB = 54.9% of 8 GiBDo not forget headroomReplication and client buffers, fork/snapshot copy-on-write, and growth all need space beyond the 47.7%.Replicas hold a full copy: memory doubles.Measure real bytes per item with the engine’s ownmemory tooling before sizing a fleet.
From 11.44 GiB of payload to 15.27 GiB of memory and 30.5 GiB with replicas

On four primaries of 8 GiB (32 GiB nominal) the average utilization is 15.27 / 32 = 47.7%, so there is room, but only after the model: also reserve headroom for replication and persistence buffers (Redis does not count the output buffers queued for replicas or the AOF toward maxmemory, so they sit on top of the limit), and for copy-on-write growth while a snapshot is being written if persistence is on. A claim that "11.44 GiB fits in 32 GiB" without that arithmetic is an invitation to an eviction storm. Cost follows the same worksheet: the 8 nodes of 8 GiB are what is billed, not the 11.44 GiB of payload, and replicas and failover reserve are the line items most often left out of the first quote. Weigh that bill against the database capacity it replaces, and note that object-size distribution matters: a few multi-megabyte values can consume more memory than millions of small ones, so size by percentile, not only by the 1.5 KiB average.

Throughput and the backend ceiling

At peak all 48,000 eligible requests do a GET first, so 48,000 GETs per second spread over 4 shards is 12,000 per shard, 48% of the hypothetical 25,000 ops/s budget. The database side is the other half: with a 90% hit rate, 4,800 eligible misses plus 12,000 non-eligible requests give 16,800 reads per second, 28% of the 60,000 no-cache baseline, a 72% backend reduction. Against a hypothetical 24,000 reads/s capacity that is 70%, below the 80% operational ceiling of 19,200. Note that a 90% hit rate on eligible traffic is not a 90% reduction in total traffic, since the 20% non-eligible requests never benefit.

Swipe to pan
Cache capacity worksheetThe Capacity Worksheet: Every Number From One Chain of Arithmeticall inputs are teaching assumptions, not measurements; measure with your engine's own toolsTraffic (peak)60,000 req/s × 80% eligible= 48,000 eligible reads/s× 90% hit = 43,200 hits/s4,800 misses + 12,000 not eligibleaverage: 12,000 → 3,360 to the DBDatabase load (peak)4,800 + 12,000 = 16,800 reads/s28% of the 60,000 no-cache load= 72% fewer database readsvs 24,000 capacity: 70% busyno cache: 2.5× capacity (overload)Memory (assumed overheads)8M × 1.5 KiB = 11.44 GiB payload× 1,640 B per entry = 12.22 GiB× 1.25 fragmentation = 15.27 GiB÷ 4 shards = 3.82 GiB (hottest 4.39)4.39 = 54.9% of 8 GiB; +replicas 30.5Shard operations (peak)48,000 GETs/s ÷ 4 = 12,000 per shardbudget 25,000/s → 48% busyhits and misses both start with a GETthe budget is a teaching input,not a benchmarkHot key (18% of eligible)8,640 req/s on one keyhot shard 18,480/s = 73.9% of 25,000other shards 9,840/s (1.88× skew)L1: 2 s TTL × 20 pods→ at most 10 shard GETs/s for the keyCache outage at peak48,000 eligible reads/s hit the backendcap: 19,200 − 12,000 = 7,200/sshed / stale / degrade: 40,800/s (85.0%)48,000 / 7,200 = 6.7× oversubscribed2,880,000 requests per minuteLatency of cache-eligible reads (illustrative: hit 3 ms, miss 85 ms)mean = 0.9 × 3 + 0.1 × 85 = 2.7 + 8.5 = 11.2 ms · p95 and p99 sit at about the miss latency (~85 ms), far above the mean.Payload-only 11.44 GiB is not the memory bill: key, metadata, fragmentation, replicas and headroom must be counted.
The capacity worksheet: memory, shard throughput and database-facing load at peak
Chapter 12

Read-Through, Write-Through and Write-Behind

Cache-aside is one placement of the logic. Three siblings move it into the cache layer, with different failure properties.

PatternWho loads or writesStrengthFailure property
Cache-asideThe application, on miss; invalidates on writeSimple, tolerant of cache outageApplication must get the race handling right
Read-throughThe cache layer calls a loader on missCentralized stampede control and loader policyCache layer now depends on the database and needs its own bounds
Write-throughWrites go through the cache to the database synchronouslyCache is fresh right after a writeWrite latency includes the database; cache and database can still diverge if one step fails
Write-behindCache accepts the write and persists laterAbsorbs bursts, low write latencyAcknowledged data exists only in memory until flushed: loss on crash
Four placements of cache logic

Write-behind deserves the longest warning. It turns the cache into the first and only copy of recent writes, so everything in chapter 8 about acknowledged loss becomes a *business* error rather than a repairable cache miss. It is a reasonable choice for low-value counters or telemetry, and an unsuitable one for orders or payments. An asynchronous buffer is not a substitute for an authoritative database write.

Write-through: the database commit succeeds and the cache update fails

Take a price change for product 4821 from $99 (v5) to $109 (v6). A write-through layer commits v6 to the database and then updates the cache. If the cache call times out or its node is failing over, the layer has two bad options. It can report failure although the database committed, which makes the caller retry a write that already happened and so requires the write to be idempotent. Or it can report success and leave the cache at v5 until the TTL ends. A sound implementation treats the database commit as the commit point, and on a cache failure deletes the key (or marks it stale) and retries that step in the background; if the retry also fails, the TTL backstop of about 360 s bounds the staleness. Reversing the order is worse: writing the cache first and the database second means a failed commit leaves a price in the cache that was never committed. No transaction spans a cache and a database, so the design has to choose which failure it prefers and bound it. Write-through also does not remove the refill race from chapter 4: a slow reader that loaded v5 before the commit can still overwrite v6, so the versioned conditional fill still applies.

Write-behind: flush ordering, crashes and what was acknowledged

Write-behind suits updates whose individual loss is tolerable, such as a view counter for product 4821. The cache layer applies each increment in memory, acknowledges, and flushes batches to the database on a timer or after a number of updates. Three details decide whether it is safe. Ordering: if two updates to one key are queued, the flusher must apply them in order or coalesce them into one value or sum, otherwise a retried older batch can overwrite newer data; that is easy while one node owns the key and harder when a failover moves it. Crash exposure: everything acknowledged but not yet flushed exists only in memory, and perhaps on an asynchronous replica. With a 1 s flush interval and 2,000 updates per second (hypothetical), a crash can lose roughly 2,000 acknowledged updates, plus whatever the replica was behind. Replay: a flush that is retried after a crash must carry a per-key sequence number so a repeated batch does not count twice. Persisting the queue in a durable log before acknowledging closes the loss window, but then the layer is a write-ahead log with its own durability cost, not a cache. Expose the unflushed backlog and the oldest unflushed update as metrics, because that age is the loss window you are carrying.

PatternWhat it can giveWhat it cannot give
Cache-asideTolerates a cache outage; the database stays the only writerFreshness without an invalidation path and a versioned fill
Read-throughStampede control and loader policy in one placeProtection of the database if the loader is unbounded; independence from the database
Write-throughA fresh cache right after a successful write on the normal pathAtomicity across cache and database, or protection from a late refill of an old value
Write-behindLow write latency, absorbed bursts, coalesced hot-key writesDurability of acknowledged writes before the flush, or read-your-writes from the database in the meantime
What each pattern can and cannot promise

Meridian uses cache-aside for products: it is the pattern that keeps working when the cache is down, which fits the principle that the cache is a derived copy. The related CQRS read-model idea is the same split at larger scale.

Chapter 13

Security and Multi-Tenant Isolation

A shared cache is a shared memory space, and the naming convention that separates tenants protects nothing if the wrong caller can ask for any name.

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.
Tenant boundaries: verified identity, ACLs, per-tenant quotas, and a leak caused by a missing tenant in the key

Cedar can be noisy; Birch is well-behaved. Isolation has layers. Identity: the tenant segment in the key comes from the verified credential, not from a header the client controls, so a request for Birch's data cannot be built by editing a parameter. Access control: the engine's ACLs and a separate credential per service restrict which key patterns and commands each client may use, and privileged commands (flush, config, key enumeration) are denied to application roles. Transport: TLS between pods and cache nodes. Quotas: per-tenant limits on memory, key count and write rate, so Cedar's burst evicts Cedar's entries first; for strict needs, a dedicated shard tier per tenant class.

Attacks specific to caches: *cache key poisoning* (crafting inputs that make a bad response get stored under a good key, or under a key that other users share); *negative-cache enumeration* (filling the store with not-found markers, hence the per-tenant cap on negative entries); *decompression bombs* (small stored values that expand enormously); unsafe deserialization. Authorization decisions are the sharpest case: a revoked permission must fail safe by re-checking the authority or consulting a versioned revocation epoch, never by serving a stale allow. Never cache a 401 or 403 as a not-found for other identities.

  • Put verified tenant and schema version into every key, and enforce ACLs on the server.
  • Cap negative entries per tenant, and keep identity out of shared negative entries.
  • Keep secrets and payment instruments out of the shared cache entirely.
  • Trusting naming conventions as isolation.
  • Letting application roles run key-listing or flush commands.
Chapter 14

Observability: What to Watch, by Key Class

A global hit rate averages away the failures that matter; the cache needs measurements split by key class, by shard, and by tenant.

SignalWhy it mattersTypical action
Hit and miss rate by key classA falling C2 hit rate points at invalidation or eviction; a stable global rate can hide itInvestigate keys, TTLs, evictions
Evictions and expired countsEvictions mean memory pressure; expiry spikes mean synchronized TTLsResize, add jitter
Used memory vs limit, fragmentationMemory is not payload (chapter 11)Plan capacity, investigate allocator
Command latency p50/p99, shard CPUOne slow shard raises the tail of every batched readLook for hot keys or big values
Replication lag, failover countBounds the loss window at failoverAlert beyond the lag budget
Hot-key concentrationTop key share of traffic by shardEnable L1 or copies
Database fallback QPSHow much load the cache is shieldingAlert before the 7,200/s fallback cap is approached
Stale-hit rateFraction of hits older than the freshness budget, found by sampled comparison with the authorityTune TTL and invalidation
A cache dashboard, grouped by purpose

The fallback figure comes from the outage math: with the cache fully down at peak, up to 48,000 eligible requests per second go to the backend, or 2,880,000 in one minute. The database's planned ceiling of 19,200/s minus 12,000 non-eligible leaves a 7,200/s fallback cap, so 40,800/s (85.0%) of eligible requests must be shed, served stale from an allowed class, or degraded — 6.7 times oversubscribed. Shedding and breakers are decided *before* the incident (load shedding, circuit breaker).

Error budgets should be written per class: for example, "price staleness beyond 360 s: zero tolerated, alert on any". Distinguish cache-side hit latency, API latency, backend QPS reduction and customer latency; they are four different numbers.

Chapter 15

Invariants and Guarantees You Must Not Claim

Everything above reduces to a short list of statements the design keeps true, and a list of phrases it must not use.

  • The cache is a derived, best-effort copy. Checkout, inventory reservation and authorization use authoritative validation.
  • A hit is not proof of freshness, completeness or permission. A TTL bounds staleness; it does not synchronize.
  • Ownership is deterministic and topology-versioned, and modulo, rings and slots are different mechanisms.
  • A hot key is not solved by sharding; L1 or copies are used, with their staleness stated.
  • Failover can lose acknowledged writes; repair relies on revalidation.
  • Atomicity is per command per node; ambiguous timeouts need tokens or reads.
  • Database fallback is bounded by breakers, queues and shedding.
  • Memory is more than payload; negative caching is capped; tenant isolation needs verified identity and ACLs.
  • Cross-region invalidation has delay and can fail.
Do not claimWhat to say instead
Zero stale readsStaleness bounded by event delay and a TTL backstop of at most 360 s for price
Exactly-once invalidationAt-least-once delivery with an idempotent subscriber
Redis Cluster is consistent hashingRedis Cluster uses 16,384 fixed hash slots
Redis Cluster never loses acknowledged writesWindows exist where acknowledged writes can be lost
Memcached replicatesMemcached has no built-in replication; clients distribute keys
Guaranteed sub-millisecond latencyIllustrative 3 ms hit; measure your own
No downtime / infinite availabilityBounded degradation with defined fallbacks
Statements that sound reassuring and are false or unsupported
Key takeaways
  1. 01The database is the authority; the cache is a derived copy that may be stale, evicted, lost on failover or cold, and the design stays correct because of that, not despite it.
  2. 02Make writes commit first and derive invalidation from the commit; fix the refill race with versions from the authority or state the TTL bound.
  3. 03Choose the placement scheme deliberately: modulo remaps N/(N+1) of keys, rings move about 1/(N+1), Redis Cluster moves explicit slots among 16,384.
  4. 04Replication is asynchronous in the described engines; an ack does not mean the replica has the write.
  5. 05Size memory from payload through overhead, fragmentation, hot shards and replicas, and bound the database fallback before an outage.
  6. 06Measure by key class and name the bound in numbers; every figure on this page is illustrative.