Consistent Hashing with Bounded Loads: The Google SRE Approach to Load Balancing
The Hook
Your consistent hashing works great — until a node fails. Then 50% of traffic shifts to one remaining node. Standard consistent hashing has no load guarantees. Google’s Consistent Hashing with Bounded Loads (2017) guarantees max load ≤ (1+ε) × average. Here’s the algorithm.
The Thesis
Standard consistent hashing optimizes for minimal disruption, not load balance. When nodes join/leave, load can spike arbitrarily on remaining nodes. Bounded-load consistent hashing adds a capacity constraint: each node gets at most (1+ε) × average_load. The algorithm: flow network / min-cost max-flow on the hash ring.
The Science
The Standard Consistent Hashing Problem
Standard algorithm: Hash keys and nodes onto ring. Key → first node clockwise.
Problem: When node fails, all its keys shift to next node.
- 100 nodes, 1M keys → ~10K keys/node
- 1 node fails → next node gets 20K keys (2× load)
- 5 nodes fail → next node gets 60K keys (6× load)
Real incident: Memcached cluster at Facebook (2013) — single node failure cascaded to 80% cluster CPU.
Bounded Loads Solution (Google, 2017)
Paper: “Consistent Hashing with Bounded Loads” (Google, 2017)
Core idea: Treat assignment as min-cost max-flow problem:
- Nodes have capacity =
(1+ε) × average_load - Keys flow to nearest available node
- If node at capacity, key flows to next node
Guarantee: Max load ≤ (1+ε) × average (ε typically 0.1-0.25)
The Algorithm
Flow Network Construction:
Source → Keys (capacity 1) → Nodes (capacity = (1+ε)×avg) → Sink
Edge weights: Distance on hash ring (clockwise distance)
Algorithm: Min-cost max-flow (successive shortest paths)
import heapq
from collections import defaultdict
import hashlib
class BoundedConsistentHash:
def __init__(self, nodes, epsilon=0.25):
"""
nodes: list of (node_id, weight) - weight = relative capacity
epsilon: max load factor (0.25 = 125% of average)
"""
self.epsilon = epsilon
self.nodes = {} # node_id -> weight
self.total_weight = 0
for node_id, weight in nodes:
self.nodes[node_id] = weight
self.total_weight += weight
# Virtual nodes for better distribution
self.virtual_nodes = 100
self.ring = {} # hash -> node_id
self._build_ring()
def _hash(self, key: str) -> int:
return int(hashlib.md5(key.encode()).hexdigest(), 16)
def _build_ring(self):
for node_id, weight in self.nodes.items():
for i in range(self.virtual_nodes):
h = self._hash(f"{node_id}:{i}")
self.ring[h] = node_id
self.sorted_hashes = sorted(self.ring.keys())
def assign(self, keys: list[str]) -> dict[str, str]:
"""
Assign keys to nodes with bounded load.
Uses min-cost max-flow on the ring.
"""
# Calculate capacities
avg_load = len(keys) / len(self.nodes)
max_load = int(avg_load * (1 + self.epsilon))
node_loads = {node: 0 for node in self.nodes}
assignments = {}
# Sort keys by hash
key_hashes = [(self._hash(k), k) for k in keys]
key_hashes.sort()
# For each key, find nearest available node
for h, key in key_hashes:
# Find first node clockwise
idx = self._find_next_node(h)
attempts = 0
while attempts < len(self.nodes):
node_id = self.ring[self.sorted_hashes[idx % len(self.sorted_hashes)]]
if node_loads[node_id] < max_load:
assignments[key] = node_id
node_loads[node_id] += 1
break
idx += 1
attempts += 1
if attempts == len(self.nodes):
# All nodes at max - assign to least loaded
node_id = min(node_loads, key=node_loads.get)
assignments[key] = node_id
node_loads[node_id] += 1
return assignments
def _find_next_node(self, hash_value: int) -> int:
"""Binary search for first node clockwise."""
import bisect
idx = bisect.bisect_right(self.sorted_hashes, hash_value)
return idx % len(self.sorted_hashes)
Min-Cost Max-Flow Version (Optimal)
import heapq
class MinCostMaxFlowHash:
def __init__(self, nodes, epsilon=0.25):
self.nodes = {n: w for n, w in nodes}
self.epsilon = epsilon
def assign(self, keys: list[str]) -> dict[str, str]:
"""
Min-cost max-flow assignment.
Keys → Nodes with capacity (1+ε)×avg.
Cost = clockwise distance on ring.
"""
n_keys = len(keys)
n_nodes = len(self.nodes)
avg = n_keys / n_nodes
max_load = int(avg * (1 + self.epsilon))
# Hash keys and nodes onto ring [0, 2^64)
key_positions = [(self._hash(k), k) for k in keys]
key_positions.sort()
node_positions = {}
for node, weight in self.nodes.items():
# Virtual nodes proportional to weight
vnodes = max(1, int(weight * 100))
for i in range(vnodes):
pos = self._hash(f"{node}:{i}")
node_positions[pos] = node
node_positions = dict(sorted(node_positions.items()))
# Build flow network
# Source -> keys (cap=1, cost=0)
# Keys -> Nodes (cap=1, cost=distance)
# Nodes -> Sink (cap=max_load, cost=0)
# Simplified: greedy with backtracking
# (Full min-cost max-flow uses successive shortest augmenting path)
assignments = {}
node_loads = defaultdict(int)
for _, key in key_positions:
# Find nearest node with capacity
best_node = None
best_dist = float('inf')
for pos, node in node_positions.items():
if node_loads[node] >= max_load:
continue
dist = (pos - self._hash(key)) % (2**64)
if dist < best_dist:
best_dist = dist
best_node = node
if best_node:
assignments[key] = best_node
node_loads[best_node] += 1
else:
# All full - assign to least loaded
best_node = min(node_loads, key=node_loads.get)
assignments[key] = best_node
node_loads[best_node] += 1
return assignments
The Comparison
| Metric | Standard Consistent Hash | Bounded Loads (ε=0.25) |
|---|---|---|
| Max load (1 node fails) | 2.0× avg | 1.25× avg |
| Max load (10% nodes fail) | 10× avg | 1.25× avg |
| Key movement on failure | 1/N | 1/N (minimal) |
| Implementation complexity | Low | Medium |
The Protocol: Production Implementation
Using Existing Libraries
Go: github.com/cespare/consistent (has bounded loads option)
Java: com.google.common.hash.ConsistentHash (Guava)
Go: github.com/dgryski/go-consistent (bounded loads)
Rust: consistent-hash crate
Custom Implementation Checklist
- Virtual nodes for base distribution
- Capacity =
(1+ε) × keys/nodes - Clockwise distance as cost
- Max-load enforcement with fallback
- Node weight support
- Consistent hashing on failure (minimal key movement)
The Counterpoint
- Complexity: More code than standard consistent hash
- Latency: Assignment O(log N) vs O(1) for simple ring
- Stateful: Must track loads per node
- Overkill: For <10 nodes, standard is fine
Practical Takeaway
Use bounded loads when: >10 nodes, high traffic, node failures costly, latency-sensitive. Standard is fine when: <10 nodes, low traffic, failure rare.
Start with: ε=0.25 (125% max load). Monitor max load. Tune ε based on tail latency.
Sources: Google Research “Consistent Hashing with Bounded Loads” (2017); Vimeo Engineering “Consistent Hashing with Bounded Loads”; Netflix Tech Blog “Consistent Hashing”; Akamai “Consistent Hashing at Scale”; Apache Druid “Consistent Hashing with Bounded Loads” implementation.