Consistent Hashing: Data Distribution Across Systems
Learn how consistent hashing works in caches, databases, and CDNs, including hash rings, virtual nodes, node additions, and redistribution strategies.
Consistent hashing solves the key remapping problem when servers are added or removed—the core issue is that regular hashing (hash % N) remaps nearly all keys when N changes, causing cache stampedes. Consistent hashing maps keys clockwise around a ring, so adding a node only affects its neighbors instead of the entire keyspace. Virtual nodes smooth distribution and prevent hotspots when cluster sizes are small. The trade-offs vary by system: DynamoDB manages partitions automatically, Cassandra uses explicit token ranges, and Redis uses hash slots instead of a true ring. Most production systems benefit from 100-200 virtual nodes per physical node with MurmurHash3 for non-cryptographic hashing.
Introduction
Regular hash distribution uses a simple formula: server = hash(key) % num_servers. This works until you add or remove a server.
# Regular hashing distribution
num_servers = 4
servers = ['server-1', 'server-2', 'server-3', 'server-4']
for key in ['user:1', 'user:2', 'user:3', 'user:4']:
server_idx = hash(key) % num_servers
print(f"{key} -> {servers[server_idx]}")
# Adding a server changes everything
num_servers = 5
for key in ['user:1', 'user:2', 'user:3', 'user:4']:
server_idx = hash(key) % num_servers
print(f"{key} -> {servers[server_idx]}") # Most keys map differently
With 4 servers, key user:1 might map to server-1. With 5 servers, it could end up on server-3 instead. Almost every key remaps to a different server. Cache miss rates spike. Database load follows.
This causes real problems in production. Rolling out a new cache server triggers a wave of cache misses. Every miss hits the database. Databases often cannot handle this sudden load.
Topic-Specific Deep Dives
When to Use and When Not to Use Consistent Hashing
When to Use Consistent Hashing:
- You need to distribute keys across multiple servers or shards
- Server additions or removals should not cause massive key remapping
- You are building a distributed cache, load balancer, or data store
- You need horizontal scaling with minimal key redistribution
- You want to achieve uniform key distribution across nodes
When Not to Use Consistent Hashing:
- You have only a single server (add complexity only when needed)
- Your system does not change frequently (static distribution is fine)
- You need ordered key retrieval (consistent hashing does not maintain order)
- You have strict partitioning requirements (use deterministic partitioning)
- The operational complexity is not justified for your scale
MurmurHash vs MD5 vs SHA-256: Choosing a Hash Function
The hash function you use affects both distribution quality and performance. Here is how the main options compare:
import hashlib
import mmh3 # MurmurHash3 (install: pip install mmh3)
def benchmark_hash(data, iterations=100000):
import time
# MD5
start = time.time()
for _ in range(iterations):
hashlib.md5(data)
md5_time = time.time() - start
# SHA-256
start = time.time()
for _ in range(iterations):
hashlib.sha256(data)
sha_time = time.time() - start
# MurmurHash3
start = time.time()
for _ in range(iterations):
mmh3.hash(data)
mmh3_time = time.time() - start
return md5_time, sha_time, mmh3_time
Typical results on a modern laptop for 100,000 hashes of a 32-byte key:
| Hash Function | Speed (relative) | Output Bits | Collision Resistance |
|---|---|---|---|
| MurmurHash3 | 10x fastest | 32 or 128 | Low (not cryptographic) |
| MD5 | 2x slower | 128 | Broken (do not use for security) |
| SHA-256 | baseline | 256 | High (cryptographic) |
Use MurmurHash3 for consistent hashing in production systems — it is fast and distributes keys evenly. The non-cryptographic nature does not matter for load distribution since an attacker cannot control key placement without controlling the hash function input.
Use SHA-256 only if you need cryptographic guarantees (for example, preventing hash collision attacks where a client crafts keys that all hash to the same server). Most production deployments use MMH3 or xxHash for performance.
Multi-Dimensional Consistent Hashing
Standard consistent hashing gives you one axis of distribution. That works fine until you need two axes. A geo-distributed database might want data-locality for both user region AND tenant ID. A single hash value cannot capture both.
Multi-dimensional consistent hashing solves this by treating each attribute as its own hash ring. Query each ring separately, then combine the results through weighted voting.
class ConsistentHashRing:
"""Single-dimension consistent hash ring for use in multi-dimensional hashing."""
def __init__(self, nodes=None, virtual_nodes=100):
self.virtual_nodes = virtual_nodes
self.ring = {}
self.sorted_keys = []
if nodes:
for node in nodes:
self.add_node(node)
def _hash(self, key):
import hashlib
return int(hashlib.md5(str(key).encode()).hexdigest(), 16) % (2**32)
def add_node(self, node):
for i in range(self.virtual_nodes):
key = self._hash(f"{node}:vn{i}")
self.ring[key] = node
self.sorted_keys.append(key)
self.sorted_keys.sort()
def get_node(self, key):
if not self.sorted_keys:
return None
hash_val = self._hash(key)
import bisect
idx = bisect.bisect(self.sorted_keys, hash_val)
if idx >= len(self.sorted_keys):
idx = 0
return self.ring[self.sorted_keys[idx]]
class MultiDimensionalHasher:
"""
Multi-dimensional consistent hashing.
Each dimension is an independent hash ring. Nodes are selected
by combining votes from each dimension with weights.
"""
def __init__(self, dimensions, nodes=None, virtual_nodes=100):
"""
Initialize with `dimensions` independent hash rings.
dimensions: list of dimension names, e.g., ['region', 'tenant']
"""
self.dimension_names = dimensions
self.rings = [ConsistentHashRing(nodes, virtual_nodes) for _ in dimensions]
self.weights = [1.0] * len(dimensions)
def get_node(self, key_attrs):
"""
key_attrs: dict mapping dimension name to key value, e.g.,
{'region': 'us-east', 'tenant': 'tenant-42'}
Returns the node with the highest weighted vote across all dimensions.
"""
if isinstance(key_attrs, dict):
# If passed as dict, extract values in dimension order
attrs = [key_attrs.get(dim, '') for dim in self.dimension_names]
else:
# If passed as list/tuple, assume same order as dimensions
attrs = list(key_attrs)
votes = {}
for i, attr in enumerate(attrs):
node = self.rings[i].get_node(attr)
votes[node] = votes.get(node, 0) + self.weights[i]
return max(votes, key=votes.get)
def get_replicas(self, key_attrs, num_replicas=3):
"""Get multiple replica nodes, one per dimension per replica slot."""
if isinstance(key_attrs, dict):
attrs = [key_attrs.get(dim, '') for dim in self.dimension_names]
else:
attrs = list(key_attrs)
all_candidates = []
for i, attr in enumerate(attrs):
node = self.rings[i].get_node(attr)
weight = self.weights[i]
all_candidates.append((node, weight))
# Sort by weight descending and pick top num_replicas
all_candidates.sort(key=lambda x: x[1], reverse=True)
return [c[0] for c in all_candidates[:num_replicas]]
# Example: geo-distributed database with region and tenant dimensions
hasher = MultiDimensionalHasher(
dimensions=['region', 'tenant'],
nodes=['node-us-east-1', 'node-us-east-2', 'node-eu-west-1', 'node-ap-south-1'],
virtual_nodes=50
)
# Keys are routed based on both region AND tenant
key1 = {'region': 'us-east', 'tenant': 'tenant-42'}
key2 = {'region': 'us-east', 'tenant': 'tenant-42'} # Same tenant, same region
key3 = {'region': 'eu-west', 'tenant': 'tenant-42'} # Different region
print(hasher.get_node(key1)) # Likely returns node-us-east-1 or node-us-east-2
print(hasher.get_node(key2)) # Same result as key1 (deterministic)
print(hasher.get_node(key3)) # Likely returns node-eu-west-1
How the voting works:
- For each dimension, hash the attribute value and find the winning node on that dimension’s ring
- Tally votes: each dimension contributes its winner with that dimension’s weight
- Return the node with the highest total vote
The result is data-locality along multiple axes. Tenants in the same region end up on regional nodes, but different tenants in that region still distribute across the nodes in it.
Use case: geo-distributed databases. Systems like DynamoDB Global Tables or Cassandra with multi-DC replication benefit from multi-dimensional hashing. You want reads to hit the nearest replica for latency, but you also want tenant-level isolation. By hashing tenant_id on one dimension and using proximity-based replica selection on another, you get both properties.
Trade-offs:
| Aspect | Single-Dimension CH | Multi-Dimensional CH |
|---|---|---|
| Routing logic | O(log N) per dimension | O(D * log N) where D = dimensions |
| Data locality | One axis only | Multiple axes |
| Implementation | Simpler | More complex |
| Debugging | Easier to trace | Must track multiple rings |
Handling Hash Collisions Gracefully
Hash functions map arbitrary input to fixed-size output. With a large enough key space, two different keys can produce the same hash value. This is a collision. In consistent hashing, collisions create ambiguity: which node owns the key when two different keys hash to the same position on the ring?
Most consistent hashing implementations use 32-bit or 64-bit hash values. With 2^32 possible hash values, random collisions are essentially a non-issue for typical workloads — the probability of two unrelated keys hitting the same spot is vanishingly small. The actual problem is systematic bias: when a hash function or a clever attacker creates patterns that concentrate keys on specific ring positions.
Double hashing (also called rehashing) handles this by using a secondary hash function to compute a step size. When the primary hash points to an occupied slot, you walk forward by that step size until you find an open position:
def get_node(self, key, probe_limit=100):
"""
Double hashing: primary hash determines start position,
secondary hash determines step size for collision resolution.
"""
if not self.sorted_keys:
return None
primary_hash = self._hash(key)
secondary_hash = self._hash_secondary(key)
for i in range(probe_limit):
probe = (primary_hash + i * secondary_hash) % (2**32)
# Find the first node at or after this position
idx = bisect.bisect_right(self.sorted_keys, probe)
if idx >= len(self.sorted_keys):
idx = 0
node = self.ring[self.sorted_keys[idx]]
if node is not None:
return node
return None # Ring is full after probe_limit attempts
The secondary hash must be genuinely independent from the primary. Mixing hash function families — say, MurmurHash3 for the primary and a polynomial hash for the secondary — avoids correlation patterns that would defeat the whole scheme.
Bucket-based partitioning takes a different angle: store a small list of keys at each hash point instead of a single entry. Both colliding keys land in the same bucket on the same node. The tradeoff is a bit more metadata overhead, but you get deterministic collision handling with no probing required.
Salted hashing protects against deliberate collision attacks where someone generates keys that all hash to the same value. Prepend a server-specific salt to each key before hashing, and suddenly the attacker cannot predict where anything lands — not without knowing the salt:
def _hash_with_salt(self, key, node_id):
"""Prevents attackers from engineering hash collisions."""
import hashlib
salt = f"{node_id}:{self.cluster_salt}"
return int(hashlib.md5(f"{salt}:{key}".encode()).hexdigest(), 16) % (2**32)
When collisions matter in practice:
| Scenario | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Accidental key collision | Very low (2^32 space) | Both keys land on same node | None needed |
| Hot spot from systematic bias | Medium with poor hash | Single node overloaded | MurmurHash3, double hashing |
| Deliberate collision attack | Low unless key space is external | Single node overwhelmed | Salt hashes, rate-limit key creation |
| Virtual node hash collision | Low with 100+ VNs per node | Minor distribution unevenness | Increase virtual node count |
The practical summary: for normal keys, you will almost never encounter a collision that matters. The hash ring handles the rare ones correctly. Where you should focus is preventing systematic bias in your hash function and protecting the key space from external manipulation if it is ever exposed to untrusted input.
Real-world Failure Scenarios
Understanding how consistent hashing fails in production helps you design more resilient systems:
| Scenario | What Goes Wrong | How to Mitigate |
|---|---|---|
| Single node failure | Keys remap to neighbors, temporary hot spots on receiving nodes | Virtual nodes scatter redistribution; gradual node removal |
| Network partition | Partition minority cannot reach quorum, writes become unavailable | Use read-repair and hinted handoff for eventual consistency |
| Cascading failures | One node failure increases load on others, triggering more failures | Monitor node load, implement circuit breakers, auto-scale |
| Client ring cache staleness | Clients with old ring state route to wrong nodes | Short TTLs on ring state, push-based updates, version checks |
| Hash collision attacks | Malicious keys crafted to hash to same node overwhelms it | Use salted hashes, rate-limit key creation |
| Thundering herd on recovery | Recovered node suddenly receives all its keys back at once | Gradual re-homing, request coalescing, backpressure |
| Asymmetric cluster capacity | Nodes with different resources get equal ring share | Weighted virtual nodes, capacity-aware placement |
Why these scenarios matter: Consistent hashing guarantees even redistribution mechanics, but production resilience requires additional layers — health checks, quorum configuration, monitoring, and failure detection are not part of the hash ring itself.
Common Pitfalls / Anti-Patterns
These mistakes show up repeatedly in production systems using consistent hashing:
Configuration Pitfalls
Wrong Hash Function
The hash function you pick for consistent hashing sits in the hot path of every request. A poor choice adds latency at scale with no corresponding benefit. The core question is simple: do you need cryptographic properties, or do you need fast distribution?
SHA-256 is a cryptographic hash. It processes data in 64-byte blocks through 64 rounds of compression, and it is intentionally slow. On a modern server, SHA-256 handles roughly 1-2 million short hashes per second. MurmurHash3 handles 10-20 million in the same time. At 100,000 lookups per second, SHA-256 consumes meaningful CPU while MurmurHash3 barely registers. If your system is already CPU-bound, switching from SHA-256 to MMH3 can reclaim 10-15% of CPU capacity with no trade-off for routing accuracy.
MD5 has a different problem. It was broken as a cryptographic hash in 2004 when Wang et al. demonstrated practical collision attacks. For security-sensitive uses like password storage or integrity verification, MD5 is not an option. For routing, the non-cryptographic nature does not matter. But MD5 is also slow, roughly 2x slower than SHA-256. You end up with a broken cryptographic hash that is also slow. The only real reason to use MD5 for consistent hashing is compatibility with an existing system that already depends on it, like the original ketama library.
The practical choice is clear. For internal cache routing, CDN lookups, load balancers, and any system where keys come from trusted sources: use MurmurHash3 or xxHash. Both distribute keys evenly and are fast enough to disappear from latency profiles. For systems exposed to untrusted key input where someone could craft keys designed to collide on purpose: use SHA-256 or a keyed hash like HMAC-SHA256. Without the key, an attacker cannot predict where anything lands. Most production memcached clients dropped MD5 years ago once MMH3 and xxHash were available.
# Slow: SHA-256 for every lookup in a hot path
def slow_lookup(key):
return hashlib.sha256(key.encode()).hexdigest() % num_nodes
# Fast: MurmurHash3 for non-security routing
def fast_lookup(key):
return mmh3.hash(key) % num_nodes
Skipping Virtual Nodes
Deploying consistent hashing with zero or too few virtual nodes defeats the core benefit. A 3-node ring with 0 virtual nodes behaves like a standard modulo hash — adding one node remaps nearly all keys. Use at least 100 virtual nodes per physical node.
Why 100 is the starting point, not a magic number. Virtual nodes solve the distribution problem by giving each physical node multiple hash points on the ring. With 3 physical nodes and 0 virtual nodes, you have exactly 3 points. The hash values for those 3 points might cluster together if the node names hash to similar values, leaving portions of the ring underpopulated. With 100 virtual nodes per physical node, you get 300 points scattered around the ring by different hash values, and the law of large numbers smooths the distribution regardless of how node names hash. The 100 figure came from ketama’s original implementation at Last.fm and has held up in production — it is not derived from any formal analysis but from empirical testing across cluster sizes from 3 to 50 nodes.
The skew window shrinks as you add virtual nodes. With 10 virtual nodes per node, a 3-node cluster still shows measurable unevenness — some nodes might own 35% of the ring while others own 28%. With 100 virtual nodes, the maximum deviation in testing drops below 5% for clusters of 5 or more nodes. The tradeoff is metadata: each virtual node entry in the ring is a (hash_value, node_id) pair. With 100 VNs per node and 10 nodes, you store 1,000 entries. With 200 VNs, you store 2,000 entries. At 1,000 nodes, that is 100,000 entries — still manageable in memory on any modern server, but something to track.
When fewer virtual nodes are acceptable. For read-heavy workloads where you control the client library and can verify distribution empirically, you might start with 50 virtual nodes and increase if you see skew. For write-heavy workloads where distribution matters more, stick with 100+. If you are running fewer than 5 nodes and can accept higher variance, 50 is a reasonable floor. Below that, you are essentially back to modulo hashing with extra steps.
Ignoring Distribution Skew
Assuming the ring distributes keys evenly without monitoring. Even with virtual nodes, hot keys, skewed key distributions, or asymmetric server capacities cause imbalance. Set alerts for nodes serving >1.5× expected key count.
The three sources of skew in practice. First, logical skew: some keys are accessed far more frequently than others. A social media cache where “user:profile:popular_user” gets 10,000 requests per second lands on the same node as any other key, but the hot key’s node bears disproportionate load. Second, hash function skew: some hash functions produce biased distributions for real-world key patterns. MD5 has known biases in the lower bits for certain key formats. Third, capacity skew: your nodes might have different CPU, memory, or network capacities, but the ring treats them identically. A mix of older 8-core nodes and newer 32-core nodes will handle the same number of keys inefficiently if the smaller nodes are the bottleneck.
How to detect skew before it becomes a problem. The basic metric is key count per node, but key count alone misses hot keys. Track bytes served per node as well — a node with fewer keys but much higher byte throughput is likely receiving the hot key traffic. For cache workloads, measure hit rate per node: a node with significantly lower hit rate than the cluster average is likely receiving the hot key traffic. For database workloads, track queries per second and latency per node. Set absolute thresholds (alert if any node exceeds 2× average) and relative thresholds (alert if any node exceeds 1.5× the cluster median).
Remediating skew when you find it. If skew comes from hot keys, your options are: increase replication factor for those keys specifically (most systems do not support this automatically, so you would implement it at the application layer), add request coalescing so concurrent requests for the same hot key wait for one backend fetch, or split the hot key across multiple partitions using a key suffix. If skew comes from capacity mismatch, use weighted virtual nodes — assign more virtual nodes to higher-capacity nodes so they own a proportionally larger share of the ring. Most production systems find that 10-20% capacity headroom per node is enough to absorb normal skew without intervention.
Operational Pitfalls
Forgetting Ring State Sync
Clients caching stale ring state is one of the most common production issues. When nodes join or leave, clients running old ring configurations route to wrong nodes — causing cache misses, failed reads, or stale writes. Use short TTLs or push-based ring state updates.
Why staleness happens. Most consistent hashing clients compute ring positions locally rather than querying a central coordinator on every request. They fetch the ring state once at startup and cache it. When topology changes, the clients that fetched the ring before the change continue using the old ring indefinitely, until their cached state expires or they are restarted. In a cluster doing rolling deploys or experiencing failures, this means a subset of clients permanently miss routes to new nodes or keep routing to removed nodes.
How stale state manifests. A client that never refreshes its ring sends requests for a key to a node that no longer exists. The connection fails, and the request either errors out or gets retried with a fallback. Meanwhile, the key that should have landed on the new node never gets cached there. The new node sits idle while the old nodes handling orphaned keys are overloaded. This pattern is particularly insidious because it does not show up as an error on any single client. Each client is doing exactly what it was told to do.
Concrete mitigations. Set TTLs between 30 and 60 seconds for client-side ring caches. This window is short enough to catch topology changes quickly but long enough to avoid excessive overhead from frequent ring fetches. Alternatively, implement push-based ring invalidation where the coordinator pushes updated ring state to clients via a long-poll or WebSocket connection the moment a node is added or removed. HashiCorp’s consistent hashing library in memberlist supports this pattern. Some production systems use a hybrid: short TTL (30s) as a safety net plus push invalidation for sub-second convergence. Without either, you are eventually consistent in theory but broken in practice.
No Pre-Warming on Node Addition
Adding a new node and immediately sending traffic to it before data is populated causes a cold-cache storm. The new node has no data, every request misses and hits the backend. Always pre-warm before serving production traffic.
What pre-warming actually means. Pre-warming is the process of populating the new node’s cache with data from existing nodes before it starts serving production traffic. Without this step, the new node joins the ring cold. Every key that lands on it triggers a cache miss, and every cache miss hits the backend database. For a large cache cluster running at 80% hit rates, adding one un-warmed node can push the overall hit rate below 60%, which is often enough to overload the backend.
How to do it. The standard approach is parallel key pre-population. You generate the list of keys the new node will own based on the current ring state, then fetch those keys from the existing nodes in parallel and write them to the new node. With 100 virtual nodes per physical node, the new node owns roughly 1/N of the total key space. For a cluster of 10 nodes, that is about 10% of all keys. Pulling 10% of the cache from existing nodes over a short window avoids traffic spikes. Most cache clients (pylibmc, spymemcached) support batch fetch-and-populate operations for this purpose.
Sequential warm-up over staged traffic. Another approach is to add the new node to the ring but keep it in a “draining” state where it handles only a fraction of traffic (say, 5% of requests) while background pre-population runs. Gradually increase the traffic fraction as the cache fills. This approach works well when the key list is unknown upfront and the client library does not support pre-population directly. Watching cache hit rate on the new node during warm-up tells you when it is safe to send full traffic, typically when the hit rate matches the cluster average.
Simultaneous Multi-Node Changes
Adding or removing many nodes at once creates redistribution chaos. Each change compounds the redistribution from the previous one. Add nodes one at a time, waiting for stabilization between each.
Why simultaneous changes create compounding chaos. When you add one node to a 10-node ring, approximately 1/11 of keys (about 9%) remap to the new node. When you add a second node immediately after, approximately 1/12 of the remaining keys remap to that node. But the first addition already changed the ring — keys that would have landed on node 2 might have landed on node 1 in the original ring. Each topology change interacts with the previous one. In a production cluster doing a rolling upgrade where 3 nodes are replaced at once, you can see 20-30% of keys remap in rapid succession, each remap triggering cache misses and potential overload on the receiving nodes.
What “stabilization” means in practice. Stabilization is not just waiting for the ring state to propagate to clients. It also means waiting for the redistribution to complete — keys must be moved from their old owners to their new owners if you are doing data migration, not just logical remapping. For a cache, stabilization means the new node’s hit rate matches the cluster average. For a database, stabilization means the new node’s replication lag is below your threshold. In practice, this means waiting 30-60 seconds between node additions for caches, and monitoring the receiving node’s load during that window. For databases with significant data to transfer, you might wait 5-10 minutes between additions and monitor replication progress via nodetool netstats or equivalent.
The exception: planned full-cluster rebalancing. If you are doing a complete rebalance (for example, replacing all nodes with larger instances), you can add all new nodes simultaneously and remove all old nodes after data migration completes. In this scenario, the intermediate state has double the nodes, which is fine as long as your clients handle larger ring sizes and your network can handle the extra migration traffic. Some systems support “atomic slot migration” where the routing layer switches to the new node the moment migration completes, avoiding any window where both old and new nodes hold the same data.
Consistency Pitfalls
Ignoring Quorum Trade-offs
Setting quorum to ONE for “speed” without understanding the consistency implications. A node fails and you accept stale reads or lost writes. QUORUM is the right default for most systems.
What ONE actually gives you. With consistency level ONE, the coordinator sends the write to the nearest replica and considers the write successful when that single node acknowledges it. Reads also go to the nearest replica. The trade-off is that if the node you wrote to fails before replicating to other nodes, that write is gone, permanently. On the read side, if you read from a replica that has not yet received the latest write (because the primary was slow or the replication lag is high), you get stale data. ONE is the fastest option and continues serving writes as long as at least one replica is up, but it makes no promises about durability or freshness.
What QUORUM gives you instead. QUORUM requires acknowledgment from a majority of replicas before returning success. With replication factor 3, that means 2 of 3 replicas must acknowledge writes. Reads also quorum, you read from 2 replicas and return the most recent version. The result is that you can tolerate one replica failure without losing write availability, and reads return fresh data unless you consistently hit the same stale replica. The cost is higher latency (two network round-trips instead of one) and unavailability if more than one replica is down.
The actual decision framework. Use ONE when you are optimizing for write availability and can tolerate temporary inconsistency: logging, metrics, ephemeral sessions. Use QUORUM when you need strong consistency without paying the full ALL price: user-generated content, shopping carts, leaderboard scores. Use ALL when the data must not be lost under any circumstances and you can afford the latency: financial transactions, inventory changes.
No Failure Detection
Treating a slow or degraded node the same as a healthy one. A node experiencing network latency spikes still owns its virtual node points and receives full traffic. Use health checks and circuit breakers to route around degraded nodes.
What failure detection needs to catch. Complete node failure is obvious — the node stops responding entirely. Partial failure is harder: a node that is alive but experiencing 500ms latency instead of 5ms is still on the ring and still receiving requests, but every request to it is now slower than it should be. Cascading failures often start as partial degradation. One node’s increased load causes latency spikes, which cause timeouts on the client, which triggers retries that increase load further. Without detection and isolation, a degraded node can drag down its neighbors.
Building a health check that actually works. The minimum viable health check is an active probe: send a request to the node every N seconds and mark it degraded if it fails or exceeds a latency threshold. The threshold should be based on your normal latency plus a margin — 99th percentile latency × 2 is a common starting point. More sophisticated implementations track trends: a node whose latency has been climbing steadily for 5 minutes is a stronger signal than a single spike. Cassandra’s dynamic snitch does exactly this, combining latency measurements, infrastructure error rates, and throughput into a single “better node” score that influences routing decisions.
Circuit breakers close the loop. A health check tells you a node is degraded. A circuit breaker acts on that information. When a node’s error rate exceeds a threshold (say, 5% of requests failing), the circuit breaker opens and stops sending traffic to that node for a cooldown period. After the cooldown, it allows a small test traffic through. If the node still fails, the circuit stays open. This pattern prevents a degraded node from being hammered by retries while it is struggling. Most circuit breaker implementations let you configure the error threshold, the cooldown duration, and the number of requests to let through during testing. The specific values depend on your workload — a cache might use a 10% error threshold with a 30-second cooldown, while a database might use 5% with a 60-second cooldown.
Real-World Case Studies
Amazon DynamoDB
DynamoDB’s partition architecture uses consistent hashing at the partition level. Each table partition is assigned a range of the hash key space. When a partition exceeds its throughput limit, DynamoDB splits it — creating two new partitions with adjusted ranges. The routing layer uses the partition map to direct requests to the correct partition.
Key design choices from DynamoDB that reflect consistent hashing principles:
- Partition count scales with load: DynamoDB splits hot partitions, not just full ones
- Ordered partition keys: Using a UUID as partition key distributes evenly but prevents range queries; using a date prefix enables time-range scans but may create hot spots
- Replication across availability zones: Consistent hashing determines primary and replica placement
The lesson: consistent hashing handles distribution mechanics, but access pattern awareness (partition key design) determines whether you get hot spots or even load.
Cassandra
Apache Cassandra uses a ring where each node owns a range of tokens. Unlike textbook consistent hashing, Cassandra assigns specific token ranges to nodes explicitly (not via virtual nodes). The partitioner (RandomPartitioner or Murmur3Partitioner) determines how keys map to these ranges.
Cassandra’s approach differs from standard consistent hashing in several ways:
- No virtual nodes by default (though they can be enabled): token ranges are explicitly assigned
- snitch: determines topology awareness — which replicas are “closest” for read requests
- Queryable ring state:
nodetool ringshows exactly which node owns which range - Bootstrapping: new nodes claim specific token ranges from existing nodes, with data streamed in the background
The lesson: explicit token assignment gives operators more control but requires more manual planning than automatic virtual node placement.
Memcached + Ketama
Memcached has no native consistent hashing — clients implement it. The ketama library (developed by Last.fm in 2007) became the de facto standard. Its key innovations:
- 100 virtual nodes per server: smooth distribution even with few servers
- Continuum: a sorted list of (hash, server) points used for binary search
- Compatibility: ketama hash values are consistent across client implementations, so any ketama-compatible client can route to the same servers
Last.fm’s original ketama implementation computed SHA-1 hashes of server_ip:port:weight entries. Most production memcached clients (pylibmc, spymemcached, ketama) implement the same algorithm.
The lesson: client-side consistent hashing shifts complexity to the client but allows memcached’s simple server model to remain stateless.
Redis Cluster
Redis Cluster uses hash slots (16,384 total) rather than a true consistent hash ring. Keys map to slots via CRC16(key) % 16384. Slots are assigned to nodes explicitly, not via hash ring traversal. This hybrid approach gives Redis:
- Predictable slot ownership: each slot has exactly one primary owner (plus replicas)
- Migration support: slots can be moved between nodes without restarting the cluster
- Key-less rebalancing: Redis can migrate individual slots rather than relying on ring mechanics
Redis Cluster’s slot approach trades some elegance for operational simplicity — operators can see and move specific slot ranges rather than dealing with abstract ring positions.
Lessons Across All Systems
| System | Distribution Strategy | Replication Model | Routing |
|---|---|---|---|
| DynamoDB | Hash ring (managed) | Multi-AZ replicas | Server-side |
| Cassandra | Explicit token ranges | Tunable consistency | Client-side or server-side |
| Memcached + Ketama | Virtual nodes (100/server) | None (cache only) | Client-side |
| Redis Cluster | Hash slots (16,384) | Primary + read replicas | Server-side |
The common thread: consistent hashing mechanics (ring, virtual nodes, redistribution) appear in every system, but each makes different trade-offs based on whether they optimize for operational simplicity, client diversity, or explicit control.
Trade-off Analysis
When designing a consistent hashing system, you make choices that involve fundamental trade-offs:
| Design Choice | Benefit | Trade-off |
|---|---|---|
| More virtual nodes | Smoother key distribution | Higher memory overhead for ring metadata |
| Fewer virtual nodes | Lower memory footprint | Uneven distribution, especially with small clusters |
| Client-side routing | Lower latency (no coordinator hop) | Ring state must sync to all clients; harder to update |
| Server-side routing | Centralized ring management; simpler clients | Extra network hop; coordinator is a dependency |
| Single-dimension hashing | Simpler implementation; O(log N) lookup | Limited data locality for complex access patterns |
| Multi-dimensional hashing | Data locality across multiple axes | O(D × log N) lookup; more complex debugging |
| QUORUM consistency | Strong consistency with good availability | Slower than ONE; unavailable if majority down |
| ONE consistency | Fastest reads and writes | Risk of stale reads; write loss if replica fails |
| ALL consistency | Strongest consistency | Slowest; unavailable if any replica down |
The right choice depends on your priorities:
- Performance over consistency: Use ONE or eventual consistency with read-repair
- Consistency over performance: Use QUORUM or ALL, accept higher latency
- Operational simplicity: Server-side routing with a managed coordinator
- Maximum scalability: Client-side routing with push-based ring state distribution
- Geo-distribution: Multi-dimensional hashing with latency-aware replica selection
Quick Recap Checklist
- Standard hashing (hash % N) remaps all keys when N changes — consistent hashing avoids this
- Consistent hashing maps keys clockwise to first node on the ring
- Adding a node affects only neighboring keys (~1/N of total), not all keys
- Virtual nodes (100-200 per physical node) smooth distribution and prevent hot spots
- Use MurmurHash3 or xxHash for non-cryptographic hashing in consistent hashing
- Use SHA-256 only when hash collision attacks are a security concern
- Multi-dimensional consistent hashing provides data-locality across multiple axes
- Virtual node count too low causes uneven distribution, especially with small clusters
- Client ring state staleness causes routing to wrong nodes — use short TTLs or push-based updates
- Pre-warm new nodes before sending production traffic to prevent cold-cache storms
- Add/remove nodes one at a time, waiting for stabilization between changes
- Replication factor N means each key lives on N nodes; quorum = ceil(N/2)+1
- QUORUM consistency: R+W > N guarantees strong consistency
- With RF=3: ONE=1, QUORUM=2, ALL=3 — choose based on consistency vs performance needs
- Thundering herd: node failure causes sudden load on neighbors — mitigate with gradual rebalancing
- Health checks and circuit breakers route around degraded nodes, not just failed ones
- Range queries are inefficient with consistent hashing — fan out to many nodes
- No multi-key ACID transactions across consistent hash partitions without a coordinator
- DynamoDB Global Tables and Cassandra multi-DC use geo-aware consistent hashing variants
Interview Questions
With 10 physical nodes and 100 virtual nodes each, there are 1000 points on the ring. Each physical node owns roughly 1/10 of the ring. When one node fails, its 100 virtual nodes are removed, and keys that previously mapped to those points are redistributed to the next clockwise nodes. In total, approximately 1/10 of keys (10%) would need to remap — specifically, the keys that were mapped to the 100 virtual node points of the failed node.
Rendezvous hashing (highest random weight) computes a score for each key-server pair and picks the server with the highest score. It avoids the ring structure entirely and guarantees that adding N servers only affects keys that were previously mapped to those N servers — not neighboring keys. Consistent hashing's advantage is O(log N) lookup versus O(N) for rendezvous. You would choose rendezvous when you have a fixed server set, need minimal redistribution on changes, and can afford the O(N) lookup cost, or when you need deterministic behavior without a distributed ring state.
Virtual nodes solve the uneven distribution problem when physical node counts are small. Without virtual nodes, a 3-node ring has only 3 hash points — some nodes might end up with significantly more keys than others. With 100 virtual nodes per physical node, the law of large numbers smooths the distribution. The trade-off is increased metadata overhead: instead of storing N nodes, you store N×V virtual node entries. Lookup is still O(log N) if using a sorted ring, but the ring structure is larger. Additionally, when a node fails, the redistribution is more granular but affects more keys per virtual node failure.
The most likely cause is that the new nodes were added to the consistent hash ring and immediately started receiving requests for their hash ranges, but the data was not pre-warmed on them — so cold cache. The theoretical 1/N cache miss rate increase would be modest (from 1/10 to 1/12 ≈ 8% miss rate increase), not 35 percentage points. Possible additional causes: the new nodes have different configuration causing connection issues, the hash algorithm changed, or the node addition triggered a rebalancing that inadvertently invalidated a large portion of existing cached data. Diagnosis: compare cache key distribution before and after, check client logs for connection errors, verify the hash ring state on the clients, and check whether pre-warming ran after the deployment.
For writes with replication factor 3, the coordinator sends the write to the primary replica and two additional nodes in the ring. If one of the replicas is on the failed node, the write can still succeed as long as the coordinator receives acknowledgments from the remaining two replicas (depending on consistency level — ONE, QUORUM, or ALL). For reads, if the consistency level is ONE and the replica contacted is stale, you get a stale read. If using QUORUM, reads quorum with the available replicas and may still return fresh data. The key risk: if the failed node held the primary replica for a significant number of keys, and the remaining nodes cannot reach quorum, writes may be unavailable. The anti-entropy process catches up after the node recovers, but the 30-minute window means up to 30 minutes of writes may need replay.
Ring rebalancing occurs when nodes are added or removed from the consistent hash ring, triggering redistribution of keys. In production, this matters because uncontrolled rebalancing causes cache misses, database load spikes, and potential availability issues. Strategies to minimize disruption include: (1) Adding nodes gradually — one at a time, waiting for stabilization between additions. (2) Pre-warming caches on new nodes before they start serving traffic. (3) Using virtual nodes with small hash space per VN to limit the impact of each change. (4) Implementing double-linking where the old owner serves requests while the new owner pulls data in background. (5) Using atomic routing switches once the new node has caught up. DynamoDB and Cassandra use variations of these strategies.
Standard consistent hashing uses a single hash value to route keys to nodes, giving you one axis of distribution. Multi-dimensional consistent hashing runs independent hash rings for each dimension (e.g., region and tenant_id) and combines results through weighted voting. You would use it when you need data-locality along multiple axes — for example, a geo-distributed database where you want reads to hit the nearest replica (geographic dimension) while also distributing load across tenants (tenant dimension). The tradeoff is increased complexity: O(D * log N) lookup instead of O(log N), where D is the number of dimensions. DynamoDB Global Tables and Cassandra with multi-DC replication use variations of this approach.
Jump Consistent Hashing (Google, 2017) is an algorithm that avoids the ring structure entirely. It assigns keys to buckets deterministically using a formula: given a key and number of buckets, it computes which bucket receives the key. When buckets are added or removed, keys redistribute deterministically without a global ring structure or binary search. The key difference is no virtual nodes needed, no ring state to maintain, and O(1) space for the node list. However, it works best for hundreds of nodes, and adding nodes can cause more remapping than classic consistent hashing for small cluster sizes. It is not as widely adopted as ketama-style or classic consistent hashing.
Consistent hashing alone does not solve hot spots — it distributes keys evenly, but if certain keys receive disproportionate traffic, those keys' nodes become bottlenecks. With replication factor 3, a hot key's replicas distribute read load across 3 nodes, but writes still hit the primary. Mitigation strategies include: (1) Increase replication factor for hot keys specifically. (2) Use client-side caching in front of consistent hashing. (3) Implement request coalescing so concurrent requests for the same hot key result in a single backend request. (4) Add a random suffix to hot key prefixes to spread them across more partitions (DynamoDB pattern). (5) Consider a hybrid approach where popular keys use a different distribution strategy than cold keys. Consistent hashing handles distribution, not access pattern skew.
Hash collisions occur when two different keys produce the same hash value. In consistent hashing, when a collision happens, the first node clockwise from that hash position handles both keys — correct behavior, but collisions at the same position cause uneven distribution. Double hashing mitigates this by using two independent hash functions: the primary determines ring position, and when a collision is detected (or for secondary lookup), the secondary function determines an alternative position. This spreads colliding keys across different ring positions. The tradeoff is slightly more complex lookup (though still O(log N)), and the secondary hash must be truly independent — using the same algorithm family can reintroduce correlation patterns.
A quorum defines how many replicas must acknowledge a write (or participate in a read) for the operation to succeed. Common quorum levels: ONE (any replica), QUORUM ((RF/2)+1), ALL (all replicas). With RF=3: ONE=1, QUORUM=2, ALL=3. Higher quorum means stronger consistency but lower availability — if 2 of 3 nodes are down, QUORUM and ALL cannot succeed, but ONE can. QUORUM gives the best balance for most use cases: writes require 2 acknowledgments, reads quorum 2 replicas, so you get fresh data unless you consistently hit the same stale replica. In partition scenarios, quorum-based systems choose consistency over availability — they refuse to serve writes if quorum cannot be reached rather than risk divergent data.
This is a gradual migration problem. The approach is hash ring versioning: (1) Introduce a new hash function with a version flag — each client computes both old and new ring positions. (2) Route reads using the version the data was written with, or compute both and merge. (3) During migration, write to both old and new hash positions. (4) Run both rings simultaneously during a transition window, monitoring that all keys are accessible via the new ring. (5) Once all data is accessible via the new ring, decommission the old ring. This is exactly how Reddit handled their Memcached client hashing algorithm change, avoiding the cache miss spike that would have hit their database.
Client-side routing: clients compute ring positions directly. Pros: no extra hop, lower latency, no coordinator dependency. Cons: all clients must implement consistent hashing logic, ring state must sync to all clients, harder to deploy algorithm changes, risks inconsistent ring views. Server-side/coordinator routing: a coordinator service (like ZooKeeper, etcd, or a dedicated routing layer) knows the ring and routes all requests. Pros: simpler clients, centralized ring management, easier to enforce routing policies. Cons: extra network hop, coordinator is a dependency/possible bottleneck, coordinator must handle its own scaling and availability. Systems like DynamoDB use server-side routing; Memcached clients use client-side routing.
The thundering herd problem occurs when many keys remap to the same node(s) simultaneously — typically when a node fails and all its keys redistribute to the next clockwise nodes. Those receiving nodes can be overwhelmed by the sudden load spike. Mitigations include: (1) Virtual nodes spread redistribution — when a physical node fails, its keys scatter across many virtual node points on different physical nodes. (2) Gradual rebalancing — new nodes claim keys incrementally rather than all at once. (3) Request coalescing — multiple requests for the same key wait for a single backend fetch. (4) Backpressure — rate-limit new requests during rebalancing to give nodes time to absorb the load. Cassandra's hinted handoff and DynamoDB's adaptive capacity are examples of thundering herd mitigations.
Range partitioning stores keys in sorted order by partition key, so keys within a range reside on the same node(s) — range queries are efficient, often hitting a single node. Consistent hashing intentionally distributes keys across nodes with no ordering guarantee, so a range query must fan out to many nodes and merge results — O(N) in the worst case where N is the number of nodes. This is a fundamental trade-off: consistent hashing optimizes for even distribution and node addition/removal graceful handling, while range partitioning optimizes for range query performance. Systems like DynamoDB compromise by using consistent hashing at the partition level but supporting range queries within a partition — you get both properties with careful partition key design.
Split brain occurs when network partition separates nodes into two or more groups, each believing the other side is dead. Without coordination, both sides may accept writes for the same keys independently, causing divergent data. Quorum-based systems prevent split brain by requiring that any write or read must gather acknowledgments from a majority of replicas. If a partition separates nodes but neither partition has a majority, operations fail — the system chooses unavailability over inconsistency. The remaining partition with a majority continues serving reads and writes normally. Once the partition heals, anti-entropy protocols synchronize divergent replicas.
Consistent hashing determines where replicas live; eventual consistency determines when writes become visible. These are orthogonal concerns. A consistent hash ring places primary and replica copies on specific nodes, but once placed, the replication protocol (gossip, read-repair, hinted handoff) handles propagation on its own timeline. Eventual consistency means updates may not immediately appear on all replicas — reads may return stale data until anti-entropy converges. Stronger consistency (QUORUM, ALL) reduces but does not eliminate this window. Consistent hashing does not inherently provide consistency guarantees; it provides placement and redistribution mechanics.
Multi-key transactions across consistent hash partitions face a fundamental problem: keys in the same transaction may reside on different nodes. Without a distributed transaction coordinator (2PC), atomic multi-key updates are impossible — node A may commit while node B fails. Consistent hashing systems often relax transaction support: transactions spanning multiple partitions are not supported (DynamoDB), or require a coordinating node that gates all partition writes (Cassandra). For use cases requiring true ACID transactions across shards, consider coordination-based approaches (Google Spanner) or accept that consistent hashing systems trade distributed transaction support for availability and partition tolerance.
You need two layers: a consistent hash ring per region, then a geo-router on top. First, partition nodes by region — each region has its own ring with local virtual nodes. Then, route requests to the nearest region's ring using latency probes or static latency maps (us-east-1: 5ms, eu-west-1: 80ms). Replicas for a key live on multiple regional rings — one primary in the local region, secondaries in other regions. The global router selects the nearest available replica with a fresh enough version (or quorum). This is how DynamoDB Global Tables and Cassandra multi-DC work. The tradeoff is added routing complexity and cross-region replication lag.
Partial network degradation (high latency, packet loss) is harder for consistent hashing to handle than complete failure. The ring topology remains intact — the node still owns its virtual node points — but requests routed to it may time out. Without explicit latency awareness, a slow node receives the same traffic as a healthy one. Mitigations: (1) Client-side timeouts with retry to the next replica in the ring. (2) Coordinators that detect slow nodes via health checks and route around them (Cassandra's dynamic snitch). (3) Circuit breakers that temporarily exclude degraded nodes from the ring. (4) Latency-sensitive load balancing that weights health checks into replica selection. The ring state does not change; only routing policies adapt.
Further Reading
Key References:
- Consistent Hashing and Random Trees — Karger et al.’s original 1997 paper
- Ketama — The original Memcached consistent hashing implementation
- Dynamo: Amazon’s Highly Available Key-value Store — Original DynamoDB design paper
- Cassandra Architecture — Hash ring implementation in Cassandra
- Memcached Full Documentation — Client-side consistent hashing options
Related Posts:
- Horizontal Sharding — Database sharding strategies and trade-offs
- Database Scaling — Vertical vs horizontal scaling approaches
- NoSQL Databases — When NoSQL makes sense and trade-offs
- Relational Databases — RDBMS fundamentals and scaling patterns
Conclusion
Use this checklist when designing or reviewing a consistent hashing implementation:
- Hash function chosen: [ ] MMH3 [ ] xxHash [ ] SHA-256 (circle one)
- Virtual nodes configured: ___ per physical node (recommend 100-200)
- Replication factor set: ___ (recommend 3 for most systems)
- Quorum configuration: [ ] ONE [ ] QUORUM [ ] ALL for reads/writes
- Node addition plan documented (add one, wait, repeat)
- Pre-warming strategy defined for new nodes
- Health checks configured for slow/degraded nodes
- Ring state synchronization mechanism in place
- Monitoring for distribution skew (alerts if node >1.5× average keys)
- Rebalancing strategy documented for node removal
- Failure scenario runbooks reviewed
Core mechanics to remember:
- Keys map clockwise to first node on the ring
- Adding a node affects only neighboring keys (~1/N of total)
- Virtual nodes smooth distribution by adding multiple hash points per physical node
- Replication factor N means each key lives on N nodes; quorum = ceil(N/2)+1
- Client ring state staleness causes inconsistent routing — use short TTLs or push-based updates
Category
Related Posts
Cache Stampede Prevention: Protecting Your Cache
Learn how single-flight, request coalescing, and probabilistic early expiration prevent cache stampedes that can overwhelm your database.
Asynchronous Replication: Speed and Availability at Scale
Learn how asynchronous replication works in distributed databases, including eventual consistency implications, lag monitoring, and practical use cases where speed outweighs strict consistency.
Distributed Systems Primer: Key Concepts for Modern Architecture
A practical introduction to distributed systems fundamentals. Learn about failure modes, replication strategies, consensus algorithms, and the core challenges of building distributed software.