Consistent Hashing
How distributed systems add and remove servers without massive data reshuffling. Master the circular hash ring, clockwise routing, minimal migration (k/N), and virtual nodes through an intuitive step-by-step case study.
📁 1. The Real-World Scenario: Building Distributed Storage
Imagine you are building a simplified distributed cloud storage service like Amazon S3 or Google Drive. Clients use two basic functions:
Takes a file name, determines which storage server should hold it, and writes the bytes to that server's disk.
Calculates which server holds the file and retrieves it for the user.
You start with 5 storage servers (Node A through Node E). The simplest, classic way to route files is Modulo Hashing: sum the bytes of the file name, divide by the number of servers N = 5, and use the remainder.
// The Naive Approach: Modulo Hashing in Python storage_nodes = [ StorageNode(name='A', host='10.0.0.1'), StorageNode(name='B', host='10.0.0.2'), StorageNode(name='C', host='10.0.0.3'), StorageNode(name='D', host='10.0.0.4'), StorageNode(name='E', host='10.0.0.5') ] def hash_fn(key: str) -> int: # Sum ASCII byte values and take modulo N (5 active nodes) return sum(key.encode('utf-8')) % len(storage_nodes) def upload(file_path: str): node_idx = hash_fn(file_path) return storage_nodes[node_idx].put_file(file_path) def fetch(file_path: str): node_idx = hash_fn(file_path) return storage_nodes[node_idx].get_file(file_path)
Let's trace 5 files under this initial cluster state (sum(key) % 5):
| File Key | Sum of ASCII Bytes | Formula (N = 5) | Assigned Storage Node |
|---|---|---|---|
f1.txt | 524 | 524 % 5 = 4 | Node E (10.0.0.5) |
f2.txt | 525 | 525 % 5 = 0 | Node A (10.0.0.1) |
f3.txt | 526 | 526 % 5 = 1 | Node B (10.0.0.2) |
f4.txt | 527 | 527 % 5 = 2 | Node C (10.0.0.3) |
f5.txt | 528 | 528 % 5 = 3 | Node D (10.0.0.4) |
💥 2. Scaling The Cluster: The 100% Data Eviction Collapse
Your storage service grows rapidly. To add more disk space, you add just 1 server (Node F), taking the cluster from N = 5 to N = 6.
The routing formula changes to sum(key) % 6. Now see what happens when clients call fetch() for their files:
| File Key | Where File Physically Lives (N = 5) | New Formula (N = 6) | Where fetch() Looks (N = 6) | Result |
|---|---|---|---|---|
f1.txt | Node E (Index 4) | 524 % 6 = 2 | Node C (Index 2) | ❌ 404 NOT FOUND |
f2.txt | Node A (Index 0) | 525 % 6 = 3 | Node D (Index 3) | ❌ 404 NOT FOUND |
f3.txt | Node B (Index 1) | 526 % 6 = 4 | Node E (Index 4) | ❌ 404 NOT FOUND |
f4.txt | Node C (Index 2) | 527 % 6 = 5 | Node F (Index 5) | ❌ 404 NOT FOUND |
f5.txt | Node D (Index 3) | 528 % 6 = 0 | Node A (Index 0) | ❌ 404 NOT FOUND |
Because every single divisor changes when N changes from 5 to 6, every file is looked up on the wrong storage server.
⭕ 3. The Consistent Hashing Ring: Step-by-Step
Think of Consistent Hashing like a standard circular wall clock. If it is 11:00 and you add 2 hours, you don't fall off a number line — the clock simply wraps around 12:00 back to 1:00.
A hash function (like SHA-256) outputs large integer values between 0 and 2²⁵⁶ - 1. We connect the end back to the start so the numbers loop endlessly like a clock.
1. Place Servers on the Ring: Hash each server's IP address (e.g. hash("10.0.0.1")) to place Node A, B, C, D, and E at fixed angles around the circle.
2. Place Files on the Ring: Hash the file name (e.g. hash("f1.txt")) to get its coordinate.
3. Clockwise Lookup: To find where a file lives, start at the file's position and travel clockwise around the circle until you hit the first server!
⚖️ 4. Zero-Downtime Scaling: Adding Node K
Now observe the magic when you scale the cluster by adding a 6th server, Node K (10.0.0.6), situated on the ring between Node D and Node E:
Files in this small segment (like f1.txt) previously routed to Node E. Now they route to Node K. Node K only copies this small slice from Node E.
100% UNCHANGED. Files f2, f3, f4, f5 do not move a single byte over the network!
🎭 5. The Two Flaws & Virtual Nodes (Tokens)
In a basic ring with only 5 physical servers, two production problems occur:
Random hashes do not space servers evenly. Node A might get a tiny 15° slice while Node B gets a 160° slice, receiving 10x more traffic!
If Node B crashes, 100% of its data segment dumps onto Node C (its clockwise neighbor), immediately doubling Node C's load and crashing it too.
Instead of placing each physical server on the ring once, we place 100 to 256 Virtual Tokens per node (e.g. NodeA#0, NodeA#1, ... NodeA#199). Tokens from all servers interleave evenly across the entire 360° circle, eliminating hotspots and dropping load variance to under 3%.
| Virtual Tokens per Physical Node | Load Variance (Standard Deviation) | Production Recommendation |
|---|---|---|
| 1 VNode (Raw Physical Server) | ~60% – 80% | Unacceptable (Severe Hotspots) |
| 50 VNodes | ~10% | Acceptable for small clusters |
| 100 – 256 VNodes | ~2% – 4% | Industry Standard (DynamoDB / Cassandra) |
💻 6. Low-Level Implementation: O(log N) Binary Search
How do we execute a clockwise lookup in production without allocating a 2²⁵⁶-element array? We maintain a sorted token index and perform O(log N) Binary Search.
// Python 3.10+ Implementation using Two-Array Binary Search (bisect) import hashlib, bisect class ConsistentHashRing: def __init__(self, replicas: int = 100, total_slots: int = 2**256): self.replicas = replicas self.total_slots = total_slots self._keys = [] # Sorted array of virtual node hash tokens self.nodes = [] # Matching list of physical storage node instances def _hash_fn(self, key: str) -> int: h = hashlib.sha256(key.encode('utf-8')) return int(h.hexdigest(), 16) % self.total_slots def add_node(self, node): for i in range(self.replicas): token_key = f"{node.host}#{i}" key = self._hash_fn(token_key) # Find insertion position via Binary Search O(log N) idx = bisect.bisect(self._keys, key) self._keys.insert(idx, key) self.nodes.insert(idx, node) def remove_node(self, node): for i in range(self.replicas): token_key = f"{node.host}#{i}" key = self._hash_fn(token_key) idx = bisect.bisect_left(self._keys, key) if idx < len(self._keys) and self._keys[idx] == key: self._keys.pop(idx) self.nodes.pop(idx) def assign(self, item: str): if not self._keys: return None key = self._hash_fn(item) # Clockwise lookup: find smallest token >= key; wrap around to 0 if needed idx = bisect.bisect_right(self._keys, key) % len(self._keys) return self.nodes[idx]
🏢 7. Production Systems Powered by Consistent Hashing
Both databases use consistent hash rings (Token Rings) to partition data by Partition Key. For replication, a write is stored on the coordinator node and replicated to the next N-1 distinct physical nodes clockwise on the ring.
Routes millions of active voice channels and chat servers (Guilds) to backend Elixir beam processes. When new voice nodes autoscale during peak evening hours, existing voice calls remain 99% uninterrupted.
Originally invented at MIT for Akamai's web caches. If an edge proxy server reboots for a kernel upgrade, neighboring edge proxies take over only its slice of cached videos without purging global cache memory.
Google's custom packet load balancer uses consistent hashing with lookup permutation tables so packet streams belonging to existing TCP connections always hit the same backend without synchronization.
🧪 8. Interactive Lab: Hands-On Hash Ring Simulator
Hands-On Cluster Rebalancing Simulator
🎯 9. Test Your Understanding
You have 8 Memcached servers partitioned with simple hash(key) % 8. During Black Friday, you scale out by adding 2 new servers (now 10). Within 5 seconds, your backend MySQL databases crash under 100% CPU load. Why?
A developer creates a Consistent Hash Ring with 4 physical servers but DOES NOT configure Virtual Nodes (vnodes=1). During a traffic spike, Server 2 crashes, and 30 seconds later Server 3 crashes, followed by Server 4. What caused this cascading failure?