Consistent Hashing
Markers: โญ MUST KNOW โ core answer, asked directly. ๐ฏ FOCUS โ high-value senior-loop follow-up. ๐ก GOOD TO KNOW โ depth/impressor, recognize + one-liner is enough.
1. The Problem It Solves โญ MUST KNOW
- Naive approach:
server = hash(key) % N. - Breaks the moment
Nchanges. Modulus changes from N to N+1, so almost every key's assignment changes. - Real-world impact: cache layer โ mass cache misses โ thundering herd on the DB. Partitioned DB โ massive unnecessary rebalancing.
- Desired property: when adding/removing one node out of N, only ~
1/Nof keys should move. Everything else stays put. Naive modulo hashing gives none of this โ it's all-or-nothing.
One-liner: Modulo remaps ~(N-1)/N of keys on scale change; consistent hashing remaps ~1/N. That's the whole point.
2. The Core Idea โ a Ring โญ MUST KNOW
- Pick a hash function with a large output range (32-bit/64-bit). Treat that range as a circle that wraps around (the "ring").
- Hash each server identifier (IP, hostname, node ID) onto the ring โ each server owns one point.
- To find a key's owner: hash the key onto the same ring, then walk clockwise to the first server hit. That server owns the key.
The question shifts from "which bucket out of N" to "which server point is nearest clockwise from this key."
Why this fixes rebalancing: add a server โ only keys between the new node and its clockwise neighbor get reassigned, every other server untouched. Remove a server โ only its keys move, to the next clockwise server. Locality on the ring = locality of impact.
3. Virtual Nodes (the essential fix) โญ MUST KNOW
- Problem: hashing each physical server to ONE point gives wildly uneven arcs by luck of the hash โ one server might own 40% of the ring, another 5%. Worse: removing a server dumps all its load onto exactly one neighbor.
- Fix: hash each physical server to many points (~100โ200, or 8โ256 in modern Cassandra) via
hash(server_id + ":0"),hash(server_id + ":1"), etc. Each physical server owns many small scattered arcs โ load averages out, and on failure the load spreads across many others instead of dumping onto one neighbor. - Trade-off: more vnodes โ better balance, but more memory for ring metadata and slower lookups.
4. Replication on the Ring ๐ฏ FOCUS
Simple case (no vnodes): for replication factor N=3, walk clockwise from the key, take the first 3 distinct servers.
With virtual nodes (the subtlety): a physical server appears many times on the ring. The replica walk is not "first N points clockwise" โ it's "walk clockwise, take the first N points that map to N distinct physical machines, skipping any vnode whose owner is already counted." Topology-aware systems (Cassandra's NetworkTopologyStrategy) add constraints: distinct rack / distinct AZ, not just distinct server. On write, if a target replica is down, the walk continues to the next distinct server as a temporary stand-in (hinted handoff), reconciled later.
Mental model: the ring gives an ordering; replication = "first N distinct things you care about in that ordering," where "things" = machines/racks/AZs depending on topology sophistication.
5. Real-World Implementations ๐ก GOOD TO KNOW
Know Cassandra/Dynamo (ring + vnodes) and Ketama cold; the rest โ one-line recognition is enough.
- Cassandra / DynamoDB โ
Murmur3Partitionerhashes keys/tokens into 64-bit space;num_tokens= vnodes. - Ketama (libketama) โ original memcached client sharding: sorted array of
(hash, server)+ binary search. The reference answer for "implement consistent hashing." - Maglev (Google) โ table-based alternative (~65537 slots), O(1) lookup, tighter balance, costlier rebuild on membership change.
- Rendezvous / Highest Random Weight (HRW) โ
hash(key, server_id)per server, pick the highest score. No ring, no vnodes. O(N) lookup vs O(log N) for a ring โ good for small/stable server counts. - Bounded-load consistent hashing โ caps how far above average any server's load can go; keys skip forward past an overloaded owner.
6. Implementation โญ MUST KNOW
Core coding task: implement a ring with add_server(id), remove_server(id), get_server(key).
import bisect, hashlib
class ConsistentHashRing:
def __init__(self, vnodes=100):
self.vnodes = vnodes
self.ring = {} # hash -> physical server
self.sorted_hashes = []
def _hash(self, s):
return int(hashlib.md5(s.encode()).hexdigest(), 16)
def add_server(self, server_id):
for v in range(self.vnodes):
h = self._hash(f"{server_id}:{v}")
self.ring[h] = server_id
bisect.insort(self.sorted_hashes, h)
def remove_server(self, server_id):
for v in range(self.vnodes):
h = self._hash(f"{server_id}:{v}")
del self.ring[h]
self.sorted_hashes.remove(h) # O(n) โ fine for interview
def get_server(self, key):
h = self._hash(key)
idx = bisect.bisect(self.sorted_hashes, h) % len(self.sorted_hashes)
return self.ring[self.sorted_hashes[idx]]
Escalating follow-ups (in the order they tend to come): (1) "What if only one vnode per server?" โ load skew, introduce vnodes. (2) "How do you pick N replicas?" โ skip-duplicate-physical-owner walk. (3) "Complexity of add/remove/lookup, faster remove?" โ ยง7. (4) "Concurrency?" โ ยง8. (5) "A specific key is hot?" โ ยง9. (6) "Compare to rendezvous hashing." โ O(log N) vs O(N), no vnode tuning needed.
7. Complexity โ Making Remove Faster Than O(n) ๐ฏ FOCUS
| Structure | Lookup | Insert | Delete |
|---|---|---|---|
Sorted array (bisect) | O(log n) | O(n) | O(n) |
Balanced BST (TreeMap) | O(log n) | O(log n) | O(log n) |
| Skip list | O(log n) exp | O(log n) exp | O(log n) exp |
| Sorted array + batch rebuild | O(log n) | O(n) amortized | O(n) amortized |
Balanced BST / TreeMap is what Cassandra actually uses (TokenMetadata โ TreeMap<Token, InetAddressAndPort>, a red-black tree): floorKey()/ceilingKey() give O(log n) clockwise-walk lookup, put()/remove() are O(log n) via rotations, not array shifts. Skip lists are easier to make concurrent (no rotations) โ Redis uses them for sorted sets. Sorted array + batch rebuild is a legitimate real-world choice when ring membership changes are rare (administrative) โ eating an O(n) rebuild amortized over millions of O(log n) lookups is fine; reach for BST/skip-list only when membership churns often (elastic autoscaling).
The expected upgrade from the
bisectversion: "TreeMap / balanced BST โceilingKey()for the clockwise walk, O(log n) everything."
8. Concurrency โ Adds/Removes vs In-Flight Lookups ๐ก GOOD TO KNOW
Only comes up in senior/staff loops. Answer = copy-on-write immutable snapshot; memorize the phrase and why readers never block.
- Ring is immutable โ lookups only read whatever snapshot reference they hold, no mutation.
- On add/remove: build an entirely new ring object (takes as long as needed, nothing blocked).
- Atomically swap a single pointer from "current ring" to "new ring."
- In-flight lookups finish safely against the old, still-consistent ring; new lookups see the new ring.
Relies on atomicity of a single reference write (AtomicReference in Java, atomic.Value in Go, GIL-protected reassignment in Python) โ this is the RCU (read-copy-update) pattern applied to a hash ring. No lock is ever held during lookups. Versioned rings with epoch numbers extend this to gossip-based multi-node systems, letting a node detect "this request assumed ring v41, I'm on v43" without a global lock.
Why not just an RWLock? Valid and simpler when mutations are rare. CoW goes further: no reader ever blocks on a writer even briefly, and extends naturally to distributed settings with no single lock to hold.
9. Hot Keys ๐ฏ FOCUS
Common trap: interviewers push "vnodes balance load, so hot keys are solved, right?" โ No. vnodes fix placement skew, not demand skew. Know this distinction cold.
A hot key (viral post, celebrity profile, flash-sale item) is hit far more than others regardless of ring balance โ consistent hashing's "one deterministic owner per key" property IS the problem here.
Fixes:
- Request-level replication of the hot key โ fan out to more servers than the normal N, load-balance reads across them ("hot key splitting," used by Twemproxy/Facebook memcache).
- Client-side / edge caching โ push a short-TTL copy to the layer nearest the request; popularity makes even a seconds-long TTL highly effective.
- Request coalescing / single-flight โ concurrent requests for the same key share one fetch instead of each hitting the origin (kills the "thundering herd on expiry" variant).
- Bounded-load consistent hashing โ hard cap on how far above average any server's load can go; a load-shedding safety valve, not demand reduction โ used alongside replication/caching.
10. CDN Routing ๐ก GOOD TO KNOW
Two different problems get conflated: client โ nearest edge POP is DNS geo-routing/anycast (NOT consistent hashing); within/across a POP โ which cache machine holds this object IS consistent hashing. Hashing the request key onto a ring of cache-server identities means every server resolves the same key to the same owner โ the object is fetched from origin once and cached on one (or N) machines, avoiding the thundering-herd-on-cold-cache problem. Same mechanism as memcached sharding; adding/removing a cache node reassigns only ~1/N of URL space. See [[CDN]].
11. Sticky Sessions on Load Balancers ๐ก GOOD TO KNOW
Naive backend = hash(client_id) % N means scaling the backend pool remaps nearly every client, causing mass session loss right when scaling. Consistent-hash affinity fixes this the same way: hash the session identifier onto a ring of backends, so scaling remaps only ~1/N of sessions. Real config knobs worth naming: HAProxy's balance hash-type consistent, nginx's hash $key consistent;, Envoy's ring_hash/maglev policies. See [[Load Balancing]].
12. How Data Physically Rearranges When a Server Is Added ๐ฏ FOCUS
Two fundamentally different cases:
Cache (memcached-style): nothing is migrated โ the new owner simply doesn't have the keys yet, so the next request is a miss, fetched from origin, and populated on the new owner. The old server's copies go cold via its own LRU. "Migration" is free โ just organic cache warming.
Persistent storage (Cassandra/Dynamo-style): data actually moves. (1) New node picks token(s) (~256 vnodes). (2) With vnodes, it claims small slices from many existing nodes, not one contiguous chunk. (3) Existing owners stream data directly over a dedicated streaming protocol (not write replay). (4) The joining node is invisible to reads until streaming completes and is verified โ flips to serving only after catching up; old owners drop redundant copies via a deliberate cleanup step. (5) Old owners keep serving throughout โ no availability gap.
Anti-entropy safety net: streaming is best-effort; background repair (Merkle-tree comparison across replicas โ see [[Merkle Trees]] for the full mechanism) re-syncs any divergence after the fact โ the general "fast/best-effort primary path + slower background reconciliation path" pattern recurs across distributed systems interviews.
Focus Summary โ Where to Spend Reps
| Section | Tier | Why |
|---|---|---|
| ยง1 Problem, ยง2 Ring, ยง3 Vnodes | โญ MUST KNOW | The definition. Asked every time. |
| ยง6 Implementation | โญ MUST KNOW | Live coding โ must be automatic. |
| ยง7 Complexity / TreeMap | ๐ฏ FOCUS | The standard "make it faster" follow-up. |
| ยง4 Replication, ยง9 Hot keys, ยง12 Data movement | ๐ฏ FOCUS | Senior-loop follow-ups; distinguish you. |
| ยง5 Implementations, ยง8 Concurrency, ยง10 CDN, ยง11 Sticky | ๐ก GOOD TO KNOW | Recognize + one-liner. Don't over-invest. |
Cross-links
[[Caching & Redis]] ยท [[Load Balancing]] ยท [[Database Replication & Sharding]] ยท [[Design a URL Shortener]] ยท [[Message Queues & Kafka]] ยท [[Merkle Trees]]