Consistent hashing
How consistent hashing adds or removes a server without moving every key. Hash rings, virtual nodes, rendezvous, jump and Maglev hashing, and bounded loads.
- Read first
- Partitioning
The problem it solves
Section titled “The problem it solves”A cache tier has 10 servers holding 50 million keys, with a 95% hit ratio. Every client picks a
server with hash(key) mod 10. It is simple, fast and perfectly even. Then traffic grows and you
add an 11th server.
A key stays where it was only if h mod 10 equals h mod 11. For a uniform hash, that happens for
1 key in 11. The other 91% of keys now point at a server that does not have them. The hit ratio
falls from 95% to about 9% in one deploy. The database was sized for 5% of reads, and now it gets
91% of them. That’s 18 times its usual load. Losing one server does the same thing in reverse.
The general result: going from N to N + 1 servers, a key stays put only when h mod N(N+1) is below
N. So a fraction 1/(N+1) stays, and N/(N+1) moves.
| Change | Keys moved by mod N |
Fewest that must move |
|---|---|---|
| 4 → 5 | 80% | 20% |
| 10 → 11 | 91% | 9% |
| 64 → 65 | 98.5% | 1.5% |
| 100 → 101 | 99% | 1% |
The right column is the floor. The new server must take its fair share, 1/(N+1), and nothing else needs to move. Karger and others introduced consistent hashing in 1997, for web caches. (That work became Akamai.) It’s any scheme that gets close to that floor, while keeping three properties:
- Balance. Each server owns about 1/N of the keys.
- Minimal disruption. Adding or removing one server moves about 1/N of the keys, and only to or from that server.
- No lookup table on the request path. Any client that knows the member list computes a key’s owner locally, and all clients agree.
The last point is what separates it from a directory. A directory (a map from key or range to server) also gives balance and minimal movement, but every client must fetch and track the map.
How it works
Section titled “How it works”Step 1: Put servers and keys on a ring
Section titled “Step 1: Put servers and keys on a ring”Treat the output of a 64 bit hash as a circle: 0 to 2⁶⁴ − 1, and past the top it wraps to 0. Hash each server’s name to a position on the circle. Hash each key the same way. A key belongs to the first server clockwise from it.
Now add a server. It lands at one position and takes over the arc between its counterclockwise neighbor and itself. Those keys were owned by its clockwise neighbor. Every other key keeps its owner, because nothing about their arcs changed. Removing a server is the same in reverse: its arc joins its clockwise neighbor’s. On average one change moves 1/N of the keys, which is the floor.
- Color key
- Service
A lookup is a binary search. Keep the server positions in a sorted array. Find the first position at or after the key’s hash, and wrap to index 0 past the end. With 10,000 positions, that is about 14 comparisons, well under a microsecond. The distributed cache problem builds exactly this ring, with the code in five languages, in its stage 2.
Step 2: One point per server is lopsided
Section titled “Step 2: One point per server is lopsided”The ring above has a flaw. You can see it in the demo below, at one point per server: the arcs have random lengths. Put N random points on a circle, and the gaps between them are roughly exponentially distributed. So the standard deviation of a server’s share is about equal to its mean. The biggest gap is about ln N times the average.
| Servers | Busiest server’s share, one point each | Smallest share |
|---|---|---|
| 10 | about 2.9× the average | often under 0.1× |
| 100 | about 5× the average | near zero for some |
(Averages over 200 random rings.) Two problems follow. Capacity is wasted, because every server must be sized for the unluckiest arc. And failure concentrates: when a server dies, its whole arc goes to one neighbor, which suddenly carries two shares. If that neighbor tips over, its double share goes to the next one. That is a cascade, and it walks around the ring.
Step 3: Virtual nodes
Section titled “Step 3: Virtual nodes”Give each physical server many points instead of one: hash "s1-0", "s1-1", … "s1-159". These
are virtual nodes (vnodes). Cassandra calls them tokens. A server’s share is now the sum of V
small arcs, instead of one big one. And a sum of V independent gaps has a relative standard
deviation of about 1/√V.
| Points per server (V) | Spread of shares (std dev / mean) | Busiest of 100 servers |
|---|---|---|
| 1 | about 100% | 5.0× the average |
| 10 | 31% | 1.96× |
| 100 | 10% | 1.27× |
| 160 | 8% | 1.21× |
| 1,000 | 3% | 1.08× |
(Measured by simulation. The spread column matches 1/√V.) The busiest server matters more than the spread, because it sets the capacity you buy. With 100 servers at V = 160, plan for the hottest to carry about 1.2 times the mean.
Virtual nodes also fix failure concentration. A dead server’s 160 arcs each go to whatever point follows them, so its load spreads over nearly every other server. A new server takes a sliver from almost everyone instead of half of one neighbor’s load.
The costs are memory and, for databases, bookkeeping. 1,000 servers at 160 points is 160,000 entries of about 16 bytes. That’s 2.5 MB per client, and a lookup of about 17 comparisons. That is cheap. But in a storage system, each point is a range that must be streamed, repaired and tracked. That’s why Cassandra 4.0 cut its default from 256 random tokens to 16, placed by an allocation algorithm.
Try it. Start at one point per server, add a server and count the moved keys. Then switch to 64
points and watch the load bars even out. Then switch to hash mod N and add a server again.
Make a change to see which keys move.
Keys per server. The dashed line is the average.
- Biggest share of the ring vs fair share
- 1.00×
- Spread of shares (std dev / mean)
- 0%
Four servers, one point each: the arcs are uneven. Add a server, then try 64 virtual nodes.
Step 4: Weights for unequal servers
Section titled “Step 4: Weights for unequal servers”Real fleets mix machine sizes. On a ring the fix is direct: give each server points in proportion to its capacity. A 96 GB cache node gets 320 points where a 48 GB node gets 160. Its share doubles and its relative spread shrinks by √2. Changing one server’s weight adds or removes only its own points, so only keys near those points move.
Ketama, the memcached client scheme from 2007, does exactly this. It gives 160 points to a server of average weight, scaled by its weight, taken 4 at a time from MD5 digests.
Step 5: Replicas by walking the ring
Section titled “Step 5: Replicas by walking the ring”For replication factor 3, a key’s replicas are its preference list (Dynamo’s term). Start at the key’s position, and walk clockwise, collecting servers until you have 3. Two rules make the list useful:
- Skip points of servers already chosen. With vnodes, the next three points often include two from one machine. Two copies on one machine are one copy.
- Skip servers in a zone already chosen. A rack switch or an availability zone takes out every
machine behind it. Cassandra’s
NetworkTopologyStrategywalks the ring per datacenter and prefers distinct racks.
- Color key
- Service
When a server in the list is down, Dynamo-style stores keep walking. The next healthy server accepts the write, with a hint naming the real owner. It hands the write back when the owner returns. That is a sloppy quorum, covered in the key-value store problem. The ring makes it cheap, because “the next server” is already defined.
Step 6: Who holds the ring
Section titled “Step 6: Who holds the ring”The ring is only useful if every caller has the same one. There are three places to compute it.
| Where routing runs | Extra hop | Wins when |
|---|---|---|
| Client library | 0 | You control every client and its language (ketama, Cassandra drivers) |
| Proxy tier (twemproxy, mcrouter, Envoy) | 1, about 0.2 to 0.5 ms | Many languages or clients you cannot update |
| Any server forwards | 1 on most requests | Clients are dumb, as with Cassandra’s coordinator fallback |
The member list itself comes from a config store (etcd, ZooKeeper) or from gossip between servers. Either way, a change reaches clients at slightly different times, so for a few seconds two clients can route one key to different servers. Version the ring with an epoch and send it on every request. A server that just gave a range away refuses requests carrying an older epoch.
- Color key
- Service
- Database
Options and tradeoffs
Section titled “Options and tradeoffs”The ring is one of five schemes
Section titled “The ring is one of five schemes”The ring is the scheme everyone draws, but it is not the only one, and it is not the best at everything. Each alternative below gives up something to win on lookup speed, memory or balance.
Rendezvous hashing (highest random weight, Thaler and Ravishankar, 1996). There is no ring. For
each key, every server computes a score hash(server, key), and the highest score wins.
Remove a server, and only its keys move, because every other key’s winner is unchanged. Its keys go to their second-highest server. That’s a different server for each key, so the load spreads evenly. Sorting all scores gives a full preference order. So the top 3 are the replicas, with no skipping.
Weights use one formula, −w / ln(u), with u the score scaled to (0, 1). That makes a server win
with probability w / Σw. The price is O(N) hashes per lookup.
import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import java.security.NoSuchAlgorithmException;import java.util.*;
// weight is capacity relative to the others: a node twice as big gets 2.record Node(String name, double weight) {}
// Highest random weight hashing: every node scores the key, the highest score owns it.// No ring and no points to store, so the only state is the node list itself.class Rendezvous { private final List<Node> nodes;
Rendezvous(List<Node> nodes) { this.nodes = List.copyOf(nodes); }
// Sorting by score gives a full preference order. The top `count` are the replicas, and // they are distinct nodes by construction: no walking past duplicates as on a ring. List<Node> owners(String key, int count) { List<Node> ranked = new ArrayList<>(nodes); ranked.sort(Comparator.comparingDouble((Node n) -> score(n, key)).reversed()); return ranked.subList(0, count); }
// O(N) hashes per lookup. Fine for tens or hundreds of nodes, too slow per packet at // thousands; that is where a ring or a Maglev table wins. Node owner(String key) { Node best = null; double bestScore = Double.NEGATIVE_INFINITY; for (Node n : nodes) { double s = score(n, key); if (s > bestScore) { best = n; bestScore = s; } } return best; }
// u is uniform in (0, 1) and fixed for this (node, key) pair. -w / ln(u) makes a node win // with probability w / sum(w): weights work without virtual nodes. static double score(Node node, String key) { double u = (hash64(node.name() + ":" + key) >>> 11) / Math.pow(2, 53); // top 53 bits fit a double exactly u += 0.5 / Math.pow(2, 53); // never 0 or 1, so ln(u) is finite and negative return -node.weight() / Math.log(u); }
// MD5 because every client language must compute the same number for the same key. static long hash64(String s) { try { byte[] d = MessageDigest.getInstance("MD5").digest(s.getBytes(StandardCharsets.UTF_8)); long h = 0; for (int i = 0; i < 8; i++) h = (h << 8) | (d[i] & 0xff); return h; } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } }}
class Main { public static void main(String[] args) { List<Node> nodes = List.of(new Node("a", 1), new Node("b", 1), new Node("c", 1), new Node("d", 2)); Rendezvous before = new Rendezvous(nodes); List<String> keys = new ArrayList<>(); for (int i = 0; i < 100_000; i++) keys.add("user:" + i); Map<String, Integer> counts = new TreeMap<>(); for (String k : keys) counts.merge(before.owner(k).name(), 1, Integer::sum); System.out.println(counts); // d, with weight 2, owns about 40%; the others about 20% each
// Remove c. Only c's keys move, and they spread over a, b and d in proportion to weight. Rendezvous after = new Rendezvous(nodes.stream().filter(n -> !n.name().equals("c")).toList()); long moved = keys.stream().filter(k -> !before.owner(k).equals(after.owner(k))).count(); System.out.printf("moved %.1f%%%n", 100.0 * moved / keys.size()); // 20.0%: exactly c's keys, the minimum possible System.out.println(before.owners("user:42", 3).stream().map(Node::name).toList()); // [c, d, a]: three distinct replicas, in order }}import hashlibimport mathfrom dataclasses import dataclass
@dataclass(frozen=True)class Node: name: str weight: float # capacity relative to the others: a node twice as big gets 2
# Highest random weight hashing: every node scores the key, the highest score owns it.# No ring and no points to store, so the only state is the node list itself.class Rendezvous: def __init__(self, nodes: list[Node]): self.nodes = list(nodes)
def owners(self, key: str, count: int) -> list[Node]: # Sorting by score gives a full preference order. The top `count` are the replicas, and # they are distinct nodes by construction: no walking past duplicates as on a ring. ranked = sorted(self.nodes, key=lambda n: _score(n, key), reverse=True) return ranked[:count]
def owner(self, key: str) -> Node: # O(N) hashes per lookup. Fine for tens or hundreds of nodes, too slow per packet at # thousands; that is where a ring or a Maglev table wins. return max(self.nodes, key=lambda n: _score(n, key))
def _score(node: Node, key: str) -> float: # u is uniform in (0, 1) and fixed for this (node, key) pair. -w / ln(u) makes a node win # with probability w / sum(w): weights work without virtual nodes. u = (_hash64(f"{node.name}:{key}") >> 11) / 2.0**53 # top 53 bits fit a double exactly u = u + 0.5 / 2.0**53 # never 0 or 1, so ln(u) is finite and negative return -node.weight / math.log(u)
def _hash64(s: str) -> int: # MD5 because every client language must compute the same number for the same key. return int.from_bytes(hashlib.md5(s.encode()).digest()[:8], "big")
if __name__ == "__main__": nodes = [Node("a", 1), Node("b", 1), Node("c", 1), Node("d", 2)] before = Rendezvous(nodes) keys = [f"user:{i}" for i in range(100_000)] counts = {n.name: 0 for n in nodes} for k in keys: counts[before.owner(k).name] += 1 print(counts) # d, with weight 2, owns about 40%; the others about 20% each
# Remove c. Only c's keys move, and they spread over a, b and d in proportion to weight. after = Rendezvous([n for n in nodes if n.name != "c"]) moved = sum(1 for k in keys if before.owner(k) != after.owner(k)) print(f"moved {moved / len(keys):.1%}") # 20.0%: exactly c's keys, the minimum possible print([n.name for n in before.owners("user:42", 3)]) # ['c', 'd', 'a']: three distinct replicas, in orderusing System;using System.Buffers.Binary;using System.Collections.Generic;using System.Linq;using System.Security.Cryptography;using System.Text;
// Weight is capacity relative to the others: a node twice as big gets 2.public sealed record Node(string Name, double Weight);
// Highest random weight hashing: every node scores the key, the highest score owns it.// No ring and no points to store, so the only state is the node list itself.public sealed class Rendezvous{ private readonly List<Node> _nodes;
public Rendezvous(IEnumerable<Node> nodes) => _nodes = nodes.ToList();
// Sorting by score gives a full preference order. The top `count` are the replicas, and // they are distinct nodes by construction: no walking past duplicates as on a ring. public IReadOnlyList<Node> Owners(string key, int count) => _nodes.OrderByDescending(n => Score(n, key)).Take(count).ToList();
// O(N) hashes per lookup. Fine for tens or hundreds of nodes, too slow per packet at // thousands; that is where a ring or a Maglev table wins. public Node Owner(string key) => _nodes.MaxBy(n => Score(n, key))!;
// u is uniform in (0, 1) and fixed for this (node, key) pair. -w / ln(u) makes a node win // with probability w / sum(w): weights work without virtual nodes. private static double Score(Node node, string key) { double u = (Hash64($"{node.Name}:{key}") >> 11) / Math.Pow(2, 53); // top 53 bits fit a double exactly u += 0.5 / Math.Pow(2, 53); // never 0 or 1, so ln(u) is finite and negative return -node.Weight / Math.Log(u); }
// MD5 because every client language must compute the same number for the same key. private static ulong Hash64(string s) => BinaryPrimitives.ReadUInt64BigEndian(MD5.HashData(Encoding.UTF8.GetBytes(s)));}
public static class Program{ public static void Main() { var nodes = new List<Node> { new("a", 1), new("b", 1), new("c", 1), new("d", 2) }; var before = new Rendezvous(nodes); var keys = Enumerable.Range(0, 100_000).Select(i => $"user:{i}").ToList(); var counts = keys.GroupBy(k => before.Owner(k).Name).OrderBy(g => g.Key) .Select(g => $"{g.Key}={g.Count()}"); Console.WriteLine(string.Join(", ", counts)); // d, with weight 2, owns about 40%; the others about 20% each
// Remove c. Only c's keys move, and they spread over a, b and d in proportion to weight. var after = new Rendezvous(nodes.Where(n => n.Name != "c")); int moved = keys.Count(k => before.Owner(k) != after.Owner(k)); Console.WriteLine($"moved {100.0 * moved / keys.Count:F1}%"); // 20.0%: exactly c's keys, the minimum possible Console.WriteLine(string.Join(" ", before.Owners("user:42", 3).Select(n => n.Name))); // c d a: three distinct replicas, in order }}package main
import ( "crypto/md5" "encoding/binary" "fmt" "math" "sort")
// Node is one cache or storage server. Weight is capacity relative to the others: a node twice// as big gets 2.type Node struct { Name string Weight float64}
// Rendezvous is highest random weight hashing: every node scores the key, the highest score// owns it. No ring and no points to store, so the only state is the node list itself.type Rendezvous struct { nodes []Node}
func NewRendezvous(nodes []Node) *Rendezvous { return &Rendezvous{nodes: append([]Node(nil), nodes...)}}
// Owners sorts by score, which gives a full preference order. The top count are the replicas,// and they are distinct nodes by construction: no walking past duplicates as on a ring.func (r *Rendezvous) Owners(key string, count int) []Node { ranked := append([]Node(nil), r.nodes...) sort.Slice(ranked, func(i, j int) bool { return score(ranked[i], key) > score(ranked[j], key) }) return ranked[:count]}
// Owner costs O(N) hashes per lookup. Fine for tens or hundreds of nodes, too slow per packet// at thousands; that is where a ring or a Maglev table wins.func (r *Rendezvous) Owner(key string) Node { best, bestScore := r.nodes[0], math.Inf(-1) for _, n := range r.nodes { if s := score(n, key); s > bestScore { best, bestScore = n, s } } return best}
// score: u is uniform in (0, 1) and fixed for this (node, key) pair. -w / ln(u) makes a node// win with probability w / sum(w): weights work without virtual nodes.func score(n Node, key string) float64 { u := float64(hash64(n.Name+":"+key)>>11) / math.Pow(2, 53) // top 53 bits fit a float64 exactly u += 0.5 / math.Pow(2, 53) // never 0 or 1, so ln(u) is finite and negative return -n.Weight / math.Log(u)}
// hash64 uses MD5 because every client language must compute the same number for the same key.func hash64(s string) uint64 { d := md5.Sum([]byte(s)) return binary.BigEndian.Uint64(d[:8])}
func main() { nodes := []Node{{"a", 1}, {"b", 1}, {"c", 1}, {"d", 2}} before := NewRendezvous(nodes) keys := make([]string, 100_000) counts := map[string]int{} for i := range keys { keys[i] = fmt.Sprintf("user:%d", i) counts[before.Owner(keys[i]).Name]++ } fmt.Println(counts) // d, with weight 2, owns about 40%; the others about 20% each
// Remove c. Only c's keys move, and they spread over a, b and d in proportion to weight. after := NewRendezvous([]Node{nodes[0], nodes[1], nodes[3]}) moved := 0 for _, k := range keys { if before.Owner(k) != after.Owner(k) { moved++ } } fmt.Printf("moved %.1f%%\n", 100*float64(moved)/float64(len(keys))) // 20.0%: exactly c's keys, the minimum possible for _, n := range before.Owners("user:42", 3) { fmt.Print(n.Name, " ") // c d a: three distinct replicas, in order } fmt.Println()}import { createHash } from 'node:crypto';
interface Node { name: string; weight: number; // capacity relative to the others: a node twice as big gets 2}
// Highest random weight hashing: every node scores the key, the highest score owns it.// No ring and no points to store, so the only state is the node list itself.class Rendezvous { private readonly nodes: Node[];
constructor(nodes: Node[]) { this.nodes = [...nodes]; }
// Sorting by score gives a full preference order. The top `count` are the replicas, and // they are distinct nodes by construction: no walking past duplicates as on a ring. owners(key: string, count: number): Node[] { return [...this.nodes].sort((x, y) => score(y, key) - score(x, key)).slice(0, count); }
// O(N) hashes per lookup. Fine for tens or hundreds of nodes, too slow per packet at // thousands; that is where a ring or a Maglev table wins. owner(key: string): Node { let best = this.nodes[0]; let bestScore = -Infinity; for (const n of this.nodes) { const s = score(n, key); if (s > bestScore) [best, bestScore] = [n, s]; } return best; }}
// u is uniform in (0, 1) and fixed for this (node, key) pair. -w / ln(u) makes a node win// with probability w / sum(w): weights work without virtual nodes.function score(node: Node, key: string): number { let u = Number(hash64(`${node.name}:${key}`) >> 11n) / 2 ** 53; // top 53 bits fit a double exactly u += 0.5 / 2 ** 53; // never 0 or 1, so ln(u) is finite and negative return -node.weight / Math.log(u);}
// MD5 because every client language must compute the same number for the same key.function hash64(s: string): bigint { return createHash('md5').update(s).digest().readBigUInt64BE(0);}
const nodes: Node[] = [ { name: 'a', weight: 1 }, { name: 'b', weight: 1 }, { name: 'c', weight: 1 }, { name: 'd', weight: 2 },];const before = new Rendezvous(nodes);const keys = Array.from({ length: 100_000 }, (_, i) => `user:${i}`);const counts: Record<string, number> = {};for (const k of keys) { const name = before.owner(k).name; counts[name] = (counts[name] ?? 0) + 1;}console.log(counts); // d, with weight 2, owns about 40%; the others about 20% each
// Remove c. Only c's keys move, and they spread over a, b and d in proportion to weight.const after = new Rendezvous(nodes.filter((n) => n.name !== 'c'));const moved = keys.filter((k) => before.owner(k) !== after.owner(k)).length;console.log(`moved ${((100 * moved) / keys.length).toFixed(1)}%`); // 20.0%: exactly c's keys, the minimum possibleconsole.log(before.owners('user:42', 3).map((n) => n.name)); // ['c', 'd', 'a']: three distinct replicas, in orderJump consistent hash (Lamping and Veach, Google, 2014). A five-line function maps a 64 bit key to
a bucket in [0, n), with no memory at all. It simulates the key’s journey as buckets are added one
at a time. It jumps straight to the last bucket count at which the key would have moved. The
expected number of jumps is ln n, and balance is essentially perfect. Going from n to n + 1 buckets
moves 1/(n+1) of keys in expectation. That’s the floor, and all of them go into the new bucket.
The catch: buckets are numbers, not names. You can grow or shrink only at the end. You can never remove bucket 3. It fits storage shards that are replica groups with stable numbers, where a failed machine is replaced inside its group.
import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import java.security.NoSuchAlgorithmException;
class Jump { // Jump consistent hash (Lamping and Veach, Google, 2014). Maps a 64 bit key to a bucket in // [0, buckets). No memory at all, near perfect balance, and growing from n to n + 1 buckets // moves exactly the keys that land in the new bucket, about 1/(n + 1) of them. static int jumpHash(long key, int buckets) { long b = -1, j = 0; while (j < buckets) { b = j; // A 64 bit linear congruential step: a cheap, deterministic stream of random numbers // seeded by the key, so every caller computes the same sequence. Java longs wrap. key = key * 2862933555777941757L + 1; // Jump straight to the next bucket count at which this key would move. Expected // number of jumps is ln(buckets), so 1,000 buckets take about 7 loop turns. j = (long) ((b + 1) * ((double) (1L << 31) / (double) ((key >>> 33) + 1))); } return (int) b; }
// Jump hash needs a well mixed 64 bit integer, not a string. MD5's first 8 bytes, // the same in every client language. static long keyHash(String s) { try { byte[] d = MessageDigest.getInstance("MD5").digest(s.getBytes(StandardCharsets.UTF_8)); long h = 0; for (int i = 0; i < 8; i++) h = (h << 8) | (d[i] & 0xff); return h; } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } }}
class Main { public static void main(String[] args) { int n = 100_000, moved = 0; boolean allToNew = true; int[] counts = new int[10]; for (int i = 0; i < n; i++) { long k = Jump.keyHash("user:" + i); int ten = Jump.jumpHash(k, 10), eleven = Jump.jumpHash(k, 11); counts[ten]++; if (ten != eleven) { moved++; allToNew &= eleven == 10; } } System.out.println(java.util.Arrays.toString(counts)); // about 10,000 each System.out.printf("moved %.1f%%%n", 100.0 * moved / n); // 9.3%, close to the ideal 1/11, all of it into bucket 10 System.out.println(allToNew); // true // The catch: buckets are numbers, not names. Removing bucket 3 of 10 is not possible; // you can only shrink from the end. Map bucket numbers to replica groups instead. }}import hashlib
MASK64 = (1 << 64) - 1 # Python ints never overflow, so wrap to 64 bits by hand
# Jump consistent hash (Lamping and Veach, Google, 2014). Maps a 64 bit key to a bucket in# [0, buckets). No memory at all, near perfect balance, and growing from n to n + 1 buckets# moves exactly the keys that land in the new bucket, about 1/(n + 1) of them.def jump_hash(key: int, buckets: int) -> int: b, j = -1, 0 while j < buckets: b = j # A 64 bit linear congruential step: a cheap, deterministic stream of random numbers # seeded by the key, so every caller computes the same sequence. key = (key * 2862933555777941757 + 1) & MASK64 # Jump straight to the next bucket count at which this key would move. Expected # number of jumps is ln(buckets), so 1,000 buckets take about 7 loop turns. j = int((b + 1) * (float(1 << 31) / float((key >> 33) + 1))) return b
def key_hash(s: str) -> int: # Jump hash needs a well mixed 64 bit integer, not a string. MD5's first 8 bytes, # the same in every client language. return int.from_bytes(hashlib.md5(s.encode()).digest()[:8], "big")
if __name__ == "__main__": keys = [key_hash(f"user:{i}") for i in range(100_000)] ten = [jump_hash(k, 10) for k in keys] eleven = [jump_hash(k, 11) for k in keys] print([ten.count(b) for b in range(10)]) # about 10,000 each moved = sum(1 for x, y in zip(ten, eleven) if x != y) print(f"moved {moved / len(keys):.1%}") # 9.3%, close to the ideal 1/11, all of it into bucket 10 print(all(y == 10 for x, y in zip(ten, eleven) if x != y)) # True # The catch: buckets are numbers, not names. Removing bucket 3 of 10 is not possible; # you can only shrink from the end. Map bucket numbers to replica groups instead.using System;using System.Buffers.Binary;using System.Linq;using System.Security.Cryptography;using System.Text;
public static class Jump{ // Jump consistent hash (Lamping and Veach, Google, 2014). Maps a 64 bit key to a bucket in // [0, buckets). No memory at all, near perfect balance, and growing from n to n + 1 buckets // moves exactly the keys that land in the new bucket, about 1/(n + 1) of them. public static int JumpHash(ulong key, int buckets) { long b = -1, j = 0; while (j < buckets) { b = j; // A 64 bit linear congruential step: a cheap, deterministic stream of random numbers // seeded by the key, so every caller computes the same sequence. unchecked: wrap. key = unchecked(key * 2862933555777941757UL + 1); // Jump straight to the next bucket count at which this key would move. Expected // number of jumps is ln(buckets), so 1,000 buckets take about 7 loop turns. j = (long)((b + 1) * ((double)(1L << 31) / (double)((key >> 33) + 1))); } return (int)b; }
// Jump hash needs a well mixed 64 bit integer, not a string. MD5's first 8 bytes, // the same in every client language. public static ulong KeyHash(string s) => BinaryPrimitives.ReadUInt64BigEndian(MD5.HashData(Encoding.UTF8.GetBytes(s)));}
public static class Program{ public static void Main() { var keys = Enumerable.Range(0, 100_000).Select(i => Jump.KeyHash($"user:{i}")).ToArray(); var ten = keys.Select(k => Jump.JumpHash(k, 10)).ToArray(); var eleven = keys.Select(k => Jump.JumpHash(k, 11)).ToArray(); var counts = Enumerable.Range(0, 10).Select(b => ten.Count(x => x == b)); Console.WriteLine(string.Join(", ", counts)); // about 10,000 each var movedTo = eleven.Where((y, i) => y != ten[i]).ToArray(); Console.WriteLine($"moved {100.0 * movedTo.Length / keys.Length:F1}%"); // 9.3%, close to the ideal 1/11, all of it into bucket 10 Console.WriteLine(movedTo.All(y => y == 10)); // True // The catch: buckets are numbers, not names. Removing bucket 3 of 10 is not possible; // you can only shrink from the end. Map bucket numbers to replica groups instead. }}package main
import ( "crypto/md5" "encoding/binary" "fmt")
// JumpHash is jump consistent hash (Lamping and Veach, Google, 2014). It maps a 64 bit key to a// bucket in [0, buckets). No memory at all, near perfect balance, and growing from n to n + 1// buckets moves exactly the keys that land in the new bucket, about 1/(n + 1) of them.func JumpHash(key uint64, buckets int) int { b, j := int64(-1), int64(0) for j < int64(buckets) { b = j // A 64 bit linear congruential step: a cheap, deterministic stream of random numbers // seeded by the key, so every caller computes the same sequence. uint64 wraps. key = key*2862933555777941757 + 1 // Jump straight to the next bucket count at which this key would move. Expected // number of jumps is ln(buckets), so 1,000 buckets take about 7 loop turns. j = int64(float64(b+1) * (float64(int64(1)<<31) / float64((key>>33)+1))) } return int(b)}
// KeyHash: jump hash needs a well mixed 64 bit integer, not a string. MD5's first 8 bytes,// the same in every client language.func KeyHash(s string) uint64 { d := md5.Sum([]byte(s)) return binary.BigEndian.Uint64(d[:8])}
func main() { const n = 100_000 counts := make([]int, 10) moved, allToNew := 0, true for i := 0; i < n; i++ { k := KeyHash(fmt.Sprintf("user:%d", i)) ten, eleven := JumpHash(k, 10), JumpHash(k, 11) counts[ten]++ if ten != eleven { moved++ allToNew = allToNew && eleven == 10 } } fmt.Println(counts) // about 10,000 each fmt.Printf("moved %.1f%%\n", 100*float64(moved)/float64(n)) // 9.3%, close to the ideal 1/11, all of it into bucket 10 fmt.Println(allToNew) // true // The catch: buckets are numbers, not names. Removing bucket 3 of 10 is not possible; // you can only shrink from the end. Map bucket numbers to replica groups instead.}import { createHash } from 'node:crypto';
// Jump consistent hash (Lamping and Veach, Google, 2014). Maps a 64 bit key to a bucket in// [0, buckets). No memory at all, near perfect balance, and growing from n to n + 1 buckets// moves exactly the keys that land in the new bucket, about 1/(n + 1) of them.function jumpHash(key: bigint, buckets: number): number { let b = -1; let j = 0; while (j < buckets) { b = j; // A 64 bit linear congruential step: a cheap, deterministic stream of random numbers // seeded by the key, so every caller computes the same sequence. BigInt, wrapped to 64 bits, // because a JavaScript number holds only 53 bits exactly. key = BigInt.asUintN(64, key * 2862933555777941757n + 1n); // Jump straight to the next bucket count at which this key would move. Expected // number of jumps is ln(buckets), so 1,000 buckets take about 7 loop turns. j = Math.floor((b + 1) * (2 ** 31 / (Number(key >> 33n) + 1))); } return b;}
// Jump hash needs a well mixed 64 bit integer, not a string. MD5's first 8 bytes,// the same in every client language.function keyHash(s: string): bigint { return createHash('md5').update(s).digest().readBigUInt64BE(0);}
const keys = Array.from({ length: 100_000 }, (_, i) => keyHash(`user:${i}`));const ten = keys.map((k) => jumpHash(k, 10));const eleven = keys.map((k) => jumpHash(k, 11));const counts = Array.from({ length: 10 }, (_, b) => ten.filter((x) => x === b).length);console.log(counts); // about 10,000 eachconst movedTo = eleven.filter((y, i) => y !== ten[i]);console.log(`moved ${((100 * movedTo.length) / keys.length).toFixed(1)}%`); // 9.3%, close to the ideal 1/11, all of it into bucket 10console.log(movedTo.every((y) => y === 10)); // true// The catch: buckets are numbers, not names. Removing bucket 3 of 10 is not possible;// you can only shrink from the end. Map bucket numbers to replica groups instead.Maglev hashing (Google, NSDI 2016). It was built for software load balancers that must choose a
backend for every packet, at line rate. The lookup table has a prime size M (65,537 by default).
Each backend gets a permutation of its slots, derived from two hashes of its name: an offset and a
skip. Backends take turns claiming their next preferred free slot, until the table is full.
A packet’s backend is then table[hash(5-tuple) mod M]: one array read. Every backend owns M/N
slots, plus or minus one, so balance is near perfect. Disruption is close to the floor, but not at
it, as the paper reports. In our simulation with 100 backends and M = 65,537, removing one changed
about 1.5% of slots, instead of the minimum 1%. Connection tracking keeps existing flows on their
backend. So that excess only matters for new flows.
- Color key
- Service
- Database
Fixed slots and a map. Hash keys into a fixed number of slots, far more than servers. Keep an explicit map from slot to server. Redis Cluster uses 16,384 slots, and Couchbase 1,024 vBuckets. Riak uses a fixed ring of 64 partitions by default. Moves are whatever you choose, so a rebalance is planned work you can watch and throttle.
The price is the map. Every client or proxy must fetch it, and follow redirects when it changes. The Dynamo paper reports this beat random tokens, on balance and on bookkeeping. Its third and final strategy splits the ring into equal fixed partitions, and assigns those to nodes.
| Scheme | Lookup | Memory per client | Balance | Moves on one change | Remove any server | Wins when |
|---|---|---|---|---|---|---|
hash mod N |
O(1) | None | Perfect | N/(N+1), almost all | Yes, at a cost | N never changes (a fixed set of partitions) |
| Ring with vnodes | O(log NV) | N·V points | ±1/√V | About 1/N, from many | Yes | Caches and Dynamo-style stores with changing members |
| Rendezvous (HRW) | O(N) | The member list | Perfect in expectation | Exactly the minimum | Yes | Tens of servers, replicas as top-k, weights |
| Jump hash | O(ln N) | None | Near perfect | The minimum, on add | No, only the last | Numbered shards that are replica groups |
| Maglev | O(1), one read | Table of M entries | Within one slot | Slightly above 1/N | Yes | Per-packet load balancing |
| Fixed slots + map | O(1) | Slot map | As planned | As planned | Yes | Stores that migrate data and want controlled moves |
How many virtual nodes
Section titled “How many virtual nodes”| Points per server | Spread of shares | Cost | Used by |
|---|---|---|---|
| 1 | About 100% | None, but a 5× hot server and cascading failures | Nobody, deliberately |
| 16, placed by an algorithm | A few percent | An allocator that picks tokens to fill the biggest gaps | Cassandra 4.0 and later |
| 100 to 200, random | 7 to 10% | Tiny for a cache client | Ketama (160), most cache clients |
| 256, random | About 6% | Hundreds of ranges per node to stream and repair | Cassandra before 4.0 |
For a cache client, more points cost nothing that matters, so 100 to 200 is the norm. For a database, the count also sets availability. Take many random tokens and three replicas. Almost every pair of nodes shares some range. So losing any two nodes leaves some range with one copy, and quorum reads for those keys fail. With few tokens, most pairs of failures are harmless.
Hot keys and bounded loads
Section titled “Hot keys and bounded loads”Every scheme above balances keys. None balances traffic. If one video, one celebrity or one prefix gets 30% of requests, its owner gets 30% of requests, however the hash is chosen. The ring also does not react to load: a server running hot keeps all its keys.
Consistent hashing with bounded loads (Mirrokni, Thorup and Zadimoghaddam, 2016) adds a cap. Pick ε, say 0.25. No server may carry more than ⌈(1 + ε) × average⌉ of the current load
(requests in flight, or connections). A request goes to its ring owner if that owner is under the
cap, otherwise to the next server clockwise that is. The paper proves the expected number of
reassignments per change is O(1/ε²), so a smaller ε buys tighter balance with more movement.
Vimeo contributed it to HAProxy as the hash-balance-factor option to spread load across its
video cache servers.
- Color key
- Service
import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import java.security.NoSuchAlgorithmException;import java.util.*;
// Consistent hashing with bounded loads (Mirrokni, Thorup and Zadimoghaddam, 2016).// A key goes to its usual ring owner unless that node already carries more than (1 + eps)// times the average load; then it goes to the next node clockwise that has room.class BoundedRing { private final double eps; // 0.25: no node carries more than 1.25 times the average final Map<String, Integer> load = new TreeMap<>(); // requests in flight, or keys held, per node private final long[] points; private final String[] owners;
BoundedRing(List<String> nodes, int vnodes, double eps) { this.eps = eps; // Unsigned order, so the ring sorts the same way as in languages with unsigned 64 bit ints. TreeMap<Long, String> ring = new TreeMap<>(Long::compareUnsigned); for (String n : nodes) { load.put(n, 0); for (int i = 0; i < vnodes; i++) ring.put(hash64(n + "-" + i), n); } points = ring.keySet().stream().mapToLong(Long::longValue).toArray(); owners = ring.values().toArray(new String[0]); }
// The bound moves with the total: counting the request being placed keeps it above // zero when the system is empty, and the ceiling guarantees some node always has room. private int capacity() { int total = load.values().stream().mapToInt(Integer::intValue).sum() + 1; return (int) Math.ceil((1 + eps) * total / load.size()); }
String acquire(String key) { int cap = capacity(); int i = firstAtOrAfter(hash64(key)); // Walk clockwise from the key's place. Most keys stop at the first point; only keys // whose owner is full move on, and they move to the same next node every time, so a // hot key's overflow still lands on a predictable, cache-warm second choice. for (int step = 0; step < points.length; step++) { String node = owners[(i + step) % points.length]; if (load.get(node) < cap) { load.merge(node, 1, Integer::sum); return node; } } throw new AssertionError("unreachable: total capacity always exceeds total load"); }
// When a request finishes, its slot frees up. Keys are not rebalanced back eagerly. void release(String node) { load.merge(node, -1, Integer::sum); }
private int firstAtOrAfter(long h) { int lo = 0, hi = points.length; while (lo < hi) { int mid = (lo + hi) >>> 1; if (Long.compareUnsigned(points[mid], h) < 0) lo = mid + 1; else hi = mid; } return lo; // may be points.length: the walk's modulo wraps it to the first point }
static long hash64(String s) { try { byte[] d = MessageDigest.getInstance("MD5").digest(s.getBytes(StandardCharsets.UTF_8)); long h = 0; for (int i = 0; i < 8; i++) h = (h << 8) | (d[i] & 0xff); return h; } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } }}
class Main { public static void main(String[] args) { BoundedRing ring = new BoundedRing(List.of("a", "b", "c", "d"), 100, 0.25); // 1,000 requests stay in flight, and half of them ask for one hot key. for (int i = 0; i < 1000; i++) ring.acquire(i % 2 == 0 ? "video:hot" : "video:" + i); System.out.println(ring.load); // {a=197, b=310, c=313, d=180}: none above // ceil(1.25 * 1000 / 4) = 313. A plain ring puts 621 on c, the hot key's owner. }}import bisectimport hashlibimport math
# Consistent hashing with bounded loads (Mirrokni, Thorup and Zadimoghaddam, 2016).# A key goes to its usual ring owner unless that node already carries more than (1 + eps)# times the average load; then it goes to the next node clockwise that has room.class BoundedRing: def __init__(self, nodes: list[str], vnodes: int, eps: float): self.eps = eps # 0.25: no node carries more than 1.25 times the average self.load = {n: 0 for n in nodes} # requests in flight, or keys held, per node ring = {_hash64(f"{n}-{i}"): n for n in nodes for i in range(vnodes)} self._points = sorted(ring) self._owners = [ring[p] for p in self._points]
def _capacity(self) -> int: # The bound moves with the total: counting the request being placed keeps it above # zero when the system is empty, and the ceiling guarantees some node always has room. total = sum(self.load.values()) + 1 return math.ceil((1 + self.eps) * total / len(self.load))
def acquire(self, key: str) -> str: cap = self._capacity() i = bisect.bisect_left(self._points, _hash64(key)) # Walk clockwise from the key's place. Most keys stop at the first point; only keys # whose owner is full move on, and they move to the same next node every time, so a # hot key's overflow still lands on a predictable, cache-warm second choice. for step in range(len(self._points)): node = self._owners[(i + step) % len(self._points)] if self.load[node] < cap: self.load[node] += 1 return node raise AssertionError("unreachable: total capacity always exceeds total load")
def release(self, node: str) -> None: # When a request finishes, its slot frees up. Keys are not rebalanced back eagerly. self.load[node] -= 1
def _hash64(s: str) -> int: return int.from_bytes(hashlib.md5(s.encode()).digest()[:8], "big")
if __name__ == "__main__": ring = BoundedRing(["a", "b", "c", "d"], vnodes=100, eps=0.25) # 1,000 requests stay in flight, and half of them ask for one hot key. for i in range(1000): ring.acquire("video:hot" if i % 2 == 0 else f"video:{i}") print(ring.load) # {'a': 197, 'b': 310, 'c': 313, 'd': 180}: none above # ceil(1.25 * 1000 / 4) = 313. A plain ring puts 621 on c, the hot key's owner.using System;using System.Buffers.Binary;using System.Collections.Generic;using System.Linq;using System.Security.Cryptography;using System.Text;
// Consistent hashing with bounded loads (Mirrokni, Thorup and Zadimoghaddam, 2016).// A key goes to its usual ring owner unless that node already carries more than (1 + eps)// times the average load; then it goes to the next node clockwise that has room.public sealed class BoundedRing{ private readonly double _eps; // 0.25: no node carries more than 1.25 times the average public SortedDictionary<string, int> Load { get; } = new(StringComparer.Ordinal); // requests in flight, or keys held, per node private readonly ulong[] _points; // sorted positions on the ring private readonly string[] _owners; // _owners[i] owns the keys hashing to (_points[i-1], _points[i]]
public BoundedRing(IEnumerable<string> nodes, int vnodes, double eps) { _eps = eps; var ring = new SortedDictionary<ulong, string>(); foreach (var n in nodes) { Load[n] = 0; for (int i = 0; i < vnodes; i++) ring[Hash64($"{n}-{i}")] = n; } _points = ring.Keys.ToArray(); _owners = ring.Values.ToArray(); }
// The bound moves with the total: counting the request being placed keeps it above // zero when the system is empty, and the ceiling guarantees some node always has room. private int Capacity() { int total = Load.Values.Sum() + 1; return (int)Math.Ceiling((1 + _eps) * total / Load.Count); }
public string Acquire(string key) { int cap = Capacity(); int i = FirstAtOrAfter(Hash64(key)); // Walk clockwise from the key's place. Most keys stop at the first point; only keys // whose owner is full move on, and they move to the same next node every time, so a // hot key's overflow still lands on a predictable, cache-warm second choice. for (int step = 0; step < _points.Length; step++) { string node = _owners[(i + step) % _points.Length]; if (Load[node] < cap) { Load[node]++; return node; } } throw new InvalidOperationException("unreachable: total capacity always exceeds total load"); }
// When a request finishes, its slot frees up. Keys are not rebalanced back eagerly. public void Release(string node) => Load[node]--;
private int FirstAtOrAfter(ulong h) { int lo = 0, hi = _points.Length; while (lo < hi) { int mid = (lo + hi) >> 1; if (_points[mid] < h) lo = mid + 1; else hi = mid; } return lo; // may be _points.Length: the walk's modulo wraps it to the first point }
private static ulong Hash64(string s) => BinaryPrimitives.ReadUInt64BigEndian(MD5.HashData(Encoding.UTF8.GetBytes(s)));}
public static class Program{ public static void Main() { var ring = new BoundedRing(new[] { "a", "b", "c", "d" }, 100, 0.25); // 1,000 requests stay in flight, and half of them ask for one hot key. for (int i = 0; i < 1000; i++) ring.Acquire(i % 2 == 0 ? "video:hot" : $"video:{i}"); Console.WriteLine(string.Join(", ", ring.Load.Select(kv => $"{kv.Key}={kv.Value}"))); // a=197, b=310, c=313, d=180: none above // ceil(1.25 * 1000 / 4) = 313. A plain ring puts 621 on c, the hot key's owner. }}package main
import ( "crypto/md5" "encoding/binary" "fmt" "math" "sort")
// BoundedRing is consistent hashing with bounded loads (Mirrokni, Thorup and Zadimoghaddam,// 2016). A key goes to its usual ring owner unless that node already carries more than// (1 + eps) times the average load; then it goes to the next node clockwise that has room.type BoundedRing struct { eps float64 // 0.25: no node carries more than 1.25 times the average Load map[string]int // requests in flight, or keys held, per node points []uint64 // sorted positions on the ring owners []string // owners[i] owns the keys hashing to (points[i-1], points[i]]}
func NewBoundedRing(nodes []string, vnodes int, eps float64) *BoundedRing { r := &BoundedRing{eps: eps, Load: map[string]int{}} ring := map[uint64]string{} for _, n := range nodes { r.Load[n] = 0 for i := 0; i < vnodes; i++ { ring[hash64(fmt.Sprintf("%s-%d", n, i))] = n } } for p := range ring { r.points = append(r.points, p) } sort.Slice(r.points, func(i, j int) bool { return r.points[i] < r.points[j] }) for _, p := range r.points { r.owners = append(r.owners, ring[p]) } return r}
// capacity: the bound moves with the total. Counting the request being placed keeps it above// zero when the system is empty, and the ceiling guarantees some node always has room.func (r *BoundedRing) capacity() int { total := 1 for _, l := range r.Load { total += l } return int(math.Ceil((1 + r.eps) * float64(total) / float64(len(r.Load))))}
func (r *BoundedRing) Acquire(key string) string { limit := r.capacity() h := hash64(key) i := sort.Search(len(r.points), func(i int) bool { return r.points[i] >= h }) // Walk clockwise from the key's place. Most keys stop at the first point; only keys // whose owner is full move on, and they move to the same next node every time, so a // hot key's overflow still lands on a predictable, cache-warm second choice. for step := 0; step < len(r.points); step++ { node := r.owners[(i+step)%len(r.points)] if r.Load[node] < limit { r.Load[node]++ return node } } panic("unreachable: total capacity always exceeds total load")}
// Release frees a slot when a request finishes. Keys are not rebalanced back eagerly.func (r *BoundedRing) Release(node string) { r.Load[node]--}
func hash64(s string) uint64 { d := md5.Sum([]byte(s)) return binary.BigEndian.Uint64(d[:8])}
func main() { ring := NewBoundedRing([]string{"a", "b", "c", "d"}, 100, 0.25) // 1,000 requests stay in flight, and half of them ask for one hot key. for i := 0; i < 1000; i++ { key := fmt.Sprintf("video:%d", i) if i%2 == 0 { key = "video:hot" } ring.Acquire(key) } fmt.Println(ring.Load) // map[a:197 b:310 c:313 d:180]: none above // ceil(1.25 * 1000 / 4) = 313. A plain ring puts 621 on c, the hot key's owner.}import { createHash } from 'node:crypto';
// Consistent hashing with bounded loads (Mirrokni, Thorup and Zadimoghaddam, 2016).// A key goes to its usual ring owner unless that node already carries more than (1 + eps)// times the average load; then it goes to the next node clockwise that has room.class BoundedRing { private readonly eps: number; // 0.25: no node carries more than 1.25 times the average readonly load = new Map<string, number>(); // requests in flight, or keys held, per node private readonly points: bigint[]; // sorted positions on the ring private readonly owners: string[]; // owners[i] owns the keys hashing to (points[i-1], points[i]]
constructor(nodes: string[], vnodes: number, eps: number) { this.eps = eps; const ring = new Map<bigint, string>(); for (const n of nodes) { this.load.set(n, 0); for (let i = 0; i < vnodes; i++) ring.set(hash64(`${n}-${i}`), n); } this.points = [...ring.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); this.owners = this.points.map((p) => ring.get(p)!); }
// The bound moves with the total: counting the request being placed keeps it above // zero when the system is empty, and the ceiling guarantees some node always has room. private capacity(): number { let total = 1; for (const l of this.load.values()) total += l; return Math.ceil(((1 + this.eps) * total) / this.load.size); }
acquire(key: string): string { const cap = this.capacity(); const i = this.firstAtOrAfter(hash64(key)); // Walk clockwise from the key's place. Most keys stop at the first point; only keys // whose owner is full move on, and they move to the same next node every time, so a // hot key's overflow still lands on a predictable, cache-warm second choice. for (let step = 0; step < this.points.length; step++) { const node = this.owners[(i + step) % this.points.length]; if (this.load.get(node)! < cap) { this.load.set(node, this.load.get(node)! + 1); return node; } } throw new Error('unreachable: total capacity always exceeds total load'); }
// When a request finishes, its slot frees up. Keys are not rebalanced back eagerly. release(node: string): void { this.load.set(node, this.load.get(node)! - 1); }
private firstAtOrAfter(h: bigint): number { let lo = 0; let hi = this.points.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (this.points[mid] < h) lo = mid + 1; else hi = mid; } return lo; // may be points.length: the walk's modulo wraps it to the first point }}
function hash64(s: string): bigint { return createHash('md5').update(s).digest().readBigUInt64BE(0);}
const ring = new BoundedRing(['a', 'b', 'c', 'd'], 100, 0.25);// 1,000 requests stay in flight, and half of them ask for one hot key.for (let i = 0; i < 1000; i++) ring.acquire(i % 2 === 0 ? 'video:hot' : `video:${i}`);console.log(Object.fromEntries(ring.load)); // { a: 197, b: 310, c: 313, d: 180 }: none above// ceil(1.25 * 1000 / 4) = 313. A plain ring puts 621 on c, the hot key's owner.The overflow is predictable. Requests for a hot key spill to the same next server every time. So that server’s cache warms up for the hot key too. The key’s normal requests still go to its owner.
For storage, hot keys need different tools. Replicate the hot key to more servers, and read from
any. Put a small in-process cache in front. Or split one logical key into several (key#1 to
key#8), and merge on read. The distributed cache
problem works through these.
Go deeperHash functions, and why the choice matters2 min
The ring is only as good as the hash. What it needs is good mixing and the same output in every language, not cryptographic strength.
| Hash | Speed (64 bit output) | Notes |
|---|---|---|
| MD5 | Slowest of these | Ketama’s choice in 2007, available everywhere, more than strong enough |
| MurmurHash3 | Fast | Cassandra’s Murmur3Partitioner, the common modern default |
| xxHash64 | Fastest | Several GB/s per core, good mixing |
| CRC16, CRC32 | Fast | Fine for picking one of 16,384 slots (Redis Cluster uses CRC16), weakly mixed for ring positions |
Two traps. Language built-in hashes, such as Python’s hash(), are randomized per process. So two
clients disagree on every key. Java’s String.hashCode() is stable, but poorly mixed. Similar keys
such as user:1 and user:2 land next to each other, and sequential keys cluster on one arc.
How it fails
Section titled “How it fails”| Failure | What you see | Blast radius | Fix |
|---|---|---|---|
| Clients disagree on the ring | Hit ratio drops for some clients; stale values after a delete | Every key whose owner differs | One library and test vectors per language; a ring epoch on requests |
| One point per server, then a failure | The dead server’s neighbor doubles its load, then fails too | Walks around the ring | Virtual nodes, so a dead server’s load spreads |
| Membership flaps | Cold misses as keys move away, stale hits when they move back | 1/N of keys per flap | Separate “down” from “removed”; flush a cache server that rejoins |
| One hot key | One server at 100% CPU while the rest idle | Everything else on that server | Replicate or split the key, local cache, bounded loads for caches |
| Replicas on one machine or zone | Data loss or unavailability when one host or rack fails | Keys whose walk hit duplicates | Skip already chosen servers and zones in the walk |
| A new node in a storage ring | Streaming saturates disks and network on many peers | Latency on every node it pulls from | Add one node at a time, throttle streaming |
| Adding a cache server without warm-up | 1/N of reads miss at once | The database | Add servers one at a time, at low traffic, or warm from the old owner |
Clients that disagree are the dangerous one. Two clients with different rings do not fail
loudly. Client X writes user:42 to server B. Client Y reads it from server D, misses, and fills D
from the database. Worse, X deletes user:42 after an update. But Y keeps reading the old copy on D,
until its TTL runs out.
The causes are ordinary. A different hash in one language’s library. Servers listed by IP in one config, and by hostname in another. Or a config push that reached half the fleet. Detect it by exporting a ring checksum and epoch from every client. Alert when more than one is live for longer than a push takes.
Flapping is the subtle one. Say a missed heartbeat removes a server from the ring, and the next heartbeat adds it back. Every flap moves 1/N of keys twice. Each move is a cold miss, or a range to stream. The move back is worse in a cache. The returning server still holds its old copies. Any key written or deleted elsewhere while it was gone now serves a stale value, until its TTL expires.
Production systems keep two ideas apart. A server that is down gets its requests routed to the next replica, or answered as misses. Its ownership changes only when an operator removes it, or after a long timeout, often minutes.
At scale
Section titled “At scale”| Scheme | Lookup cost, roughly | State |
|---|---|---|
| Ring, 160 × 1,000 | 17 comparisons, 100 to 200 ns | 160,000 points, about 2.5 MB |
| Rendezvous, 100 | 100 hashes, 1 to 5 µs with a fast hash | The member list |
| Jump, 1,000 | About 7 loop turns, tens of ns | None |
| Maglev | One array read, a few ns | 65,537 entries, rebuilt in milliseconds |
At 10 times the servers, say 100 to 1,000, membership changes stop being events. At 1,000 servers, some machine fails or gets replaced most days. Each change moves only 0.1% of keys, which is the point. But the change must reach every client quickly and in order. So the epoch and the config push become the critical path. Rendezvous at O(N) starts to cost real CPU per request. Split it into two levels (rendezvous over 30 groups, then over the 30 servers in a group), or move to a ring.
At 100 times, the ring itself stops being one thing. Large deployments split into cells of a few hundred servers, each with its own ring, and route to a cell first. Gossip, repair and ring changes all slow down with size, and a cell bounds the damage of a bad change. Maglev tables must stay much larger than the backend count, roughly M ≥ 100 × N, so the per-backend imbalance stays under 1%.
What to monitor:
- Keys and bytes per server, and requests per second per server, as max divided by mean. Keys measure the hash, requests measure the traffic, and the gap between them is your hot keys.
- Hit ratio during and after every membership change, and the database’s read rate beside it.
- The number of distinct ring epochs live across clients, and how long two coexist.
- Streaming throughput and duration when a storage node joins or leaves.
- The top keys by request rate, so a hot key is visible before it saturates its server.
In the interview
Section titled “In the interview”Interviewer asksWhy not just use hash(key) mod N?
Because changing N moves almost every key. Going from N to N + 1 servers, a key stays only when its hash gives the same remainder for both. That’s a 1 in N + 1 chance. Going from 10 to 11 servers moves 91% of keys. For a cache, that is a near-total cold start. A 95% hit ratio becomes about 9%, and the database sees 18 times its usual reads. The floor is 1/(N+1), the new server’s fair share. A hash ring with virtual nodes gets close to that floor, and so do rendezvous and jump hashing. If N truly never changes, for example a fixed set of 1,024 partitions, then mod N is fine and simpler.
Interviewer asksHow many virtual nodes would you use, and what do they cost?
A server’s share varies by about 1/√V. So 100 points gives about 10%, and 160 gives 8%. With 100 servers at 160 points, the busiest carries about 1.2 times the average. That number sets how much capacity I buy. For a cache client, 100 to 200 points cost a couple of megabytes and a binary search, so I’d use 160, like ketama. For a database, each point is a range to stream and repair. And many random tokens mean almost any two failures share a range, which hurts quorum availability. There I’d use fewer, around 16, placed by an allocation algorithm as Cassandra 4 does. Or fixed partitions, assigned by a map.
Interviewer asksA cache server dies. What happens to its keys and its traffic?
With virtual nodes, each of its arcs passes to the server holding the next point. So its keys spread over almost all the others, and each takes about 1/N extra load. All of those keys miss once and refill from the database. So the database sees a burst of 1/N of the read traffic. I would not remove the server from the ring on the first missed heartbeat. Route around it while it is down, and change ownership only after a timeout, or by an operator. Then a flap does not move keys twice. If the cache has replicas, reads go to the replica instead, and the database never sees the burst.
Interviewer asksOne key gets 30% of all requests. Does consistent hashing help?
No. It balances keys, and that key hashes to one place on any scheme. For a cache tier, I could use bounded loads. Cap each server at 1.25 times the average in-flight load, and spill the excess to the next server clockwise. That server then also caches the hot key. Storage is different, because a key must be found where it was written. There I’d replicate the hot key to more servers and read from any, add a small local cache in front, or split it into sub-keys and merge on read. The first step is finding it: a top-k of keys by request rate on each server.
Seen in
Section titled “Seen in”- Design a distributed cache: 64 shards on a ring, with 160 virtual nodes each (ketama). A client library routes requests, and swaps rings by epoch.
- Design a key-value store: 16 tokens per node, and a preference list of 3 distinct machines in distinct racks. Membership by gossip, and sloppy quorums with hinted handoff.
- Design a metrics and monitoring system: each series goes to 3 ingesters on a token ring, one per zone. Rule engines are sharded by rule group, on a ring.
- Design a URL shortener: the counterexample, a fixed set of 4,096 slots with an explicit map, because moves should be planned rather than computed.
- Design search autocomplete: why no hashing scheme fixes a hot key, and the edge cache that does.
Beyond these problems, you’ll find these schemes in:
- Amazon’s Dynamo and Apache Cassandra (rings with tokens).
- Memcached clients and Nginx’s
hash … consistent(ketama). - Envoy’s
ring_hashandmaglevload balancers. - Google’s Maglev and Facebook’s Katran (Maglev tables for layer 4 load balancing).
- GitHub’s GLB Director (rendezvous hashing to build its forwarding table).
- CDN cache hierarchies, such as Apache Traffic Server’s consistent hash parent selection. It sends each URL to one parent cache, so the parents do not all store the same objects.
The problem. hash mod N moves N/(N+1) of keys when N changes: 91% going from 10 to 11
servers. The floor is 1/(N+1).
The ring. Servers and keys hash onto a circle. A key belongs to the first server clockwise. One change moves about 1/N of keys. Lookup is a binary search.
Virtual nodes. One point per server leaves the busiest server at 3 to 5 times the average. V points per server cut the spread to about 1/√V: 160 points, 8%, busiest of 100 at 1.2×. A dead server’s load spreads over everyone. Weights are more points.
Replicas. Walk clockwise, skipping servers and zones already chosen.
Alternatives. Rendezvous: highest score wins, O(N), replicas as top-k. Jump: no memory, near perfect balance, add or remove only at the end. Maglev: one table read per packet, near perfect balance, slightly more than minimal movement. Slots and a map: planned moves, a map to track.
Failures. Clients that disagree on the ring (fix with an epoch and test vectors). Flapping membership (separate down from removed). Hot keys (bounded loads for caches, replication or splitting for storage).
Further reading
Section titled “Further reading”- Partitioning and sharding, for range versus hash keys and resharding. Replication, for what happens on the preference list. Caching and load balancing, for the tiers that use these schemes.
- Dynamo: Amazon’s Highly Available Key-value Store, SOSP 2007: the ring, virtual nodes, preference lists, hinted handoff, and the three partitioning strategies it compared.
- A Fast, Minimal Memory, Consistent Hash Algorithm, Lamping and Veach, 2014: jump consistent hash, with its proof and a comparison with rings.
- Maglev: A Fast and Reliable Software Network Load Balancer, NSDI 2016. It covers the lookup table, its fill algorithm, and how disruption and balance trade off with table size.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.