Distributed Caching
Scaling and Sharding Distributed Cache Clusters for High Performance
Understand consistent hashing and partitioning techniques to distribute cache keys across multiple nodes, ensuring high availability and horizontal scalability.
In this article
The Bottleneck of Vertical Scaling
In the early stages of an application, a single caching instance like Redis or Memcached is often sufficient to handle the load. As your user base grows, the memory and throughput requirements of your cache layer will eventually exceed the capacity of a single machine. You might try to scale vertically by adding more RAM or more powerful CPUs, but this approach reaches a hard ceiling of physical hardware limits and diminishing returns on cost.
Relying on one large instance also creates a massive single point of failure within your architecture. If that one cache node crashes, your underlying database will be suddenly overwhelmed by a flood of requests that were previously served from memory. This phenomenon, known as a cache stampede, can lead to cascading failures across your entire system.
Distributed caching solves this by spreading the data across a cluster of multiple nodes. By partitioning the dataset, you can increase the total available memory and aggregate network bandwidth of the system. This allows your infrastructure to scale horizontally, adding new nodes as demand increases without the constraints of a single physical machine.
Horizontal scaling through partitioning is not just about capacity; it is an insurance policy against the catastrophic failure of a centralized resource.
Understanding Data Partitioning
Data partitioning, or sharding, is the process of dividing a large dataset into smaller, manageable chunks called partitions. Each partition is then assigned to a specific node in the distributed cluster. The primary challenge is determining which node should store or retrieve a specific key while maintaining a balanced load across all available servers.
A well-designed partitioning strategy ensures that every node in the cluster handles an approximately equal share of the traffic. It also ensures that the system can locate the correct node for any given key with minimal overhead. Without an efficient mapping mechanism, the latency benefits of using a cache would be negated by the complexity of finding the data.
The Fragility of Simple Hashing
The most intuitive way to distribute keys is using the modulo hashing algorithm. In this scheme, you take the hash of a key and divide it by the number of nodes in your cluster, using the remainder to select the target node. For example, if you have three nodes, a key with a hash value of ten would be placed on node one because ten modulo three equals one.
While this method is simple to implement and initially balances the load well, it is extremely fragile in dynamic environments. The number of nodes in a production cluster is rarely static. You may need to add nodes to handle peak traffic or remove nodes for scheduled maintenance or due to hardware failures.
1def get_node_modulo(key, node_count):
2 # Simulate a hash function
3 key_hash = hash(key)
4 # Calculate the node index using modulo
5 return key_hash % node_count
6
7# Scenario: Cluster grows from 3 to 4 nodes
8key = "user_session_9921"
9print(f"Node with 3 instances: {get_node_modulo(key, 3)}")
10print(f"Node with 4 instances: {get_node_modulo(key, 4)}")
11# Almost every key will map to a different node after the changeWhen the number of nodes changes, the result of the modulo operation changes for nearly every key in your system. This causes a massive cache miss event across the entire cluster. In a large-scale system, this results in a rehash storm where your database is hit with thousands of concurrent requests for data that technically still exists in the cache but is now assigned to the wrong node.
Impact on System Stability
A rehash storm can be more damaging than a total cache failure because it happens exactly when you are trying to scale your system to handle more load. Instead of providing relief, adding a new node temporarily makes the system slower and more unstable. This makes it impossible to perform elastic scaling or rolling updates without significant downtime or performance degradation.
Architects must prioritize a distribution strategy that minimizes data movement when the cluster topology changes. The goal is to ensure that adding or removing a node only affects a small fraction of the keys rather than the entire dataset. This requirement leads us directly to the concept of consistent hashing.
The Mechanics of Consistent Hashing
Consistent hashing solves the rehash storm problem by decoupling the key distribution from the number of nodes in the cluster. Imagine a circle representing the range of all possible hash values, often called a hash ring. Both the cache nodes and the data keys are hashed and placed onto this ring based on their resulting values.
To find the node responsible for a specific key, you locate the key on the ring and move clockwise until you encounter the first node. That node is the owner of the key. Because the nodes are distributed across the same hash space as the keys, the mapping is determined by their relative positions rather than a global calculation based on the total node count.
- Minimal Disruption: Adding a node only requires remapping keys located between the new node and its counter-clockwise neighbor.
- Scalability: Nodes can be added or removed with predictable, localized impact on cache misses.
- Decentralization: Every client can independently calculate the correct node location without a central coordinator.
When a node is removed from a consistent hash ring, only the keys that were previously assigned to it are affected. These keys are naturally reassigned to the next node in the clockwise direction. The rest of the ring remains completely untouched, allowing the majority of the cache to stay valid and functional.
Implementing the Hash Ring
Modern client libraries for Redis and Memcached often implement consistent hashing under the hood. However, understanding the implementation is crucial for debugging distribution issues. The algorithm typically uses a sorted data structure to keep track of node positions and performs a binary search to find the successor node for any given key hash.
1import bisect
2import hashlib
3
4class ConsistentHashRing:
5 def __init__(self, nodes=None):
6 # Store sorted hashes of the nodes
7 self.ring = []
8 self.nodes = {}
9 if nodes:
10 for node in nodes:
11 self.add_node(node)
12
13 def add_node(self, node):
14 node_hash = self._gen_hash(node)
15 bisect.insort(self.ring, node_hash)
16 self.nodes[node_hash] = node
17
18 def get_node(self, key):
19 if not self.ring:
20 return None
21 key_hash = self._gen_hash(key)
22 # Find the first node with a hash >= key_hash
23 idx = bisect.bisect_right(self.ring, key_hash)
24 # Wrap around to the start of the ring if necessary
25 return self.nodes[self.ring[idx % len(self.ring)]]
26
27 def _gen_hash(self, val):
28 return int(hashlib.md5(val.encode()).hexdigest(), 16)Achieving Equilibrium with Virtual Nodes
A common problem with basic consistent hashing is non-uniform distribution. Because node hashes are random, they might cluster together on the ring, leaving large gaps. Nodes following a large gap will be responsible for a disproportionately high number of keys, leading to hot nodes that run out of memory while others remain underutilized.
Virtual nodes, or vnodes, address this by mapping each physical node to multiple points on the hash ring. Instead of placing a node named Server-A once, you might place Server-A-1, Server-A-2, through Server-A-100 at different positions. This fragments the responsibility of a single physical server across the entire hash space.
By increasing the number of virtual nodes, you make the distribution of keys more granular and uniform. If a physical node fails, its many virtual nodes disappear from different parts of the ring. This spreads the resulting load increase evenly across all remaining physical nodes in the cluster rather than dumping the entire burden onto a single neighbor.
Tuning VNode Counts
Selecting the right number of virtual nodes is a trade-off between memory overhead and distribution quality. Typically, using 100 to 200 virtual nodes per physical server provides a good balance, ensuring that load variance remains within a few percentage points across the cluster. If you have servers with different hardware specifications, you can assign more virtual nodes to the more powerful machines to give them a larger share of the workload.
This technique allows for heterogeneous clusters where you can mix and match hardware without worrying about overloading weaker nodes. The weight of a node in the cluster is directly proportional to its number of virtual nodes on the ring. This flexibility is essential for long-lived systems that undergo multiple generations of hardware upgrades.
Operational Strategies for Partitioned Clusters
While consistent hashing handles the mapping of keys, you must also decide where the partitioning logic lives. Client-side partitioning is the most common approach, where the application code uses a library to calculate the correct node. This minimizes latency by avoiding an extra network hop, but it requires every application instance to have an identical view of the cluster topology.
Proxy-based partitioning involves placing a layer like Twemproxy or Envoy between your application and the cache nodes. The application connects to the proxy, which handles the hashing and routing logic. This simplifies application code and allows you to update the cluster configuration in one central place without redeploying your entire service fleet.
Regardless of the architecture, you must account for hot keys which are frequently accessed keys that can overwhelm a single node despite perfect hashing. Strategies like local in-memory caching for extremely popular keys or key salting can help mitigate the impact of these high-traffic items. Monitoring your distribution metrics regularly is the only way to ensure your partitioning strategy remains effective as traffic patterns evolve.
Dealing with Data Locality
In some scenarios, you may want to ensure that related keys are stored on the same node to perform multi-key operations or transactions. Most distributed caching systems support hashtags for this purpose. If a key contains a specific pattern like user123 inside curly braces, only that portion of the string is hashed to determine the node location.
This allows you to group data together intentionally while still benefiting from the global distribution of the rest of the dataset. However, use this feature sparingly, as overusing hashtags can lead to unintended data hotspots. Balancing data locality with uniform distribution is an ongoing architectural challenge in large-scale distributed systems.
