System Design · lesson 6 of 11
Beginner
4Intermediate
4Advanced
3Also part of System Design
12- + Scaling Patterns: Real Sharding vs a Table That Only Looks Sharded from Databases
- + Killing the Primary and Measuring the Real Failover Time from MongoDB
- + ACID vs BASE: the Lost Update Neither Engine Prevents by Default from Databases
- + Distributed Correctness in Interviews from Interview Preparation
- + Events vs Requests: Why the Order Service Doesn't Wait for Email from Event-Driven Architecture
- + Scaling, Queues, and Backpressure from Cloud Interviews
- + Cache-Aside with Redis: Stale Reads, Races and Stampedes from Redis
- + Circuit Breaker: Stop Calling a Service That's Already Down from Microservices
- + API Gateway: Aggregating Calls With a Fallback from Microservices
- + Observability Synthesis: Logs, Metrics, Traces, and Alerts as One Signal from DevOps
- + Design a URL Shortener: Sequential Codes vs Real Hash Collisions from Low-Level Design
- + Availability, Regions, and the Arithmetic of Nines from Cloud Interviews
Consistent Hashing: Why Adding a Server Shouldn't Remap Everything
Plain modulo hashing remaps most keys when the server count changes. A hash ring with virtual nodes remaps only the fraction that actually has to move.
By SkillByExample · Published
Plain modulo hashing recomputes every key’s server the moment the server count changes; a hash ring only has to move the keys that actually land near the server that joined or left.
Removing one server: 77% remapped vs 23%
import hashlib
import bisect
def h(key):
return int(hashlib.md5(key.encode()).hexdigest(), 16)
def modulo_assignment(keys, servers):
return {key: servers[h(key) % len(servers)] for key in keys}
class ConsistentHashRing:
def __init__(self, servers, replicas=100):
self.replicas = replicas
self.ring = {}
self.sorted_hashes = []
for server in servers:
self.add_server(server)
def add_server(self, server):
for i in range(self.replicas):
point = h(f"{server}#{i}")
self.ring[point] = server
bisect.insort(self.sorted_hashes, point)
def remove_server(self, server):
points = [p for p, s in self.ring.items() if s == server]
for p in points:
del self.ring[p]
self.sorted_hashes.remove(p)
def get(self, key):
point = h(key)
idx = bisect.bisect(self.sorted_hashes, point)
if idx == len(self.sorted_hashes):
idx = 0
return self.ring[self.sorted_hashes[idx]]
keys = [f"user-{i}" for i in range(1000)]
servers = ["server-a", "server-b", "server-c", "server-d"]
before_modulo = modulo_assignment(keys, servers)
after_modulo = modulo_assignment(keys, servers[:3]) # server-d removed
moved_modulo = sum(1 for k in keys if before_modulo[k] != after_modulo[k])
print(f"Modulo hashing: {moved_modulo}/{len(keys)} keys remapped after removing 1 of 4 servers")
ring = ConsistentHashRing(servers)
before_ring = {k: ring.get(k) for k in keys}
ring.remove_server("server-d")
after_ring = {k: ring.get(k) for k in keys}
moved_ring = sum(1 for k in keys if before_ring[k] != after_ring[k])
print(f"Consistent hashing: {moved_ring}/{len(keys)} keys remapped after removing 1 of 4 servers")
$ python consistent_hashing.py
Modulo hashing: 770/1000 keys remapped after removing 1 of 4 servers
Consistent hashing: 230/1000 keys remapped after removing 1 of 4 servers
key % 4 and key % 3 agree for almost no key, so removing one server changes where 77% of all
keys land — every cache or shard using modulo routing would see a mass eviction/rebalance for a
single server loss. The ring only remaps the ~23% of keys that actually lived in server-d’s
ranges; everyone else’s assignment doesn’t change at all.
Practice
1. Compare key distribution evenness across 1, 10, and 100 virtual replicas per server.
import hashlib
import bisect
def h(key):
return int(hashlib.md5(key.encode()).hexdigest(), 16)
class ConsistentHashRing:
def __init__(self, servers, replicas=100):
self.replicas = replicas
self.ring = {}
self.sorted_hashes = []
for server in servers:
self.add_server(server)
def add_server(self, server):
for i in range(self.replicas):
point = h(f"{server}#{i}")
self.ring[point] = server
bisect.insort(self.sorted_hashes, point)
def get(self, key):
point = h(key)
idx = bisect.bisect(self.sorted_hashes, point)
if idx == len(self.sorted_hashes):
idx = 0
return self.ring[self.sorted_hashes[idx]]
keys = [f"user-{i}" for i in range(1000)]
servers = ["server-a", "server-b", "server-c", "server-d"]
for replicas in [1, 10, 100]:
ring = ConsistentHashRing(servers, replicas=replicas)
counts = {}
for k in keys:
s = ring.get(k)
counts[s] = counts.get(s, 0) + 1
values = list(counts.values())
print(f"replicas={replicas:>3}: {counts} (min={min(values)}, max={max(values)})")
$ python replica_count_evenness.py
replicas= 1: {'server-a': 364, 'server-c': 77, 'server-d': 294, 'server-b': 265} (min=77, max=364)
replicas= 10: {'server-c': 251, 'server-b': 271, 'server-d': 294, 'server-a': 184} (min=184, max=294)
replicas=100: {'server-d': 230, 'server-c': 242, 'server-a': 265, 'server-b': 263} (min=230, max=265)
At 1 replica per server, one unlucky hash placement gives server-a almost 5x server-c’s
share. By 100 replicas, the min/max gap shrinks from 287 keys down to 35 — more virtual nodes
trades ring size for evenness.
2. Add a 4th server instead of removing one, and confirm every remapped key moved specifically to the new server.
import hashlib
import bisect
def h(key):
return int(hashlib.md5(key.encode()).hexdigest(), 16)
class ConsistentHashRing:
def __init__(self, servers, replicas=100):
self.replicas = replicas
self.ring = {}
self.sorted_hashes = []
for server in servers:
self.add_server(server)
def add_server(self, server):
for i in range(self.replicas):
point = h(f"{server}#{i}")
self.ring[point] = server
bisect.insort(self.sorted_hashes, point)
def get(self, key):
point = h(key)
idx = bisect.bisect(self.sorted_hashes, point)
if idx == len(self.sorted_hashes):
idx = 0
return self.ring[self.sorted_hashes[idx]]
keys = [f"user-{i}" for i in range(1000)]
servers = ["server-a", "server-b", "server-c"]
ring = ConsistentHashRing(servers)
before = {k: ring.get(k) for k in keys}
ring.add_server("server-d")
after = {k: ring.get(k) for k in keys}
moved = sum(1 for k in keys if before[k] != after[k])
moved_to_new = sum(1 for k in keys if after[k] == "server-d")
print(f"Keys remapped after adding server-d: {moved}/{len(keys)}")
print(f"Of those, keys that moved TO server-d: {moved_to_new}")
$ python add_server.py
Keys remapped after adding server-d: 230/1000
Of those, keys that moved TO server-d: 230
Every single remapped key moved to the new server — none moved between two of the three existing servers. That’s the property modulo hashing doesn’t have: joining a ring only steals keys from whichever ranges the new node’s points happen to fall into.
3. Confirm modulo hashing is just as disruptive when ADDING a server as it was removing one.
import hashlib
def h(key):
return int(hashlib.md5(key.encode()).hexdigest(), 16)
def modulo_assignment(keys, servers):
return {key: servers[h(key) % len(servers)] for key in keys}
keys = [f"user-{i}" for i in range(1000)]
servers = ["server-a", "server-b", "server-c"]
before = modulo_assignment(keys, servers)
after = modulo_assignment(keys, servers + ["server-d"])
moved = sum(1 for k in keys if before[k] != after[k])
print(f"Modulo hashing: {moved}/{len(keys)} keys remapped after ADDING a 4th server")
$ python modulo_add_server.py
Modulo hashing: 770/1000 keys remapped after ADDING a 4th server
Same 77% churn as the removal case — key % 3 and key % 4 disagree for almost every key
regardless of which direction the server count changes, since the modulus itself changed.
4. Measure how the remap fraction compares to the theoretical ideal (1/N) as cluster size grows.
import hashlib
import bisect
def h(key):
return int(hashlib.md5(key.encode()).hexdigest(), 16)
class ConsistentHashRing:
def __init__(self, servers, replicas=100):
self.replicas = replicas
self.ring = {}
self.sorted_hashes = []
for server in servers:
self.add_server(server)
def add_server(self, server):
for i in range(self.replicas):
point = h(f"{server}#{i}")
self.ring[point] = server
bisect.insort(self.sorted_hashes, point)
def remove_server(self, server):
points = [p for p, s in self.ring.items() if s == server]
for p in points:
del self.ring[p]
self.sorted_hashes.remove(p)
def get(self, key):
point = h(key)
idx = bisect.bisect(self.sorted_hashes, point)
if idx == len(self.sorted_hashes):
idx = 0
return self.ring[self.sorted_hashes[idx]]
keys = [f"user-{i}" for i in range(1000)]
for n in [4, 8, 16]:
servers = [f"server-{i}" for i in range(n)]
ring = ConsistentHashRing(servers)
before = {k: ring.get(k) for k in keys}
ring.remove_server("server-0")
after = {k: ring.get(k) for k in keys}
moved = sum(1 for k in keys if before[k] != after[k])
ideal = len(keys) / n
print(f"{n} servers: {moved}/{len(keys)} remapped (ideal ~1/{n} = {ideal:.0f})")
$ python remap_fraction_vs_ideal.py
4 servers: 226/1000 remapped (ideal ~1/4 = 250)
8 servers: 77/1000 remapped (ideal ~1/8 = 125)
16 servers: 45/1000 remapped (ideal ~1/16 = 62)
The theoretical minimum for removing 1 of N servers is exactly 1/N of the keys — whatever that one server owned has to go somewhere. Consistent hashing gets close to that floor at every cluster size tested here; modulo hashing has no relationship to it at all, since its remap fraction depends on how the old and new modulus happen to agree, not on how many servers changed.