This article explores the evolution of distributed caching from naive sharding to a robust system using consistent hashing. It details how consistent hashing, combined with virtual nodes and gossip protocols, enables elastic scalability and high availability by minimizing data reshuffling during node additions or removals, critical for maintaining cache performance in dynamic environments.
Read original on Dev.to #systemdesignThe article begins by highlighting the common pitfalls of naive modulo-based sharding for distributed caches. When the number of nodes changes (due to scaling up or down), a simple `hash(key) % N` approach leads to a massive key remapping, effectively invalidating the entire cache and putting immense pressure on the backend database. This demonstrates a critical challenge in distributed system design: maintaining data locality and availability during topology changes.
Consistent hashing addresses this problem by mapping both keys and cache nodes onto a circular hash ring. A key is assigned to the first node encountered when moving clockwise from the key's position on the ring. The key benefit is minimal data movement: when a node is added or removed, only the keys residing in the affected segment of the ring need to be remapped to a new node, leaving the majority of the cache untouched.
While consistent hashing solves the remapping problem, a basic implementation can suffer from uneven data distribution if physical nodes are not perfectly spread on the ring, leading to hotspots. Virtual nodes (vnodes) mitigate this by assigning multiple points on the hash ring to each physical node. This strategy improves load balancing and reduces the impact of a single node's failure or addition. Furthermore, a gossip protocol allows nodes to discover each other and maintain a consistent view of the cluster state without a central coordinator, enhancing fault tolerance and decentralization.
Architectural Takeaway
Consistent hashing is not just for caches. It's a fundamental pattern for distributing data or requests across a dynamic set of servers while minimizing data rebalancing. Consider its application in distributed databases, load balancers, and content delivery networks (CDNs).
import hashlib
import bisect
class ConsistentHashRing:
def __init__(self, nodes=None, replicas=100):
self.replicas = replicas
self.ring = dict() # hash -> node
self._sorted_keys = [] # list of hashes in ascending order
self.nodes = set(nodes or [])
for node in self.nodes:
self._add_node(node)
def _hash(self, key):
return int(hashlib.md5(key.encode('utf-8')).hexdigest(), 16)
def _add_node(self, node):
for i in range(self.replicas):
h = self._hash(f"{node}:{i}")
self.ring[h] = node
bisect.insort(self._sorted_keys, h)
def _remove_node(self, node):
for i in range(self.replicas):
h = self._hash(f"{node}:{i}")
del self.ring[h]
idx = bisect.bisect_left(self._sorted_keys, h)
del self._sorted_keys[idx]
def get_node(self, key):
if not self.ring:
return None
h = self._hash(key)
idx = bisect.bisect_left(self._sorted_keys, h)
if idx == len(self._sorted_keys):
idx = 0 # wrap around the ring
return self.ring[self._sorted_keys[idx]]