Skip to main content
PProfileMatch

MediumProblem 12 of 36 in the system design series

Design a Distributed Cache: Consistent Hashing, LRU and Hot Keys

Cover illustration: Design a Distributed Cache: Consistent Hashing, LRU and Hot Keys

A distributed cache is an in-memory key-value store spread across many machines that answers reads and writes in roughly a millisecond. Systems in the Redis or Memcached family sit in front of slower databases so that frequently requested data can be served from RAM instead of disk, which cuts latency and shields the database from most of the read traffic.

Interviewers like this question because it strips away product details and tests fundamentals directly. You have to decide how keys are spread across nodes so that resizing the cluster does not wipe out the cache, what to discard when memory fills up, and how the system keeps serving when a machine dies or a single key suddenly becomes enormously popular. Each of those decisions has a classic wrong answer and a well-understood better one, and the interviewer wants to hear you reason from one to the other.

This is an interview practice design for a Distributed Cache-like service, built from first principles. It is not a description of how Distributed Cache is actually built.

Functional Requirements

The interface is intentionally small. Clients can store a value under a key and fetch it back later, and they can delete a key explicitly, typically when the underlying record in the database has changed and the cached copy is now wrong.

Every key can also carry a time to live so it disappears on its own after a chosen duration, which bounds how stale cached data can get. Finally, when memory runs out, the cache must make room by itself, discarding the keys that were used least recently. Persistence to disk, multi-key transactions and rich data structures are excluded; they matter for a full data store but distract from the core distribution problem.

Get and set

Store a value by key, and read it back quickly.

GET · SET

Delete

Remove a key on demand, for example when the data changes.

DEL

Expiry (TTL)

Each key can expire automatically after a set time.

time to live

Eviction when full

When memory runs out, drop the least useful keys.

LRU

  • Out of scope today: disk persistence, transactions, rich data types

Non-Functional Requirements

Latency is the whole point. Reads and writes should complete in about 1 ms, because a cache that is not dramatically faster than the database is not worth operating. The system must scale horizontally, so that adding machines increases both memory and throughput without redesign.

It also needs to be highly available: losing one node should degrade capacity slightly, not take the cache offline and send every request to the database at once. To get that availability, we accept eventual consistency between a node and its replica. A replica that trails its primary by a few milliseconds is a reasonable price for a cache, since the database remains the source of truth.

Very low latency

Reads and writes in about a millisecond.

≈ 1 ms

Scales horizontally

Add nodes to get more memory and more throughput.

add nodes

Highly available

One node failing must not take the cache down.

replicas

Eventual consistency

A replica can lag for a moment. For a cache, that's fine.

availability first

Scale Estimation

Assume we want to hold about 1 billion keys averaging 1 KB each, roughly 1 TB of hot data. At peak the cluster serves around 1 million requests per second, and the overwhelming majority of them are reads rather than writes.

If each node offers about 64 GB of usable memory, 1 TB requires 16 primary nodes. Adding one replica per primary brings the cluster to 32 nodes. Spreading 1 million requests over 16 primaries gives each one about 62K operations per second, comfortably within what a single in-memory node can serve. The estimate makes an important point for interviews: memory capacity, not CPU, determines how many machines the cache needs, so sizing conversations should start from data volume.

1TB

Hot data to cache

1 billion keys × 1 KB

1million

Requests per second

peak · mostly reads

32nodes

Nodes (64 GB each)

16 primaries + 16 replicas

62K / s

Ops per primary

1M / 16 · easy for one node

Memory decides the node count, not CPU

Core Entities & API

The entities are the Key, the Value with its optional TTL, the Cache node that stores a slice of the data, and the Hash ring, which decides which node owns any given key. The ring is treated as a first-class entity because every client depends on having an up-to-date copy of it.

For clarity the operations are shown as REST: GET /cache/{key} returns the value or a 404 on a miss, PUT /cache/{key} stores a value with an optional ttl_seconds, and DELETE /cache/{key} removes it with a 204. A production cache would not use HTTP, though. It would speak a compact binary or text protocol over persistent TCP connections, similar in spirit to Redis's RESP, because at millisecond budgets the overhead of HTTP headers and parsing becomes significant.

