Designing a cache that spans many nodes
Open in the interactive app: practice, quiz, mock interview →In one minute
- A distributed cache spreads one logical key-value cache across many servers, because the hot data no longer fits in one machine's memory, CPU or network card.
- The central design choice is where routing lives: inside the client library, in a proxy, or in the servers themselves (Redis Cluster's hash slots with redirects).
- Replication in a cache buys availability and read capacity, not durability: replicas are asynchronous, and a failover can silently drop recent writes.
- Partitioning turns one request into many: a page that reads 40 keys may touch every shard, so tail latency and connection counts grow with the cluster.
- The database behind the cache is the real capacity limit. Design for what happens when a shard, a zone or the whole cache is empty.
How it works
Why one box stops being enough
A single cache node runs out of something: memory (a 1.5 TB working set does not fit in a 128 GB machine), throughput (Redis executes commands on one thread, roughly 100,000 to 200,000 simple operations per second without pipelining), network (a 25 Gbit/s NIC moves about 3 GB/s), or blast radius (when the only node restarts, every read hits the database).
This lesson is about the architecture once you split. How entries get in and out (cache-aside, write-through) and what to evict are separate topics; Consistent Hashing, Hot Keys and Design a Distributed Cache go deeper on placement, skew and a full design.
Three places to put the routing
Client-side sharding. Each application process holds the server list and hashes keys to servers itself. Memcached has always worked this way: the servers do not know about each other. It is one hop, but every language needs an identical hashing library, and processes with different configs disagree about where keys live.
A routing proxy. Applications talk to a local or shared proxy (Twemproxy, Facebook's mcrouter, Envoy's Redis proxy) that owns the pool layout. Mcrouter expresses policy as composable route handles: PoolRoute for a sharded pool, AllSyncRoute to write to several replicas, FailoverRoute to try a backup pool, WarmUpRoute to fill a cold pool from a warm one. It costs a hop of tens of microseconds and a fleet to run, but layout changes need no application deploys.
Server-side clustering. In Redis Cluster the nodes agree among themselves who owns which of 16,384 hash slots. A client may send a command to any node; a node that does not own the slot replies MOVED <slot> <host:port>, and a smart client caches the slot map so it usually goes straight to the right node. This avoids a proxy while keeping one source of truth, at the cost of smarter clients. Managed services often let you pick: Azure Managed Redis offers an OSS clustering policy, where clients follow MOVED and ASK themselves, and a proxy-based policy behind a single endpoint that costs a little routing overhead (Azure caching guidance).
Replication and failure
Cache replicas serve two purposes. For availability, a replica in another zone is promoted when its primary dies, so the shard does not go cold. For read capacity, reads of hot keys spread across replicas. Both are asynchronous in Redis, so a replica can serve a value the primary has already overwritten, and a promotion can lose acknowledged writes; the cluster specification says there is always a window in which writes can be lost during partitions (Redis cluster spec). That is acceptable for a cache only because the database still holds the truth.
Memcached has no replication of its own. Facebook's answer was the gutter pool: about 1% of servers that take over the keys of any failed server with short TTLs (Facebook NSDI 2013), so a dead node causes a burst of short-lived entries instead of a burst of database reads. The general lesson is to decide explicitly what a failed shard's traffic hits next.
Multi-key reads and fan-out
A product page that needs 40 keys issues one multiget per shard involved. With 16 shards, 40 random keys touch about 15 of them, and the page waits for the slowest. Adding shards makes this worse, not better: more shards means more round trips per page for the same work. The fix is to co-locate keys that are read together. Redis Cluster supports this with hash tags: only the part of the key between the first { and the next } is hashed, so {user:42}:profile and {user:42}:cart land in the same slot and can be fetched, or updated atomically, in one command.
This is the fan-out problem shape; it reappears when a search query scatters to 50 index shards, or an agent waits on ten parallel tool calls.
Near caches
A small in-process L1 in front of the distributed L2 removes the network hop for the hottest keys and shields their shard. The cost is a weaker consistency layer: invalidations must reach every process, or the L1 uses TTLs of seconds.
Down the stack
Layer 1The contract
A distributed cache promises much less than a database. Any GET may miss, even immediately after a SET, because of eviction, a restart or a failover. Writes are acknowledged by the primary before replication, so a failover can discard them. Reads from replicas can be stale by the replication lag, usually milliseconds but unbounded under load. Atomic multi-key operations exist only within one slot; Redis rejects a multi-key command spanning slots with CROSSSLOT. The Redis cluster specification says plainly that it does not guarantee strong consistency, so correct code treats every answer as a hint and every miss as normal.
Layer 2The mechanism
Redis Cluster maps a key to CRC16(key) mod 16384 using the XMODEM CRC (polynomial 0x1021), after applying the hash-tag rule. Each node keeps the full slot table and gossips PING and PONG messages on a cluster bus at the client port plus 10,000 (or cluster-port) (Redis cluster spec), carrying its view of slot ownership and a configuration epoch that resolves conflicting claims. A node that does not answer for cluster-node-timeout (15,000 ms by default) is flagged PFAIL (possibly failing); when a majority of primaries report it within twice the node timeout, it becomes FAIL, and one of its replicas wins an election and takes over its slots with a higher epoch.
Resharding moves one slot at a time. The source marks the slot MIGRATING and the target IMPORTING, and keys move with MIGRATE. During the move, a key already moved produces ASK <slot> <target> from the source. The client sends ASKING and then the command to the target, but does not update its slot map, because the slot still belongs to the source until the move finishes. Only MOVED changes the map. Clients refresh the whole map with CLUSTER SHARDS (or the older CLUSTER SLOTS). A connection that sends READONLY to a replica may read the slots that replica serves.
Key-by-key migration is no longer the only way. Redis 8.4 added CLUSTER MIGRATION IMPORT, run on the target, which copies whole slot ranges as a snapshot plus a replication stream; when the remaining lag drops under cluster-slot-migration-handoff-max-lag-bytes (1 MB by default), the source pauses writes, for at most 10 seconds by default, and ownership flips in one step (Redis docs). Valkey 9.0 shipped the same idea as CLUSTER MIGRATESLOTS in October 2025: the source keeps every key until the whole slot has moved, so there is no half-migrated slot for multi-key commands to trip over and big collections stream item by item instead of as one huge MIGRATE payload (Valkey). Clients still need to handle ASK, because older servers and tools use the classic path.
Layer 3The machine
A GET of a 1 KB value costs the Redis thread a few microseconds: parse the RESP request, one hash table lookup, copy into the output buffer, one write. Within an availability zone a round trip is about 100 to 300 microseconds; across zones it is closer to 1 ms, and AWS bills cross-zone traffic, including to ElastiCache, at $0.01 per GB in each direction (AWS EC2 pricing). So network, not CPU, dominates, and the levers are fewer round trips (pipelining and multiget turn 40 reads into one write and one read syscall per shard) and fewer bytes.
Connections are the hidden cost: a thousand application servers with 4 connections to each of 32 nodes is 128,000 TCP connections, each with kernel socket buffers and a Redis client struct. Memory has traps too: allocator fragmentation after churn in value sizes, and an RDB snapshot fork() that shares pages copy-on-write, so a write-heavy node can briefly need close to double its memory.
Why it's built this way
- RAM per machine and packets per core are fixed, so a large hot set must be partitioned.
- Partitioning needs a stable key-to-node map that survives membership changes, so the cluster uses a fixed slot space (or a hash ring) and moves slots, not keys one by one.
- Moving data while serving traffic means some keys are temporarily in two places, so the protocol separates a one-off redirect (
ASK) from a permanent one (MOVED). - Synchronous replication would double write latency for data the database already holds, so replicas are asynchronous and the contract admits loss.
Diagram
sequenceDiagram participant C as Client participant A as Node A (slots 0-8191) participant B as Node B (importing 866) C->>A: GET hello (slot 866) A-->>C: ASK 866 B C->>B: ASKING C->>B: GET hello B-->>C: value Note over C: slot map unchanged Note over A,B: migration finishes, B owns 866 C->>A: GET hello A-->>C: MOVED 866 B Note over C: update slot 866 to B C->>B: GET hello B-->>C: value
Worked example
A catalogue service needs 800,000 reads per second at peak over 1.2 TB of hot values averaging 1 KB.
Memory. Keys, hash table entries and allocator overhead add roughly 25%, so plan for 1.5 TB. Nodes with about 100 GB usable each need 15; use 16 primaries, 1,024 slots each.
Throughput. 800,000 / 16 is 50,000 reads per second per primary, comfortably inside one Redis thread, and 50 MB/s of values per node is a small fraction of the NIC.
Replicas and zones. One replica per primary in a second zone gives 32 nodes. If clients read from whichever copy is in their own zone, no read crosses zones. If instead half the reads cross zones, that is about 400 MB/s, roughly 34,500 GB per day, which at $0.01 per GB in each direction costs about $690 per day, or $250,000 per year, for nothing.
Fan-out. A product page reads 40 keys. Spread randomly, they touch about 15 of the 16 shards. If each shard's p99 is 2 ms, the chance that a page hits at least one p99 response (The Tail at Scale) is 1 - 0.99^15, about 14%, so the page's p90 is already above the shard's p99. Grouping a product's keys under one hash tag, {product:981}, collapses the page to one or two shards.
Failure. A primary dies; detection takes 15 seconds plus an election. Meanwhile 50,000 reads per second for its slots fall through to a database sized for 20,000. Decide in advance: read from the replica, shed, or use a gutter pool.
In the wild
- Discord, 2017: a correct Redis failover, an incorrect client. A Google Cloud live migration knocked a Redis primary offline. The cluster failed over as designed, but API instances hit a known bug in their failover handling, and the outage compounded with a bad edge-cache rule, a Cassandra failure and other services running out of memory. It lasted about 2 hours 51 minutes and ended with a full restart that reconnected millions of clients over about 20 minutes. The failover path in the client is part of the cache architecture, and needs testing like the server (Discord).
- GitHub, 2018: 1.35 Tbps from exposed memcached servers. Attackers spoofed GitHub's address in UDP requests to memcached servers open on the internet, which answered with responses up to about 51,000 times larger. Traffic peaked at 1.35 Tbps and 126.9 million packets per second. GitHub was down for about five minutes and intermittently unavailable for four more, until traffic was routed through Akamai's scrubbing (GitHub).
- Slack, 2022: the cache control plane worked, and the database still fell over. During a Consul agent rollout, Slack's cache control plane replaced deregistered memcached nodes with empty spares, exactly as designed. Each miss on group DM membership became a query to every Vitess shard, because the table was sharded by user but read by channel, and retries kept the system overloaded. Fixes included re-sharding by channel and throttling client boots (Slack).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Client-side sharding | One hop, no extra infrastructure | Identical hashing in every language, config skew | Few languages, simple pools (classic Memcached) |
| Routing proxy | Central policy, replication, failover and warm-up without app changes | Extra hop, proxy fleet to operate | Many services and languages, frequent pool changes |
| Server-side cluster | One source of truth, online resharding | Smart clients, redirects, cross-slot limits | Redis at scale, keys that need co-location |
| Read from replicas | More read capacity, survives a primary loss | Stale reads, more memory | Hot keys, read-heavy traffic |
| Near cache (L1) | No network hop for the hottest keys | Invalidation to every process, more staleness | Read-mostly keys with tolerant staleness |
Pitfalls in production
- The database sized for a warm cache. A cache restart or a lost zone sends every miss to a database sized for a 95% hit rate. Know the database's capacity at 0% hit rate, and add warm-up, request coalescing and load shedding.
- Slot map refresh storms. After a failover, thousands of clients receive
MOVEDand all callCLUSTER SHARDSat once. Rate-limit map refreshes per client and apply individualMOVEDreplies directly. - Unbounded fan-out. A multiget across every shard makes page latency equal to the cluster's worst node. Co-locate keys with hash tags and cap per-request fan-out.
- Big values and big keys. A 5 MB value or a hash with a million fields blocks the single thread while it is serialised or deleted. Split them, and delete large keys with
UNLINK, which frees memory in the background. - Treating the cache as durable. Sessions or counters stored only in the cache vanish on failover. Anything you cannot recompute belongs in a database.
- Cache ports open to the internet. Memcached once listened on UDP by default, and exposed servers became 51,000x DDoS amplifiers in 2018; version 1.5.6 turned UDP off by default (memcached release notes). Keep cache nodes on private networks with authentication on.
Leadership lens
- Ask "what does the database see when this cache is empty?" in every design review. If the answer is an outage, the cache is a hidden availability dependency and needs an owner and a runbook.
- Decide between proxy, client sharding and cluster mode once per organisation. Mixed approaches mean every team re-learns failover, and incidents are debugged in three ways.
- Track cost per cached GB and per million reads, including cross-zone traffic. Zone-aware reads are often the cheapest win available.
Check yourself
Why does a Redis Cluster client update its slot map on
MOVEDbut not onASK?Show answer
MOVEDmeans the slot now permanently belongs to another node.ASKhappens mid-migration: only some keys have moved, the source still owns the slot, so the redirect applies to that one request.You double the number of cache shards to cut latency, and page p99 gets worse. Why?
Show answer
Pages that read many keys now fan out to more shards, so each page waits for the slowest of more round trips. Throughput per shard dropped, but tail latency is driven by fan-out.
Why is asynchronous replication acceptable for a cache when it would not be for a payments database?
Show answer
The cache is not the source of truth. A lost write produces a miss or a stale value that TTLs and invalidation repair, while the database still holds the correct data.
Sources and further study
Primary
- Karger et al., Consistent Hashing and Random Trees (STOC 1997): the original distributed-caching paper behind hash rings and minimal key movement.
Systems docs
- Redis CLUSTER MIGRATION: atomic slot migration in Redis 8.4, its handoff threshold and write-pause timeout.
- Introducing Valkey 9.0: atomic slot migration, multiple databases in cluster mode and throughput changes.
- Caching guidance (Azure Architecture Center): a managed Redis with both a cluster-aware and a proxy routing policy, and layered local plus shared caches.
Books
- Designing Data-Intensive Applications (1st ed.), Martin Kleppmann, chapter 6 "Partitioning": fixed partition counts, rebalancing and request routing, the same choices a cache cluster makes.
- Designing Distributed Systems (2nd ed.), Brendan Burns, chapter 7 "Sharded Services": sharded caching, sharding functions and hot shards.
- Understanding Distributed Systems (2nd ed.), Roberto Vitillo, chapter "Caching": local versus external caches and what an external cache's failure does to the service behind it.
Other curricula
- donnemartin/system-design-primer: places Memcached and Redis as application caches and moves on; it does not cover routing, replication or slot migration.
- Caching challenges and strategies (AWS Builders' Library): argues external caches avoid cold starts on deploy but add a fleet that can fail, and pairs them with in-memory fallbacks.
Watch
- NSDI '13 - Scaling Memcache at Facebook (USENIX): pools, gutter servers, regional replication and mcrouter's ancestors.
- Lecture 16: Cache Consistency: Memcached at Facebook (MIT 6.824): the same system examined for its consistency trade-offs.
Free resources
- Redis Cluster specification: slots, hash tags,
MOVED,ASK, gossip and failover in the authors' words. - Scaling Memcache at Facebook (NSDI 2013): the classic paper on running cache fleets at scale.
- mcrouter route handles: how a routing proxy composes sharding, replication and failover.
- A large scale analysis of hundreds of in-memory cache clusters at Twitter (OSDI 2020): real workload data on sizes, TTLs and skew.
- The Tail at Scale (Google Research): why fan-out makes rare slow responses common.
- EVCache (Netflix): a client-sharded, zone-replicated Memcached deployment you can read.