In distributed key-value stores (Amazon DynamoDB, Apache Cassandra, Riak) and distributed caching tiers (Memcached, Redis Cluster), data must be partitioned across an elastic cluster of storage nodes. A naive modulo hashing scheme ( ext{Node} = ext{hash}( ext{key}) \pmod N) suffers catastrophic failure during scaling: adding or removing a single node changes N, causing nearly 100\% of keys to remap to new nodes, inducing massive cache stampedes and network bandwidth saturation.
Consistent Hashing, introduced by Karger et al. in 1997, solves this by decoupling the hash space from the number of servers. When a cluster scales from N to N+1 nodes, only K / (N+1) keys are relocated on average, where K is the total number of keys.
+-----------------------------------------------------------------------------------------------------------------------+
| PARTITIONING SCHEME COMPARISON |
+-----------------------------------------------------------------------------------------------------------------------+
| Partitioning Scheme| Resharding Churn on Resize | Load Balance Invariant | Routing State Size | Hotspot Risk |
+--------------------+--------------------------------------+----------------------------+----------------------+--------------+
| Modulo Hashing | $pprox rac{N}{N+1} pprox 100\%$| Uniform (Ideal) | $O(1)$ | Low |
| Range Partitioning | High (Splits single range) | Dynamic splits required | $O( ext{Partitions})| High (Sequential)|
| Consistent Hashing | Minimal ($rac{1}{N+1}$) | Skewed without Vnodes | $O(N imes V)$ | Low (with Vnodes)|
| Directory Lookup | Zero (Reassign single pointer) | Arbitrary | $O(K)$ (Every key) | Medium |
+-----------------------------------------------------------------------------------------------------------------------+
Consistent hashing maps both node identifiers and data keys into a continuous cyclic mathematical space [0, 2^{32} - 1] (or [0, 2^{128} - 1] when using MD5 / Murmur3 / CityHash).
Hash Ring Space [0, 2^32 - 1]
Node A (0x2000)
/ \
/ \
Key 1 (0x1000) Node B (0x6000)
| |
| |
Node D (0xE000) Key 2 (0x7500)
\ /
\ /
Node C (0xA000)
To determine which node owns a given key k:
With a small number of physical nodes N, random hash distributions cause severe load imbalance; some arcs cover 40\% of the ring while others cover 5\%.
Physical Nodes without Vnodes (Severe Skew):
[------- Node A -------][-- Node B --][---------------- Node C ----------------]
Physical Nodes with 256 Vnodes each (Uniform Distribution):
[A1][B1][C1][A2][C2][B2][A3][B3][C3][A4]... (Homogeneous coverage)
Each physical server is assigned V distinct pseudo-random tokens across the ring (e.g., V = 128 or V = 256):
Benefits:
import bisect
import hashlib
from typing import List, Dict, Optional
class ConsistentHashRing:
def __init__(self, replicas: int = 128):
self.replicas = replicas
self.ring: List[int] = [] # Sorted list of token hashes
self.vnode_map: Dict[int, str] = {} # token_hash -> physical_node_id
def _hash(self, key: str) -> int:
return int(hashlib.md5(key.encode('utf-8')).hexdigest(), 16) & 0xFFFFFFFF
def add_node(self, node: str) -> None:
for i in range(self.replicas):
token = self._hash(f"{node}:vnode:{i}")
bisect.insort(self.ring, token)
self.vnode_map[token] = node
def remove_node(self, node: str) -> None:
tokens_to_remove = [t for t, n in self.vnode_map.items() if n == node]
for token in tokens_to_remove:
self.ring.remove(token)
del self.vnode_map[token]
def get_node(self, key: str) -> Optional[str]:
if not self.ring:
return None
h = self._hash(key)
idx = bisect.bisect_right(self.ring, h)
if idx == len(self.ring):
idx = 0 # Wrap around
return self.vnode_map[self.ring[idx]]