Core entities

KeyValue + TTLCache nodeHash ring

API

  • GET /cache/{key}→ value, or 404 on a miss
  • PUT /cache/{key} { value, ttl_seconds? }→ 200 OK
  • DELETE /cache/{key}→ 204 No Content

High-Level Design

Application servers talk to the cache through a client library that does the routing itself. It hashes each key, determines which node owns that portion of the key space, and sends the request directly there. This smart-client approach avoids a proxy hop on every operation.

Each primary node has a replica that receives a stream of its writes and can take over if the primary fails. A configuration service such as etcd or ZooKeeper tracks node health and publishes the current ring map, which every client keeps locally and refreshes when membership changes.

Reads follow the cache-aside pattern: on a miss, the application loads the record from the database and writes it into the cache for subsequent requests. Nodes do not coordinate with each other on ordinary reads and writes, so capacity grows almost linearly as machines are added.

hash(key) → Breplicatering mapApp serveryour serviceCache clienthashes the keyNode Aowns part of ringNode Bowns part of ringNode Cowns part of ringReplica Acopy of AReplica Bcopy of BReplica Ccopy of CConfig serviceetcd · ring + health
High-level design. Dashed arrows are taken only on a cache miss or as a fallback. Scroll sideways on small screens.

Cache-aside: miss → read the database → fill the cache

Deep Dive 1 — Consistent Hashing

The simplest placement rule is hash(key) mod N. It spreads keys evenly, but changing N remaps almost every key. Adding a single node to a 16-node cluster would make nearly the whole cache miss at once, and the resulting flood of database reads could take the database down. A central directory that records each key's location allows arbitrary placement, but it adds a lookup hop to every request and becomes a large, hot system of its own.

Consistent hashing avoids both problems. Nodes and keys are hashed onto the same circular space, and a key belongs to the first node found moving clockwise from its position. A newly added node takes over only the arc between itself and its predecessor, so with N nodes roughly 1/N of keys move; with five nodes, about a fifth.

A handful of points per server would leave arcs of very different sizes and uneven load. Giving each server around 150 virtual nodes scattered across the ring evens out ownership and spreads the work of rebalancing across many peers. Consistent hashing with virtual nodes is the choice, and the extra client logic is a small price.

hash(key) mod N

  • + Simple, even spread
  • − Add a node → most keys move
  • − Huge miss storm on resize

Lookup directory

  • + Flexible placement
  • − Extra hop on every request
  • − Directory must scale too

Consistent hashingChosen

  • + Only ≈ 1/N keys move
  • + No central lookup
  • − A bit more client logic
  • Ring: hash nodes and keys onto a circle · walk clockwise to the first node
  • ≈ 150 virtual nodes per server → even load, smooth rebalancing

Deep Dive 2 — Eviction & Expiry

Least recently used eviction can be implemented in constant time with two structures working together. A hash map points each key to its entry, giving O(1) lookup. A doubly linked list threads every entry in order of recency, with the most recently touched entry at the head and the stalest at the tail.

Every read moves the accessed entry to the head of the list, which takes only a few pointer updates because each node knows both of its neighbors. When a write arrives and memory is full, the entry at the tail is removed, since it has gone untouched the longest. In Java, LinkedHashMap constructed with access ordering and an overridden removeEldestEntry gives exactly this behavior in a few lines.

Expiry is handled two ways. Each read checks the entry's TTL and treats an expired key as a miss, and a background task periodically samples random keys and deletes those that have expired, so untouched keys do not linger. At very large scale, a Redis-style approximate LRU that samples a few keys and evicts the oldest saves memory over maintaining a perfect list.

  1. 1Hash mapKey → entry in O(1)
  2. 2Doubly linked listEntries ordered from newest use to oldest
  3. 3On every GETMove the entry to the head of the list
  4. 4On SET when fullEvict from the tail: least recently used
  5. 5Expire TTLsCheck on read + a background sampler
  • Java: LinkedHashMap(cap, 0.75f, true) + removeEldestEntry() = an LRU cache
  • Redis: approximate LRU — sample a few keys, evict the oldest

Deep Dive 3 — Replication & Hot Keys

