6. Consistent Hashing
Consistent hashing is a distributed hashing technique that minimizes the number of keys that need to be remapped when nodes are added or removed from a cluster. It is a fundamental building block for distributed caches, databases, and load balancers.
The Problem with Simple Hashing
With naive modular hashing, a key is mapped to a server using:
Where
Example with 4 servers:
hash("user:1") % 4 = 2 → Server 2
hash("user:2") % 4 = 0 → Server 0
hash("user:3") % 4 = 1 → Server 1
hash("user:4") % 4 = 3 → Server 3
Problem: When
With 4 servers: hash("user:1") % 4 = 2 → Server 2
With 5 servers: hash("user:1") % 5 = 3 → Server 3 ← REMAPPED!
On average, when going from
- Cache avalanche: When keys move, the old cache is useless; all requests hit the database.
- Data rebalancing storms: Massive data migration across nodes.
How Consistent Hashing Works
Step 1: The Hash Ring
Imagine the output space of a hash function as a circular ring from
0
/ \
/ \
/ \
/ \
/ \
2^32-1 2^31
\ /
\ /
\ /
\ /
\ /
2^32/2
Step 2: Place Servers on the Ring
Hash each server to a position on the ring:
hash("Server A") → position 10
hash("Server B") → position 50
hash("Server C") → position 80
0
.|.
. | .
A(10)| .
. | .
. | .
. | C(80)
. | .
. | .
. | .
B(50) .
'.'
Step 3: Map Keys to Servers
Each key is hashed to a position on the ring and is assigned to the first server encountered moving clockwise.
hash("key1") → position 15 → Server B (next clockwise is 50)
hash("key2") → position 55 → Server C (next clockwise is 80)
hash("key3") → position 85 → Server A (wraps around to 10)
Step 4: Adding/Removing Servers
Adding Server D at position 30:
Only keys between A(10) and D(30) need to move — from B to D.
All other keys remain unchanged.
Removing Server B:
Only keys that were on B (between A(10) and B(50)) move to C(80).
All other keys remain unchanged.
Key insight: On average, only
The Non-Uniformity Problem
With only a few servers on the ring, key distribution can be very uneven:
0
.|.
. | .
A(5) | .
. | .
. | B(10) ← A and B are close together
. | . ← C handles most of the ring!
.| .
. .
C(80)
Server C would be responsible for most keys. This creates hot spots.
Virtual Nodes (Vnodes)
The solution is to map each physical server to multiple positions on the ring using virtual nodes.
Server A → hash("A-vnode0") = 10
hash("A-vnode1") = 45
hash("A-vnode2") = 75
Server B → hash("B-vnode0") = 25
hash("B-vnode1") = 60
hash("B-vnode2") = 90
Server C → hash("C-vnode0") = 35
hash("C-vnode1") = 55
hash("C-vnode2") = 85
0
.|.
. | .
A0(10)| .
. B0(25) .
. C0(35) .
A1(45) | C2(85)
. C1(55) B2(90)
. B1(60) .
. | .
A2(75)
'.'
Benefits of virtual nodes:
- More uniform distribution: Each server gets multiple points on the ring, averaging out the load.
- Proportional assignment: A more powerful server can have more virtual nodes.
- Smoother rebalancing: When a node is added or removed, its load is distributed across many other nodes rather than just one.
Number of virtual nodes: Typically 100-200 vnodes per physical server provides good distribution.
Handling Replication
For fault tolerance, each key is replicated to
Key X → Primary: Node A → Replica 1: Node B → Replica 2: Node C
Replication with virtual nodes: Walk clockwise on the ring, but skip virtual nodes belonging to the same physical server.
Key X at position 15:
→ Vnode: A0 (position 10) ← Primary (physical Server A)
→ Skip: A1 (position 45) ← Same physical server, skip
→ Vnode: B0 (position 25) ← Replica 1 (physical Server B)
→ Vnode: C0 (position 35) ← Replica 2 (physical Server C)
Algorithm Complexity
| Operation | Complexity |
|---|---|
| Key lookup (find server) | |
| Add node | |
| Remove node |
Where:
= number of physical nodes = total keys = virtual nodes per physical node
Consistent Hashing Variants
Jump Consistent Hashing
A fast, minimal-memory algorithm that maps keys to buckets uniformly.
int32_t JumpConsistentHash(uint64_t key, int32_t num_buckets) {
int64_t b = -1, j = 0;
while (j < num_buckets) {
b = j;
key = key * 2862933555777941757ULL + 1;
j = (b + 1) * (double(1LL << 31) / double((key >> 33) + 1));
}
return b;
}
- Pros: Zero memory, perfectly uniform, very fast.
- Cons: Only supports sequential bucket IDs (0 to N-1); can't handle arbitrary node names or weights.
- Best for: Sharding with known, sequential shard IDs.
Rendezvous Hashing (Highest Random Weight)
For each key, hash the key with every server and pick the server with the highest hash value.
For key K:
score_A = hash(K + "Server A") = 0.73
score_B = hash(K + "Server B") = 0.91 ← highest → choose Server B
score_C = hash(K + "Server C") = 0.42
- Pros: Simple, no ring structure needed, perfectly uniform.
- Cons:
per lookup (must hash with every server). - Best for: Small number of servers.
Maglev Hashing (Google)
A lookup-table-based approach for connection-consistent load balancing.
- Fixed-size lookup table populated with a permutation-based algorithm.
lookup time.- Designed for network load balancers.
Real-World Usage
| System | How It Uses Consistent Hashing |
|---|---|
| Amazon DynamoDB | Partitions data across storage nodes |
| Apache Cassandra | Maps partition keys to nodes in the cluster |
| Memcached | Client-side consistent hashing for cache distribution |
| Redis Cluster | Hash slots (16384 slots) — a variant of consistent hashing |
| Akamai CDN | Maps URLs to edge servers |
| Discord | Routes users to specific servers/shards |
| Netflix | EVCache ring for distributed caching |
Redis Cluster: Hash Slot Approach
Instead of a continuous hash ring, Redis divides the keyspace into 16384 fixed slots:
Slots are assigned to nodes. When nodes change, only slots (not individual keys) are migrated.
Node A: Slots 0-5460
Node B: Slots 5461-10922
Node C: Slots 10923-16383
Consistent Hashing vs Simple Hashing
| Aspect | Simple Hash ( |
Consistent Hashing |
|---|---|---|
| Keys remapped on node change | ~ |
~ |
| Distribution uniformity | Good | Good (with vnodes) |
| Complexity | ||
| Implementation | Trivial | Moderate |
| Use case | Fixed-size clusters | Dynamic clusters |
Implementation Sketch
import hashlib
import bisect
class ConsistentHashRing:
def __init__(self, nodes=None, num_vnodes=150):
self.num_vnodes = num_vnodes
self.ring = {} # hash_value → node
self.sorted_keys = [] # sorted hash positions
if nodes:
for node in nodes:
self.add_node(node)
def _hash(self, key):
return int(hashlib.md5(key.encode()).hexdigest(), 16)
def add_node(self, node):
for i in range(self.num_vnodes):
vnode_key = f"{node}:vnode{i}"
hash_val = self._hash(vnode_key)
self.ring[hash_val] = node
bisect.insort(self.sorted_keys, hash_val)
def remove_node(self, node):
for i in range(self.num_vnodes):
vnode_key = f"{node}:vnode{i}"
hash_val = self._hash(vnode_key)
del self.ring[hash_val]
self.sorted_keys.remove(hash_val)
def get_node(self, key):
if not self.ring:
return None
hash_val = self._hash(key)
idx = bisect.bisect_right(self.sorted_keys, hash_val)
if idx == len(self.sorted_keys):
idx = 0 # wrap around
return self.ring[self.sorted_keys[idx]]
Summary
| Concept | Key Point |
|---|---|
| Problem | Naive hashing remaps almost all keys on node changes |
| Solution | Consistent hashing — keys map to ring, walk clockwise |
| Virtual nodes | Multiple ring positions per server for uniform distribution |
| Key movement | Only |
| Replication | Replicate to next |
| Variants | Jump hash (fast, sequential), Rendezvous (simple), Maglev (Google LB) |
Rule of thumb: Use consistent hashing whenever you need to distribute data across a dynamic set of nodes — caches, databases, or load balancers.