Consistent Hashing
Spread keys over servers so that adding or removing one moves only its share: the hash ring, virtual nodes, and what moves when a node joins or dies.
On this page
A cache cluster has 4 nodes holding 10 million keys and serves 50,000 reads a second. 95% are hits, so the database sees 50,000 × 0.05 = 2,500 reads a second. Each key's node is chosen with hash(key) mod 4. Traffic grows, so someone adds a fifth node and the code switches to mod 5.
A key stays on the same node only if its hash leaves the same remainder for 4 and 5, which happens for 4 values in every 20. So 80% of keys now point at a node that does not have them. The hit rate falls to about 0.95 × 0.2 = 19%, and the database goes from 2,500 to 50,000 × 0.81 = 40,500 reads a second. Adding capacity caused the outage.
The idea
Consistent hashing places keys so that when a node joins or leaves, only about 1/N of them move, not almost all. Going from 4 to 5 nodes, the new node takes its fair share of 10 million ÷ 5 = 2 million keys, and every other key stays where it was.
It fits wherever keys spread over a changing set of machines: cache clusters, partitioned key-value stores, sticky load balancing. The cost is a little bookkeeping in every client.
The ring
Hash every server name and every key into the same range of numbers, and bend that range into a circle. Real rings use 0 to 232 − 1 or more; to trace by hand, use 0 to 99. A key belongs to the first server clockwise from its position, wrapping past 99 to 0.
Each arrow points from a key to its owner. Watch what moves when a server joins, then leaves.
- keys moved
- 0
1Servers A, B and C sit at 10, 40 and 75. A owns 76 to 99 and 0 to 10 (35 positions), B owns 11 to 40 (30), C owns 41 to 75 (35).
- keys moved
- 0
2Key 90: no server between 90 and 99, so the walk wraps past 0 to A.
- keys moved
- 0
3Server D joins at 60. It takes positions 41 to 60 from C, the next server clockwise. Only keys in that stretch can move.
- keys moved
- 1
4Key 52 is in 41 to 60, so it moves from C to D. Key 68 is past 60 and stays on C. Keys on A and B are not touched. 1 of 5 keys moved.
- keys moved
- 2
5Now B leaves. Its keys (positions 11 to 40) go to the next server clockwise, which is now D. Key 25 moves to D. Nothing else changes.
- keys moved
- 2
6The catch: D inherited all of B's range and owns 11 to 60, 50 of 100 positions, while C owns 15. One point per server gives lopsided shares.
Virtual nodes
The fix is to give each server many points on the ring, called virtual nodes: hash "A#0", "A#1", up to "A#99". Each server owns 100 small scattered arcs instead of one big one. Three things improve:
- Even shares. A server's share is the sum of many random arcs, so its deviation from the average shrinks roughly as 1 ÷ √v for v points each: about 10% at 100 points.
- Even recovery. When a server leaves, each of its 100 arcs goes to a different neighbour, so its load spreads over all the others instead of landing on one.
- Weights. A server with twice the memory gets 200 points and about twice the keys.
A lookup is a binary search in the sorted list of points: the first point at or after the key's hash, wrapping to the start. The hash must mix its input well (here, the first 32 bits of MD5); a hash that mixes poorly can bunch the points of similar names like "A#0" and "A#1",. If hashing itself is new, see hash tables.
const crypto = require("crypto");
function hash(s) { // first 32 bits of MD5
return parseInt(crypto.createHash("md5").update(s).digest("hex").slice(0, 8), 16);
}
class Ring {
constructor(vnodes = 100) {
this.vnodes = vnodes;
this.points = []; // [position, node], sorted
}
add(node) {
for (let i = 0; i < this.vnodes; i++) this.points.push([hash(`${node}#${i}`), node]);
this.points.sort((a, b) => a[0] - b[0]);
}
remove(node) {
this.points = this.points.filter(([, n]) => n !== node);
}
lookup(key) {
const h = hash(key);
let lo = 0, hi = this.points.length; // first point at or after h
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (this.points[mid][0] < h) lo = mid + 1; else hi = mid;
}
return this.points[lo % this.points.length][1]; // past the end: wrap to 0
}
}import bisect
import hashlib
def ring_hash(s):
# first 32 bits of MD5
return int(hashlib.md5(s.encode()).hexdigest()[:8], 16)
class Ring:
def __init__(self, vnodes=100):
self.vnodes = vnodes
self.points = [] # (position, node), sorted
def add(self, node):
for i in range(self.vnodes):
bisect.insort(self.points, (ring_hash(f"{node}#{i}"), node))
def remove(self, node):
self.points = [p for p in self.points if p[1] != node]
def lookup(self, key):
# first point at or after the key's hash; past the end: wrap to 0
i = bisect.bisect_left(self.points, (ring_hash(key), ""))
return self.points[i % len(self.points)][1]When a cache node dies
Back to the 4-node cache cluster, now on a ring with 100 virtual nodes each. Every app server holds the same ring and finds a key's node.
- db reads/s
- 2,500
1An app server needs
user:7. It hashes the key, binary-searches its copy of the ring, and gets cache C. Hit.- db reads/s
- 2,500
2Cache C dies. Reads for its keys time out.
- db reads/s
- 2,500
3Health checks remove C from every ring. Each of C's 100 arcs now belongs to the next point clockwise: A, B or D in roughly equal parts.
- db reads/s
- ≈ 14,000
4C held about a quarter of the keys, and their new owners do not have them yet. Those reads miss and go to the database: 50,000 × (0.05 + 0.95 × 0.25) ≈ 14,000 reads a second, until refilled.
- db reads/s
- ≈ 14,000
5Keys on A, B and D are untouched and still hit, and the misses refill over time. With
mod N, going from 4 to 3 nodes would move 75% of keys: about 38,000 database reads a second.- db reads/s
- brief rise
6C restarts. Its arcs move back to it, and only those keys miss again. Start it empty: values it held from before may have changed while it was down.
Other ways to place keys
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| hash mod N | One line of code, perfectly even. | Changing N moves almost every key (80% going from 4 to 5). | The node count never changes. |
| Ring with virtual nodes Usual default | Joins and leaves move about 1/N of keys. Supports weights. Each client computes placement alone. | Needs many points per node to be even. Every client must see the same membership. | Cache clusters and leaderless stores with nodes coming and going. The usual answer. |
| Rendezvous hashing | Score each node with hash(key, node), take the highest. Even, minimal movement, no ring. | A lookup scores every node: O(N) per key. | A small node count, such as choosing among tens of servers. |
| Fixed partition table | Hash into many fixed partitions; a table maps partitions to nodes. Moves are explicit and easy to reason about. | The table must be stored, versioned and distributed. | Databases that move data deliberately, as in sharding. |
Numbers worth remembering
- Adding the (N+1)th node moves about 1/(N+1) of keys with consistent hashing; with
mod N, about N/(N+1). - Virtual nodes: 100 to 200 per server is a common range; spread shrinks as 1 ÷ √v.
A cache uses hash(key) mod 4 over 10 million keys. A fifth node is added and the code switches to mod 5. How many keys now map to a different node?
- 10 million keys, hashes spread evenly
- Old placement: hash mod 4. New: hash mod 5
Show the working
- A key stays only if hash mod 4 = hash mod 5. Over any 20 consecutive hashes that holds for 0, 1, 2 and 3: 4 in 20
- Moved: 10 million × 16/20 = 8 million
- With consistent hashing: about 10 million ÷ 5 = 2 million
Estimate 8 million (anything from 3 million to 10 million is a good answer)
In the interview
Bring it up when keys are spread across nodes that come and go. Say the problem it solves in numbers, then mention virtual nodes.
The cache needs a fifth node. Any problem?
If placement is hash mod N, going from 4 to 5 remaps 80% of keys and the database takes most of the reads at once. I'd use consistent hashing with about 100 virtual nodes per server: the new node takes roughly a fifth of the keys, spread evenly across the old ones, and nothing else moves.
Does that fix a single very hot key?
No. One key still lives on one node. For that I'd keep a copy of the hot key in each app server's memory for a few seconds, or replicate it to several nodes and pick one at random.
Follow-ups you should expect:
- "How do clients agree on the ring?" Membership comes from a configuration service or gossip. During a change clients briefly disagree, so a few requests miss.
- "How does replication fit?" Store each key on the next 3 distinct servers clockwise, skipping extra points of a server already chosen.
Common mistakes
Ring of 0 to 99 with servers at 20, 50 and 80. Which server owns key 95?
A 9-node ring with virtual nodes gets a tenth node. About what share of keys move?
Why do virtual nodes help when a server dies?
What to practise next
Next in this module, blob storage moves from rows to large files. A ring with replication is the core of Design a Key-Value Store.