Replication is asynchronous: the primary acknowledges a write immediately and streams it to its replica, which is typically only milliseconds behind. That keeps write latency low. When a primary stops sending heartbeats, the configuration service promotes its replica and distributes an updated ring map so clients redirect traffic within seconds.

Hot keys need separate treatment. Picture a celebrity profile receiving a million reads per second, all landing on one node. The first defense is a tiny in-process cache on each application server holding that value for a second or two. The second is to write several suffixed copies, such as key:1 through key:N, which hash to different nodes, and have readers choose one at random so the load is shared.

Finally, when a hot entry expires, thousands of requests can miss at the same moment and stampede the database. Allowing a single request to rebuild the value while the rest wait for its result keeps that spike contained.

  • Asynchronous replication

    The primary acks first; replicas catch up within milliseconds.

  • Automatic failover

    Missed heartbeats → the config service promotes a replica.

  • Hot key: local in-process cache

    Keep the hottest keys in app memory for a second or two.

  • Hot key: spread copies across nodes

    Write key:1 … key:N on different nodes; read a random copy.

  • Stop the stampede

    On a miss, one request rebuilds the value; the others wait for it.

Trade-offs

Asynchronous replication keeps writes fast, but a failover can drop the last few writes the replica had not yet received; for a cache that is usually tolerable. A smart client removes the proxy hop, at the cost of every client needing to track the ring correctly. Approximate LRU uses less memory and CPU than an exact list, though it will not always evict the single oldest key.

TTL length is a dial between freshness and load. Long TTLs raise hit rates but serve staler data, while short TTLs keep data fresher and send more traffic to the database. The overall approach: place keys with a ring, evict with cheap bookkeeping, keep a replica for every primary, and spread out any key that gets too hot.

  • Async vs sync replication

    Fast writes, but a failover can lose the last few writes.

  • Smart client vs proxy

    No extra hop, but every client must know the ring.

  • Approximate LRU

    Cheaper on memory and CPU, but not perfectly exact.

  • Long vs short TTL

    Long: more hits but staler data. Short: fresher data, more database load.

Hash smart. Evict cheaply. Replicate, and spread the hot keys.

Key takeaways

  • Size the cluster from memory: about 1 TB of hot data means 16 primaries plus 16 replicas at 64 GB each.
  • Consistent hashing with around 150 virtual nodes per server moves only about 1/N of keys on resize.
  • LRU in O(1) is a hash map plus a doubly linked list; approximate LRU saves memory at scale.
  • Replicate asynchronously and let a config service promote replicas and publish the new ring map.
  • Defuse hot keys with local in-process caching, suffixed copies across nodes and request coalescing.

Interview questions

What is consistent hashing and why do distributed caches use it?

Consistent hashing places both nodes and keys on a circular hash space and assigns each key to the next node clockwise. When a node is added or removed, only the keys on its neighboring arc move, roughly 1/N of the total, instead of almost all keys as with modulo hashing. Virtual nodes spread each server across the ring to keep load even.

How do you implement an LRU cache in O(1) time?

Combine a hash map with a doubly linked list. The map gives constant-time lookup from key to list node, and the list keeps entries ordered by recency. On each access, move the node to the head; when the cache is full, remove the tail. In Java, a LinkedHashMap with access order and removeEldestEntry implements the same idea.

How do you handle a hot key in a distributed cache?

Cache the value briefly in each application server's memory so most reads never reach the cluster. For extreme traffic, store several copies under suffixed keys that hash to different nodes and read one at random. Also guard against stampedes when the key expires by letting a single request rebuild the value while other requests wait for that result.

What happens when a cache node fails?

Each primary has a replica receiving its writes asynchronously. A configuration service such as etcd notices missed heartbeats, promotes the replica, and pushes an updated ring map to clients, which then route requests to the new primary. A few recent writes may be lost, which is acceptable for a cache because the database still holds the authoritative data.

How many nodes does a 1 TB distributed cache need?

Start from memory. With about 64 GB usable per node, 1 TB needs 16 primaries, and one replica each brings the total to 32 nodes. At an assumed peak of 1 million requests per second, each primary handles around 62K operations per second, well within a single node's capacity, so memory rather than CPU sets the cluster size.

Keep practising