- Routing keys with
hash(key) mod Nis perfectly even — until N changes: going from 4 to 5 servers moves ~80% of all keys, which for a cache means a sudden flood of misses onto the database. - Consistent hashing places both servers and keys on a ring (the hash space bent into a circle). A key belongs to the first server clockwise from it, found by binary search over sorted server positions.
- Adding or removing a server moves only the keys in one arc — about K/N keys (≈20% when going from 4 to 5), and they move only to/from the changed server.
- Virtual nodes (many ring positions per server) fix uneven arcs, spread a failed server's load across all survivors, and let bigger machines take proportionally more keys. Replicas are the next N distinct servers clockwise.
- Used by Cassandra, Dynamo/Riak, memcached clients (ketama), load balancers (Envoy, HAProxy, Nginx) and CDNs. Alternatives: rendezvous (HRW), jump consistent hash, and consistent hashing with bounded loads. At Instagram it underpins cache clusters and Cassandra's token ring.
1. The problem: why hash(key) mod N breaks when N changes
Instagram's feed cache spreads hundreds of millions of keys (timelines, counters, profile objects) across many Redis/Memcached servers. Each app server must decide, with no coordination, which cache server holds a key. The obvious answer:
server_index = hash(key) mod N # N = number of cache servers
It's fast, stateless and — with a good hash — spreads keys evenly. The problem is what happens when N changes: a server crashes (N decreases) or you add capacity before a big event (N increases). Almost every key's mod N result changes, so almost every key is now "on the wrong server".
1.1 Worked example: 12 keys, 4 → 5 servers
Take keys whose hashes are 0–11 for simplicity:
| hash(key) | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| mod 4 | 0 | 1 | 2 | 3 | 0 | 1 | 2 | 3 | 0 | 1 | 2 | 3 |
| mod 5 | 0 | 1 | 2 | 3 | 4 | 0 | 1 | 2 | 3 | 4 | 0 | 1 |
| Moved? | – | – | – | – | ✘ | ✘ | ✘ | ✘ | ✘ | ✘ | ✘ | ✘ |
8 of 12 keys moved (67%) here. In general a key stays put only if h mod 4 = h mod 5, which is true for 4 out of every 20 consecutive hash values — so over many keys, 80% move. The general rule for going from N to N+1 servers with mod hashing: only about 1/(N+1) of keys stay; the fraction that moves is N/(N+1). The bigger the cluster, the worse it gets: going from 100 to 101 servers moves ~99% of keys — to add 1% capacity!
Assume Instagram's cache tier: 100 M hot keys on 4 servers, 95% hit ratio, 1 M reads/s.
Add a 5th server with mod-N routing:
80 M keys are now looked up on the "wrong" server → miss
hit ratio collapses from 95% to roughly 0.95 × 20% ≈ 19%
database reads jump from 50,000/s (5% misses) to ~810,000/s → the database falls over.
Removing a failed server (4 → 3) is just as bad: a key stays only if h mod 4 = h mod 3,
true for 3 of every 12 values → 75% of keys move, at exactly the moment you're already degraded.
For a database (rather than a cache), "moving" means physically copying 80% of the data across the network while serving traffic. We need a mapping where adding or removing one server moves only the keys that must move — ideally just 1/N of them. That's what consistent hashing provides (Karger et al., 1997, originally designed for distributed web caching — the work that led to Akamai's CDN).
2. The hash ring
Take a hash function that outputs, say, a 32-bit number: 0 to 232 − 1 (≈ 4.3 billion). Now imagine bending that number line into a circle, so that after 232 − 1 comes 0 again. This is the hash ring (Cassandra's Murmur3 ring uses the 64-bit range −263 … 263 − 1).
- Place the nodes. Hash each server's identifier (e.g.
hash("cache-a")or its IP) to get its position on the ring. Each node's position is called its token. - Place the keys. Hash each key (
hash("user:42:timeline")) with the same function to get its position. - Assign ownership. Each key belongs to the first node at or after its position, moving clockwise — the key's successor. Equivalently, each node owns the arc of the ring from the previous node's position (exclusive) up to its own position (inclusive).
0 / 2^32
.----- ●A (pos 20) -----.
k4 •' `• k1 ──► owned by B
●D / \
(290) | ring positions |
k3 • ──► D | increase clockwise • k5 ──► B
\ /
`. .● B (pos 110)
`----- ●C (200) -----• k2 ──► C
Rule: walk clockwise from the key's position; the first node you meet owns it.
A owns (290, 20], B owns (20, 110], C owns (110, 200], D owns (200, 290]
Note that the hash of a node and the hash of a key live in the same space, and nothing about a key's position depends on how many nodes exist. That independence is the whole trick.
3. Lookup: clockwise successor via binary search
Implementation is simple: keep the node positions in a sorted array. To find a key's owner, hash the key and binary-search for the first position greater than (or equal to) the key's hash. If you run off the end of the array, wrap around to index 0 (that's the "ring").
positions = [20, 110, 200, 290] # sorted node tokens
owner = {20: "A", 110: "B", 200: "C", 290: "D"}
lookup(hash = 75): first position > 75 → 110 → "B"
lookup(hash = 290): first position >= 290 → 290 → "D" (bisect_left treats equal as owned)
lookup(hash = 300): nothing > 300 → wrap to index 0 → 20 → "A"
Cost: with M total positions on the ring, a lookup is O(log M) — for 100 servers × 200 virtual nodes = 20,000 positions, that's about 15 comparisons, i.e. well under a microsecond. Adding a node is O(M) with a sorted array (insertion) or O(log M) with a balanced tree — irrelevant in practice because membership changes are rare. Every client computes the same answer independently, as long as everyone has the same membership list (distributed via config service, gossip, or a ZooKeeper/etcd watch).
4. Adding and removing nodes: only ~K/N keys move
Suppose a new node E hashes to a position between B and C. Which keys change owner?
- Keys in the arc (B, E] previously walked clockwise past E's (empty) spot to C. Now they hit E first → they move from C to E.
- Every other key still meets the same first node → unchanged.
So adding a node only steals keys from one neighbour, and removing a node only hands its keys to one neighbour (its successor). With K keys and N nodes evenly spread, a change moves about K/N keys — the theoretical minimum, since the new node must end up with its fair share.
4.1 Worked example: 1,000,000 keys
| Change | mod-N hashing: keys moved | Consistent hashing (ideal): keys moved |
|---|---|---|
| 4 → 5 nodes (add) | 800,000 (80%) | 200,000 (1/5 = 20%), all to the new node |
| 4 → 3 nodes (one fails) | 750,000 (75%) | 250,000 (the failed node's 1/4), all to survivors |
| 10 → 11 nodes | ~909,000 (91%) | ~91,000 (1/11 ≈ 9%) |
| 100 → 101 nodes | ~990,000 (99%) | ~9,900 (1/101 ≈ 1%) |
The Python demo in Section 7 measures this on 100,000 real keys: adding a 5th node to 4 moved 19.6% of keys (ideal 20%), and every moved key went to the new node; mod-N would have moved 80%.
5. Virtual nodes: balance and heterogeneous capacity
5.1 Why one point per node is unbalanced
N random points split a circle into arcs whose lengths vary a lot: the largest arc is typically several times the average (on the order of log N times). Measured on 200,000 keys and 10 nodes using the implementation below:
| Ring positions per node | Std-dev of load (as % of mean) | Busiest node vs mean | Least-busy node vs mean |
|---|---|---|---|
| 1 | 122% | 3.63× | 0.07× |
| 10 | 34% | 1.81× | 0.52× |
| 100 | 8.0% | 1.13× | 0.88× |
| 1,000 | 2.2% | 1.04× | 0.97× |
With one position each, one server got 3.6× its fair share while another got almost nothing. The spread shrinks roughly like 1/√V where V is the number of positions per node.
5.2 The fix: many virtual nodes per physical node
Give each physical node V virtual nodes (vnodes): hash "cache-a#0", "cache-a#1", …, "cache-a#99" to get 100 positions, all owned by cache-a. Lookup is unchanged — find the successor position, then map it to its physical node.
Benefits of virtual nodes:
- Even load. Many small arcs average out (see the table: 100 vnodes → ~8% spread).
- Graceful failure. When a node dies, its many arcs are absorbed by many different successors, so each survivor takes a small, roughly equal slice instead of one neighbour taking it all.
- Faster recovery/rebuild. A new or replacement node streams data from many peers in parallel rather than from one.
- Heterogeneous capacity (weights). A server with twice the RAM gets twice as many vnodes and therefore ~twice the keys —
add_node("cache-big", weight=2)in the implementation below.
Costs: a bigger ring (N × V positions — still tiny: 1,000 servers × 256 = 256,000 sorted integers), and in databases, more token ranges to repair and stream. Cassandra used num_tokens: 256 by default for years; Cassandra 4.0 lowered the default to 16 with a smarter token-allocation algorithm that places tokens to balance load deliberately rather than randomly.
6. Replication along the ring
To store each key on N replicas (see Replication), Dynamo-style systems use the ring itself: the key's primary is its successor node, and the other replicas are the next distinct physical nodes clockwise. The ordered list is called the key's preference list.
- Hash the key; find the first vnode clockwise → its physical node is replica #1.
- Keep walking clockwise. For each vnode, if its physical node is already in the list, skip it (otherwise two "replicas" could be the same machine — no redundancy).
- Stop when you have N distinct physical nodes. Optionally also skip nodes in the same rack or availability zone until each replica is in a different failure domain — Cassandra's
NetworkTopologyStrategydoes this per data center, placing replicas on distinct racks.
Nice properties: every node can compute any key's replica set locally; when a node joins or leaves, preference lists change only for keys near it on the ring; and during a temporary failure, the next node in the walk can accept writes on its behalf (the sloppy quorum and hinted handoff covered in Replication).
7. A complete Python implementation
The whole algorithm fits in ~60 lines: a sorted list of vnode positions, a dict from position to physical node, and bisect for the binary search. It supports weights, removal and replica sets.
import bisect
import hashlib
from collections import Counter
def _hash(value: str) -> int:
"""Map a string to a point on the ring: 0 .. 2**32 - 1."""
digest = hashlib.md5(value.encode("utf-8")).digest()
return int.from_bytes(digest[:4], "big")
class ConsistentHashRing:
def __init__(self, vnodes_per_weight: int = 100):
self.vnodes_per_weight = vnodes_per_weight
self._points = [] # sorted list of vnode positions
self._owner = {} # position -> physical node name
self._nodes = {} # physical node -> list of its positions
def add_node(self, node: str, weight: int = 1) -> None:
positions = []
for i in range(self.vnodes_per_weight * weight):
pos = _hash(f"{node}#vn{i}")
if pos in self._owner: # extremely rare collision: skip it
continue
self._owner[pos] = node
bisect.insort(self._points, pos)
positions.append(pos)
self._nodes[node] = positions
def remove_node(self, node: str) -> None:
for pos in self._nodes.pop(node):
del self._owner[pos]
idx = bisect.bisect_left(self._points, pos)
self._points.pop(idx)
def get_node(self, key: str) -> str:
if not self._points:
raise RuntimeError("ring is empty")
h = _hash(key)
idx = bisect.bisect_left(self._points, h) # first vnode at or clockwise of h
if idx == len(self._points): # past the last point: wrap to 0
idx = 0
return self._owner[self._points[idx]]
def get_nodes(self, key: str, n: int) -> list:
"""Replica set: walk clockwise, collecting n DISTINCT physical nodes."""
if n > len(self._nodes):
raise ValueError("not enough physical nodes")
result = []
idx = bisect.bisect_left(self._points, _hash(key))
while len(result) < n:
if idx == len(self._points):
idx = 0
node = self._owner[self._points[idx]]
if node not in result: # skip other vnodes of chosen nodes
result.append(node)
idx += 1
return result
if __name__ == "__main__":
ring = ConsistentHashRing(vnodes_per_weight=100)
for name in ["cache-a", "cache-b", "cache-c", "cache-d"]:
ring.add_node(name)
keys = [f"user:{i}:timeline" for i in range(100_000)]
before = {k: ring.get_node(k) for k in keys}
print("Load with 4 nodes:", sorted(Counter(before.values()).items()))
ring.add_node("cache-e")
after = {k: ring.get_node(k) for k in keys}
moved = sum(1 for k in keys if before[k] != after[k])
print("Load with 5 nodes:", sorted(Counter(after.values()).items()))
print(f"Keys moved: {moved} ({moved / len(keys):.1%}); ideal = 1/5 = 20.0%")
print("All moved keys went to cache-e:",
all(after[k] == "cache-e" for k in keys if before[k] != after[k]))
naive_moved = sum(1 for i in range(100_000) if i % 4 != i % 5)
print(f"Naive mod-N would move: {naive_moved} ({naive_moved / 100_000:.1%})")
print("Replicas for user:42:timeline ->", ring.get_nodes("user:42:timeline", 3))
ring.remove_node("cache-b")
gone = {k: ring.get_node(k) for k in keys}
moved_b = sum(1 for k in keys if after[k] != gone[k])
print(f"Removing cache-b moved {moved_b} keys, all previously on cache-b:",
all(after[k] == "cache-b" for k in keys if after[k] != gone[k]))
Actual output (Python 3, deterministic because MD5 is deterministic):
Load with 4 nodes: [('cache-a', 26028), ('cache-b', 22725), ('cache-c', 23324), ('cache-d', 27923)]
Load with 5 nodes: [('cache-a', 20640), ('cache-b', 19816), ('cache-c', 18519), ('cache-d', 21391), ('cache-e', 19634)]
Keys moved: 19634 (19.6%); ideal = 1/5 = 20.0%
All moved keys went to cache-e: True
Naive mod-N would move: 80000 (80.0%)
Replicas for user:42:timeline -> ['cache-b', 'cache-d', 'cache-e']
Removing cache-b moved 19816 keys, all previously on cache-b: True
What to notice:
- Balance: with 100 vnodes per node, each of 4 nodes holds 23–28% (ideal 25%); after adding cache-e each holds 18.5–21.4% (ideal 20%).
- Minimal movement: 19.6% of keys moved — essentially exactly the new node's fair share — and only onto the new node. Mod-N would have moved 80%.
- Removal: deleting cache-b moved exactly the 19,816 keys cache-b owned, and nothing else; thanks to vnodes, they were spread across all four survivors.
- Replicas:
get_nodesreturned three distinct physical nodes.
hash() is randomised per process for strings, so it must never be used for this.8. Where consistent hashing is used
| System | How it uses consistent hashing |
|---|---|
| Apache Cassandra / ScyllaDB | Token ring with Murmur3 partitioner (64-bit tokens); each node owns several token ranges (vnodes, num_tokens); replicas = next distinct nodes clockwise, rack/DC-aware. Adding a node streams only its new ranges. |
| Amazon Dynamo (2007 paper), Riak, Voldemort | The canonical design: ring + virtual nodes + preference lists + sloppy quorums. Riak uses a fixed number of equal-sized partitions on the ring. |
| Amazon DynamoDB | Descends from Dynamo's ideas (hash-partitioned by partition key), though the managed service maps partitions via its own metadata rather than a client-visible ring. |
| Memcached clients — ketama | libketama (Last.fm, 2007) popularised client-side consistent hashing for memcached pools; it's implemented by most memcached client libraries and proxies (twemproxy, Nginx hash … consistent). Adding a cache server invalidates only ~1/N of keys. |
| Load balancers | Session/cache affinity: Envoy's RING_HASH and MAGLEV policies, HAProxy hash-type consistent (with optional bounded loads), Nginx hash $key consistent, Google's Maglev. Requests for the same user/URL go to the same backend, and backend changes reshuffle few clients. See Load Balancing. |
| CDNs and web caches | Inside a PoP, the URL is hashed to pick which cache server stores it, so the PoP's total cache is the union (not N copies) of server caches. The 1997 consistent-hashing paper was written for exactly this problem. See CDN. |
| Distributed hash tables, chat and messaging | Chord/Kademlia DHTs (peer-to-peer), routing chat rooms or user sessions to a stateful server (e.g. which WebSocket node handles a conversation — see Direct Messaging). |
Contrast: Redis Cluster uses 16,384 fixed hash slots (CRC16(key) mod 16384) assigned to nodes — the "fixed number of logical partitions" approach from Sharding, which achieves the same goal (move slots, not keys) with an explicit slot map instead of a ring.
9. Alternatives and refinements
9.1 Rendezvous hashing (Highest Random Weight, HRW)
For each key, compute a score for every node — score = hash(node_id + key) — and pick the node with the highest score. For N replicas, take the top-N scores.
def hrw_owner(key: str, nodes: list) -> str:
return max(nodes, key=lambda node: _hash(f"{node}:{key}"))
- ✅ Remove a node → only keys whose top choice was that node move (each to its own second choice, so load spreads evenly across survivors). Add a node → it wins ~1/N of keys. Same minimal-disruption property as the ring.
- ✅ Naturally balanced without virtual nodes; no ring to store; trivially gives an ordered replica list; supports weights (e.g.
score = −weight / ln(h)with h normalised to (0,1)). - ❌ Lookup is O(N) hashes per key — fine for tens or hundreds of nodes, costly for thousands (can be made hierarchical).
- Used by: Apache Ignite, GitHub's load balancer (GLB) director, many CDN/cache routing schemes, Microsoft's CARP proxy-array protocol.
9.2 Jump consistent hash
Google's jump consistent hash (Lamping & Veach, 2014) maps a 64-bit key to a bucket number in [0, num_buckets) with a tiny loop, no memory, O(ln N) time and near-perfect balance:
def jump_hash(key: int, num_buckets: int) -> int:
b, j = -1, 0
while j < num_buckets:
b = j
key = (key * 2862933555777941757 + 1) & 0xFFFFFFFFFFFFFFFF # 64-bit LCG step
j = int((b + 1) * (float(1 << 31) / float((key >> 33) + 1)))
return b
- ✅ Going from n to n+1 buckets moves exactly ~1/(n+1) of keys, all to the new bucket (measured: 10 → 11 buckets moved 8.98% of 100,000 keys; ideal 9.09%).
- ✅ Better balance than a ring with vnodes, zero lookup state.
- ❌ Buckets must be numbered 0 … n−1, and you can only add or remove the last bucket. You can't remove an arbitrary failed server — so it fits sharded storage with numbered, replicated shards (each "bucket" is a replica group that never disappears), not a pool of individually failing cache nodes.
9.3 Consistent hashing with bounded loads
Even with vnodes, a ring balances keys, not load: a few very hot keys can overload their owner. Consistent hashing with bounded loads (Mirrokni, Thorup & Zadimoghaddam, Google, 2016) sets a capacity per node of c = ⌈(1 + ε) × average load⌉. A request goes to its usual successor unless that node is at capacity, in which case it continues clockwise to the next node with room.
ε = 0.25, 4 servers, 100 active connections → average 25, cap = ⌈1.25 × 25⌉ = 32 each
key K hashes near server B: B has 32/32 (full) → skip
next clockwise: C has 20/32 → assign to C ✔
Guarantee: no server exceeds 1.25× the average, while most keys still land on their
"natural" server (good cache affinity) and changes still move few keys.
Vimeo contributed this to HAProxy (hash-balance-factor) to keep video-cache affinity without overloading any single cache server. It's ideal for load balancing with affinity, where load matters more than strict key placement.
9.4 Comparison
| Scheme | Lookup cost | Memory | Balance | Keys moved on change | Arbitrary node removal | Best for |
|---|---|---|---|---|---|---|
| mod N | O(1) | None | Excellent | ~all (N/(N+1)) | Yes, but reshuffles everything | Fixed-size clusters that never change |
| Ring + vnodes | O(log NV) | N·V positions | Good (≈1/√V) | ~K/N | ✅ Yes | Caches, Dynamo-style databases |
| Rendezvous (HRW) | O(N) | Node list | Excellent | ~K/N | ✅ Yes | Small/medium node sets; easy replica lists |
| Jump hash | O(log N) | None | Excellent | ~K/(N+1) | ❌ Only last bucket | Numbered shards / replica groups |
| Bounded loads | O(log NV) + skips | Ring + load counters | Guaranteed ≤ (1+ε)×avg | Small, plus overflow moves | ✅ Yes | Load balancers with affinity, hot keys |
| Maglev | O(1) table lookup | Lookup table (e.g. 65,537 entries) | Near-perfect | Small (slightly more than minimal) | ✅ Yes | High-throughput L4 load balancers |
| Fixed slots (Redis Cluster) | O(1) | Slot map | Depends on slot assignment | Only moved slots | ✅ Yes (reassign slots) | Databases/caches with a coordinator |
10. How Instagram uses consistent hashing
- Cache clusters. The Feed Cache (timelines, counters) and object caches sit on many Redis/Memcached servers. Meta's memcache deployment — described publicly in the 2013 paper Scaling Memcache at Facebook — distributes items across memcached servers using consistent hashing, and Meta's open-source router mcrouter offers consistent-hashing routes. Instagram runs on Meta's infrastructure since its data-center migration, so the same pattern applies: when a cache server is added or dies, only ~1/N of keys miss, instead of the whole cache going cold.
- Cassandra's token ring. Instagram's Cassandra clusters (feeds, activity and other high-write data) place each partition key — e.g.
user_id— on the Murmur3 token ring; vnodes balance load and let new nodes join by streaming only their token ranges; replicas (RF = 3) are the next distinct nodes clockwise, on different racks. - Stateful routing. Services that keep per-user state in memory (e.g. which WebSocket gateway holds a user's live DM connection, or which worker owns a user's ranking cache) can use consistent hashing on
user_idso most users keep their server when the fleet scales.
10.1 Example: scaling the feed cache before a big event
- Assume 40 Redis timeline servers, 100 vnodes each, and ~400 M cached timelines (~10 M per server). Traffic is forecast to rise 25%, so ops adds 10 servers.
- The new servers' vnodes are inserted into the ring; the new membership list is published via the config service, and every Feed Service instance rebuilds its ring (a few ms).
- Each new server takes ~1/50 of the ring → in total 10/50 = 20% of timelines (≈80 M keys) now map to new servers and miss once. With mod-N, 40/50 = 80% (≈320 M) would have missed.
- Misses are refilled lazily from Cassandra (or by pre-warming: copying the affected ranges before flipping the membership). Because those 20% are spread evenly over time and users, the database sees a manageable bump instead of a stampede. See Caching for stampede protection.
11. Trade-offs & common pitfalls
✅ Benefits
- Minimal key movement (~K/N) when nodes join or leave
- Fully decentralised: every client computes owners locally
- With vnodes: good balance, graceful failure spreading, weighted capacity
- Natural replica placement (next distinct nodes clockwise)
- Simple: a sorted array and a binary search
⚠️ Limitations
- Balances keys, not request load — hot keys still overload one node
- Without vnodes, arcs are very uneven
- All clients must agree on membership, hash and vnode scheme
- Range queries are impossible across a hash ring
- Moved keys are still a cache-miss burst (smaller, not zero)
Common pitfalls
- Using one point per node — 3–4× load imbalance with a handful of nodes. Use 100+ vnodes (or a token-allocation algorithm).
- Inconsistent membership views — during a rolling config push, half the clients use the old ring and half the new, so the same key is written to two servers and reads flip between them. Version the membership and propagate it quickly; tolerate short windows with TTLs.
- Language-specific or randomised hashes (Python
hash(), JavaString.hashCode()with poor mixing) → different clients disagree or keys cluster. - Flapping nodes — a node bouncing in and out of the ring moves its keys back and forth. Add hysteresis: require sustained health before re-adding.
- Replicas on the same machine/rack — forgetting to skip vnodes of already-chosen physical nodes (or racks) when walking the ring.
- Assuming it solves hot keys — a celebrity's timeline key still lives on one server. Combine with replication of hot keys, local caching, or bounded-load hashing. See Sharding.
- Forgetting data movement in stateful stores — for a cache, a moved key is just a miss; for a database, the new owner must stream the data before it can serve it.
12. Real-world technologies
| Technology | Mechanism | When to use it |
|---|---|---|
| Cassandra / ScyllaDB | Murmur3 token ring, vnodes, rack/DC-aware replica placement | Distributed database with automatic partitioning and multi-DC replication. |
| Riak, Voldemort | Dynamo-style ring with fixed partitions | Highly available key-value stores (largely legacy now). |
| libketama, twemproxy, mcrouter | Client/proxy-side consistent hashing for memcached/Redis pools | Sharding a cache tier without a central coordinator. |
Envoy (RING_HASH, MAGLEV) | Ring hash with configurable ring size; Maglev table | Service-mesh load balancing with affinity (e.g. by user ID header). |
HAProxy (hash-type consistent, hash-balance-factor) | Consistent hashing, optionally with bounded loads | Cache affinity at the load balancer without overload. |
Nginx (hash $key consistent) | Ketama-compatible ring | Upstream selection for caches/proxies. |
Guava Hashing.consistentHash | Jump consistent hash | Mapping keys to numbered shards in Java. |
| Redis Cluster | 16,384 fixed hash slots (not a ring) | Sharded Redis with explicit slot migration. |
13. Interview corner
Explain consistent hashing and why it's better than hash mod N.
With mod N, a key's server depends on N, so changing N remaps about N/(N+1) of keys — 80% when going from 4 to 5 servers — which empties a cache or forces a massive data migration. Consistent hashing hashes both servers and keys onto the same circular space; a key belongs to the first server clockwise. A key's position doesn't depend on how many servers exist, so adding a server only takes over the arc between it and its predecessor (~K/N keys), and removing one only hands its arc to its successor. Lookup is a binary search over sorted server positions, O(log M).
What are virtual nodes and why do we need them?
Each physical server is placed at many ring positions (e.g. 100–256) by hashing "server#i". With one position each, random arcs are very uneven (a node can get 3–4× its share) and a departing node dumps all its keys on one neighbour. With many vnodes, each server owns many small interleaved arcs, so load evens out (spread ≈ 1/√V), a failure is absorbed by all survivors, a joining node pulls from many peers in parallel, and weights are easy — give bigger machines more vnodes.
How do you choose replicas on a consistent hash ring?
Walk clockwise from the key's position, collecting the physical nodes of the vnodes you encounter, skipping any physical node (and ideally any rack/AZ) already chosen, until you have N distinct ones — the key's preference list. The first is the coordinator/primary. Every node can compute it locally, and membership changes affect only nearby keys. Cassandra's NetworkTopologyStrategy does this per data center with rack awareness.
Your cache cluster uses consistent hashing, but one server is at 95% CPU while others are at 30%. Why, and what do you do?
Either the key distribution is uneven (too few vnodes, or a poor hash) or — more likely at Instagram scale — a few hot keys (a celebrity's profile/timeline, a viral post's counter) live on that server; consistent hashing balances keys, not traffic. Check per-key request rates. Fixes: more vnodes or weight adjustments for distribution problems; for hot keys, replicate them to several servers and read randomly among copies, add an in-process L1 cache in app servers, split the key, or use consistent hashing with bounded loads at the routing layer.
Compare consistent hashing, rendezvous hashing and jump hash.
Ring hashing: O(log M) lookup, needs vnodes for balance, supports arbitrary node add/remove — the classic for caches and Dynamo-style stores. Rendezvous (HRW): score every node with hash(node, key) and take the max; perfectly balanced without vnodes, minimal disruption, easy top-N replicas, but O(N) per lookup. Jump hash: no memory, O(log N), excellent balance and minimal movement, but buckets are numbered 0..n−1 and only the last can be removed — great for numbered shards backed by replica groups, not for individually failing nodes.
Is consistent hashing used for Instagram's PostgreSQL shards?
Not per Instagram's public description. For PostgreSQL they use a fixed number of logical shards (thousands) mapped to physical servers by a mapping table, with the logical shard ID embedded in every object ID. That achieves the same goal — growing without re-hashing — via explicit shard moves. Consistent hashing shows up where membership changes are frequent and decentralised: cache tiers (memcache/Redis) and Cassandra's token ring.
14. Check your understanding
With hash mod N, what fraction of keys move when going from 8 to 9 servers? With an ideal consistent hash?
Mod N: a key stays only if h mod 8 = h mod 9, true for 8 of every 72 values → ~11% stay, ~89% move. Consistent hashing: the new server takes its fair share, 1/9 ≈ 11%, and nothing else moves.
Ring positions are [100, 400, 700] for nodes [X, Y, Z] on a ring of size 1,000. Who owns keys hashing to 50, 400, 401 and 950?
Using "first position ≥ hash": 50 → 100 → X; 400 → 400 → Y; 401 → 700 → Z; 950 → past the end → wrap to 100 → X.
In the same ring, node W is added at position 550. Which keys move, from whom, and what fraction (assuming uniform keys)?
Keys in (400, 550] move from Z to W; nothing else changes. That's 150/1,000 = 15% of keys. (With only one position per node, the share depends on where W lands — another reason for vnodes.)
A cluster has 10 nodes with 1 token each, and the busiest node has 3.6× the average load. Roughly how many vnodes per node would bring the spread to about ±8%?
Spread shrinks roughly like 1/√V. Our measurement showed ~122% std-dev at V = 1 and ~8% at V = 100 (busiest node ≈ 1.13× average). Real systems use 16–256 vnodes or smarter token allocation.
Why must you skip vnodes when selecting N = 3 replicas by walking the ring? Give a failure scenario if you don't.
Consecutive vnodes may belong to the same physical node. Without skipping, a key's "three replicas" could be cache-b, cache-b, cache-d — two copies on one machine. If cache-b dies, two of three replicas vanish and a quorum (2 of 3) becomes impossible, even though "only one node" failed.
You have 3 cache servers: two with 64 GB RAM and one with 128 GB. How do you give the big one its fair share with consistent hashing?
Weight vnodes by capacity: e.g. 100 vnodes for each 64 GB server and 200 for the 128 GB server. The big server then owns ~200/400 = 50% of the ring, the others ~25% each — proportional to RAM.
Why is jump consistent hash a poor fit for a memcached pool where any server may crash, but fine for 64 numbered Cassandra-like replica groups?
Jump hash can only shrink by removing the highest-numbered bucket. If server #17 crashes, you can't remove bucket 17 without renumbering (and remapping) everything above it. In a store where each bucket is a replicated group that never disappears (a failed machine is replaced inside the group), bucket numbers are stable and jump hash's zero-memory perfect balance is ideal.
hash mod Nremaps ~N/(N+1) of keys when N changes — catastrophic for caches and databases.- Consistent hashing puts nodes and keys on a ring; a key's owner is its clockwise successor, found by binary search.
- Adding/removing a node moves only ~K/N keys, only to/from that node.
- Virtual nodes give balance (≈1/√V spread), graceful failure and weighted capacity; replicas are the next N distinct (rack-aware) nodes clockwise.
- Know the alternatives: rendezvous/HRW, jump hash, bounded loads, Maglev and fixed slots — and that none of them fixes hot keys on its own.
- Instagram relies on it for cache clusters and Cassandra's token ring; its PostgreSQL shards use fixed logical shards instead.
Related lessons
- Sharding — logical shards, hash vs range partitioning and hot keys
- Replication — preference lists, sloppy quorums and hinted handoff on the ring
- Caching — the Redis/Memcached tiers that consistent hashing distributes
- Databases — Cassandra's partition keys and token ring
- Load Balancing — hash-based affinity, ring hash and Maglev
- CDN — consistent hashing inside a PoP to pick the cache server for a URL