System Design (High-Level Design) Module 9 – Partitioning and sharding
Consistent hashing and rendezvous hashing
Why hash mod N moves most keys when a node joins, how a consistent-hash ring and rendezvous hashing move only the new node's share, and when to use each.
What you will learn
- Explain why hash mod N moves almost every key when a node joins
- Implement the lookup of a consistent-hash ring with virtual nodes
- Compare consistent hashing, rendezvous hashing, jump consistent hash and fixed hash slots
- Build a preference list that puts every replica on a distinct machine
- Plan a resize whose cache misses stay within what the database can absorb
Before you start
What you’ll learn · Before you start
What you will learn
- Explain why hash mod N moves almost every key when a node joins
- Implement the lookup of a consistent-hash ring with virtual nodes
- Compare consistent hashing, rendezvous hashing, jump consistent hash and fixed hash slots
- Build a preference list that puts every replica on a distinct machine
- Plan a resize whose cache misses stay within what the database can absorb
Before you start
On this page
Consistent hashing and rendezvous hashing decide which node stores a key so that resizing a cluster moves as few keys as possible. With the obvious rule, the key’s hash modulo the number of nodes, adding a fifth node to four moves about 80 % of the keys. With either scheme, only the keys that the new node takes over move: about a fifth.
Consistent hashing puts nodes and keys on one circle of hash values and gives each key to the first node clockwise from it. Rendezvous hashing, also called highest random weight hashing, scores every node for each key and gives the key to the top score. Use one of them wherever nodes come and go while the data stays: cache clusters, sharded databases and load balancers that keep each user on one server.
Hash mod N moves almost every key when a node joins
The partitioning lesson split rows by hash(key) mod N. The rule spreads keys evenly, but its answer depends on N. A
key keeps its node only when its hash leaves the same remainder for the old count and the new one. From 4 nodes to 5
that happens for 4 hash values in every 20, so 80 % of the keys move, and most of them move between old nodes that
have nothing to do with the new one. In a cache, every moved key is a miss that lands on the database; in a
database, it is data to copy.
This script places 100,000 keys on 4 nodes, adds a fifth, E, and counts the keys that change owner under four schemes:
import bisect
def mix(h):
"""MurmurHash3's final mix: spreads the bits of a 32-bit number."""
h ^= h >> 16
h = (h * 0x85EBCA6B) & 0xFFFFFFFF
h ^= h >> 13
h = (h * 0xC2B2AE35) & 0xFFFFFFFF
return h ^ (h >> 16)
def h32(text):
"""A 32-bit hash of a text: FNV-1a, then the final mix, so that similar keys land far apart."""
h = 0x811C9DC5
for ch in text:
h = ((h ^ ord(ch)) * 0x01000193) & 0xFFFFFFFF
return mix(h)
KEYS = [h32(f"user:{i}") for i in range(100_000)] # each key's hash, computed once
BEFORE = ["A", "B", "C", "D"]
AFTER = BEFORE + ["E"]
def modulo(nodes):
return lambda k: nodes[k % len(nodes)]
def ring(nodes, points):
"""Each node at `points` places on a ring of 2^32 positions; a key belongs to the next point clockwise."""
placed = sorted((h32(f"{n}#{i}"), n) for n in nodes for i in range(points))
positions = [p for p, _ in placed]
return lambda k: placed[bisect.bisect_left(positions, k) % len(placed)][1]
def rendezvous(nodes):
"""Score every node with a hash of the pair (node, key); the highest score wins."""
hashed = [(h32(n), n) for n in nodes]
return lambda k: max(hashed, key=lambda hn: mix(hn[0] ^ k))[1]
schemes = [
("hash mod N", modulo),
("ring, 1 point", lambda nodes: ring(nodes, 1)),
("ring, 100 points", lambda nodes: ring(nodes, 100)),
("rendezvous", rendezvous),
]
print(f"{len(KEYS):,} keys on {len(BEFORE)} nodes, then E joins")
print("(an even share is 20.0 %)\n")
print(f"{'':<17}{'moved':>7}{'between':>9}{'busiest':>9}")
print(f"{'':<17}{'':>7}{'old':>9}{'node':>9}")
for name, make in schemes:
old, new = make(BEFORE), make(AFTER)
moved = between_old = 0
load = dict.fromkeys(AFTER, 0)
for k in KEYS:
a, b = old(k), new(k)
load[b] += 1
if a != b:
moved += 1
between_old += b != "E"
pct = lambda n: f"{100 * n / len(KEYS):.1f} %"
print(f"{name:<17}{pct(moved):>7}{pct(between_old):>9}{pct(max(load.values())):>9}") Output
100,000 keys on 4 nodes, then E joins
(an even share is 20.0 %)
moved between busiest
old node
hash mod N 79.9 % 59.9 % 20.1 %
ring, 1 point 7.5 % 0.0 % 47.1 %
ring, 100 points 25.4 % 0.0 % 25.4 %
rendezvous 19.9 % 0.0 % 20.2 %
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 remap.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Hash mod N moved 79.9 % of the keys, and 59.9 % moved between the old nodes. Every other scheme moved keys only to E. A ring with one point per node moved just 7.5 %, because E landed on a short stretch of the circle, while the busiest node held 47.1 % of all keys. With 100 points per node the ring moved 25.4 %, because E’s points happened to cover a quarter of the circle instead of a fifth, and rendezvous hashing moved 19.9 %. Azure’s sharding pattern describes the same trade: modulo hashing reassigns most keys when a shard is added or removed.
Checkpoint
Check: What hash mod N moves
The ring: a key belongs to the next node clockwise
Bend the hash values from 0 to 2³² − 1 into a circle. Each node is hashed to a point on it, and each key belongs to the first node point clockwise from the key’s own hash. A node that joins takes over only the stretch of circle just before its point; a node that leaves hands its stretch to the next node. Karger and colleagues introduced the idea in 1997 for web caches, defining a consistent hash function as one that changes minimally when its range changes. The figure shows E joining between B and C:
What moves when a node joins a consistent-hash ring
Text description of the diagram
The diagram shows a ring of positions from 0 to 2^32 − 1 that wraps round at the top, read clockwise. Four nodes sit on it: A near the top right, B on the right, C at the bottom left and D on the left. Four keys sit just inside the ring, and each belongs to the first node point clockwise from it:
- k1, between A and B, belongs to B.
- k2, between B and C, belonged to C until node E joined on the arc between B and C. Now the first point clockwise from k2 is E, so k2 moves to E.
- k3, between E and C, still belongs to C.
- k4, between D and the top, belongs to A, because the ring wraps round.
The arc from B to E is highlighted: when E joins, only the keys on that arc change owner, and every one of them moves to E. Keys elsewhere on the ring keep their node.
One point per node leaves stretches of very different lengths, so real rings give each node many points, called The many points that one physical node takes on a hash ring. Its share of the keys is then the sum of many short stretches, which evens out, and a bigger machine can take more points., and a larger machine gets more of them. In the model below, add and remove servers, change the virtual nodes and watch which keys move.
Add and remove servers, change the number of virtual nodes, and see which keys move. Each key belongs to the first server point clockwise from it.
At the start: 3 servers with 4 virtual nodes each hold 200 keys: A 69 (35%), B 41 (21%), C 90 (45%).
Checkpoint
Check: The ring and its virtual nodes
Rendezvous hashing: the highest score wins
Rendezvous hashing needs no circle. To place a key, every node gets a score from a hash of the pair (node, key), and the node with the highest score stores the key. Thaler and Ravishankar described it in 1998 as highest random weight mapping. When a node leaves, only its own keys move, each to the node that scored second for it; a new node wins exactly the keys for which it now scores highest. The cost is a lookup that scores every node, which is cheap for tens or hundreds of nodes and slow for many thousands. Weights let a bigger node win a proportionally larger share. Step through the scores of two keys:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- One key, four nodes. There is no ring to keep. To place user:2, each of the four nodes scores the pair (node, key) with the same 32-bit hash, and the highest score wins.
- A and B score it. A's score for user:2 is 4,003,376,559 and B's is 3,818,784,405. A score depends on both names, so another key ranks the nodes in another order.
- C and D score it. C scores 1,362,213,446 and D 2,598,365,037. All four scores are computed on every lookup; the client keeps only the list of nodes.
- A wins. A's score is the highest of the four, so A stores user:2.
- E joins. Node E joins, and every key is now scored by five nodes. For user:2, E scores 4,190,020,205, more than A's 4,003,376,559.
- user:2 moves to E. So user:2 moves, and it moves to E, the only node whose score changed the ranking. A key moves only when the new node outscores every old one.
- A key that stays. Another key ranks the nodes in another order. For user:4, A scores 3,057,577,012, the highest of all five, and E only 1,304,290,658, so user:4 stays on A. E's arrival never touches it.
- If A leaves instead. Had A left, user:4 would go to the node that scored second for it, D with 1,549,533,969. A leaving moves only A's own keys, each to its own second choice.
Checkpoint
Check: Rendezvous hashing
The rules differ in what a lookup costs and what a resize moves
| Criterion | Hash mod N | Consistent hashing | Rendezvous hashing |
|---|---|---|---|
| Lookup | One division | A binary search of the sorted points | One score per node; the top score wins |
| Client state | Each client keeps the node count | Each client keeps every point | Each client keeps the node list |
| Node joins | Most keys move | Only the keys of its new stretch move | Only the keys it now wins move |
| Node leaves | Most keys move | Its stretch goes to the next node | Its keys go to their second choice |
| Node sizes | All nodes count the same | A bigger node gets more points | A bigger node gets a higher weight |
| When to choose | A node count that never changes | Many nodes that come and go | Tens to hundreds of nodes, no shared state |
The three rules fit in a short program. It places 2,000 keys on nodes A to D, adds E, and counts the keys that change owner:
Python 3.14 · main.py
from bisect import bisect_left
NODES = "ABCDE"
def h32(text: str) -> int:
# FNV-1a, then MurmurHash3's final mix.
h = 0x811C9DC5
for byte in text.encode():
h = ((h ^ byte) * 0x01000193) & 0xFFFFFFFF
h = ((h ^ (h >> 16)) * 0x85EBCA6B) & 0xFFFFFFFF
h = ((h ^ (h >> 13)) * 0xC2B2AE35) & 0xFFFFFFFF
return h ^ (h >> 16)
# 100 points a node, packed as position * 8 + node number.
RINGS = {
n: sorted(h32(f"{NODES[i]}#{p}") * 8 + i for i in range(n) for p in range(100))
for n in (4, 5)
}
def by_ring(n: int, key: str) -> str:
# The first point at or after the key, wrapping round.
ring = RINGS[n]
return NODES[ring[bisect_left(ring, h32(key) * 8) % len(ring)] % 8]
def by_rendezvous(n: int, key: str) -> str:
return max(NODES[:n], key=lambda node: h32(f"{node}|{key}"))
keys = [f"user:{i}" for i in range(2_000)]
schemes = {
"hash mod N": lambda n, key: NODES[h32(key) % n],
"ring": by_ring,
"rendezvous": by_rendezvous,
}
for name, place in schemes.items():
moved = [key for key in keys if place(4, key) != place(5, key)]
to_e = "yes" if all(place(5, key) == "E" for key in moved) else "no"
print(f"{name}: {len(moved)} keys move, all to E: {to_e}") In Python: bisect_left finds the first point at or after the key, and max keeps the first node with the highest score, which settles a tie.
Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
const NODES = "ABCDE";
function h32(text) {
// FNV-1a, then MurmurHash3's final mix.
let h = 0x811c9dc5;
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193);
h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
return (h ^ (h >>> 16)) >>> 0;
}
// 100 points a node, packed as position * 8 + node number.
const ring = (n) =>
Array.from({ length: n * 100 }, (_, k) => {
const node = Math.floor(k / 100);
return h32(`${NODES[node]}#${k % 100}`) * 8 + node;
}).sort((a, b) => a - b);
const RINGS = { 4: ring(4), 5: ring(5) };
function byRing(n, key) {
// The first point at or after the key, wrapping round.
const ring = RINGS[n];
const target = h32(key) * 8;
let lo = 0;
let hi = ring.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (ring[mid] < target) lo = mid + 1;
else hi = mid;
}
return NODES[ring[lo % ring.length] % 8];
}
function byRendezvous(n, key) {
const score = (node) => h32(`${node}|${key}`);
return [...NODES.slice(0, n)].reduce((best, node) => (score(node) > score(best) ? node : best));
}
const keys = Array.from({ length: 2000 }, (_, i) => `user:${i}`);
const schemes = {
"hash mod N": (n, key) => NODES[h32(key) % n],
ring: byRing,
rendezvous: byRendezvous,
};
for (const [name, place] of Object.entries(schemes)) {
const moved = keys.filter((key) => place(4, key) !== place(5, key));
const toE = moved.every((key) => place(5, key) === "E") ? "yes" : "no";
console.log(`${name}: ${moved.length} keys move, all to E: ${toE}`);
} In JavaScript: Math.imul multiplies as 32-bit integers, and >>> 0 reads the result as unsigned. JavaScript has no binary search of its own, so byRing writes the search that bisect_left does in Python; reduce keeps the first of equal top scores.
Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.stream.IntStream;
public class Main {
static final String NODES = "ABCDE";
static final List<String> KEYS = IntStream.range(0, 2_000).mapToObj(i -> "user:" + i).toList();
static final long[][] RINGS = {ring(4), ring(5)};
interface Place {
char at(int n, String key);
}
static long h32(String text) {
// FNV-1a, then MurmurHash3's final mix.
int h = 0x811C9DC5;
for (byte b : text.getBytes(StandardCharsets.UTF_8)) {
h = (h ^ (b & 0xFF)) * 0x01000193;
}
h = (h ^ (h >>> 16)) * 0x85EBCA6B;
h = (h ^ (h >>> 13)) * 0xC2B2AE35;
return Integer.toUnsignedLong(h ^ (h >>> 16));
}
// 100 points a node, packed as position * 8 + node number.
static long[] ring(int n) {
long[] ring = new long[n * 100];
for (int k = 0; k < ring.length; k++) {
ring[k] = h32(NODES.charAt(k / 100) + "#" + k % 100) * 8 + k / 100;
}
Arrays.sort(ring);
return ring;
}
static char byRing(int n, String key) {
// The first point at or after the key, wrapping round.
long[] ring = RINGS[n - 4];
int i = Arrays.binarySearch(ring, h32(key) * 8);
return NODES.charAt((int) (ring[(i >= 0 ? i : -i - 1) % ring.length] % 8));
}
static char byRendezvous(int n, String key) {
char best = NODES.charAt(0);
for (char node : NODES.substring(1, n).toCharArray()) {
if (h32(node + "|" + key) > h32(best + "|" + key)) {
best = node;
}
}
return best;
}
static void report(String name, Place place) {
List<String> moved = KEYS.stream().filter(k -> place.at(4, k) != place.at(5, k)).toList();
String toE = moved.stream().allMatch(k -> place.at(5, k) == 'E') ? "yes" : "no";
System.out.println(name + ": " + moved.size() + " keys move, all to E: " + toE);
}
public static void main(String[] args) {
report("hash mod N", (n, key) -> NODES.charAt((int) (h32(key) % n)));
report("ring", Main::byRing);
report("rendezvous", Main::byRendezvous);
}
} In Java: An int multiplies with 32-bit wraparound, so h32 needs no mask; Integer.toUnsignedLong reads the result as unsigned. Arrays.binarySearch returns minus the insertion point minus one when the value is absent, and the strict > keeps the first of equal scores.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <algorithm>
#include <cstdint>
#include <format>
#include <print>
#include <string>
#include <string_view>
#include <vector>
const std::string kNodes = "ABCDE";
std::uint32_t h32(const std::string &text) {
// FNV-1a, then MurmurHash3's final mix.
std::uint32_t h = 0x811C9DC5u;
for (const unsigned char byte : text) {
h = (h ^ byte) * 0x01000193u;
}
h = (h ^ (h >> 16)) * 0x85EBCA6Bu;
h = (h ^ (h >> 13)) * 0xC2B2AE35u;
return h ^ (h >> 16);
}
// 100 points a node, packed as position * 8 + node number.
std::vector<std::uint64_t> ring(int n) {
std::vector<std::uint64_t> ring;
for (int k = 0; k < n * 100; ++k) {
const int node = k / 100;
const std::string text = std::format("{}#{}", kNodes[node], k % 100);
ring.push_back(std::uint64_t{h32(text)} * 8 + node);
}
std::ranges::sort(ring);
return ring;
}
const std::vector<std::uint64_t> kRings[] = {ring(4), ring(5)};
char byRing(int n, const std::string &key) {
// The first point at or after the key, wrapping round.
const auto &ring = kRings[n - 4];
const auto it = std::ranges::lower_bound(ring, std::uint64_t{h32(key)} * 8);
return kNodes[(it == ring.end() ? ring.front() : *it) % 8];
}
char byRendezvous(int n, const std::string &key) {
return *std::ranges::max_element(
std::string_view(kNodes).substr(0, n), {},
[&](char node) { return h32(std::format("{}|{}", node, key)); });
}
void report(std::string_view name, char (*place)(int, const std::string &)) {
int moved = 0;
bool toE = true;
for (int i = 0; i < 2000; ++i) {
const std::string key = std::format("user:{}", i);
if (place(4, key) != place(5, key)) {
++moved;
toE = toE && place(5, key) == 'E';
}
}
std::println("{}: {} keys move, all to E: {}", name, moved,
toE ? "yes" : "no");
}
int main() {
report("hash mod N",
[](int n, const std::string &key) { return kNodes[h32(key) % n]; });
report("ring", byRing);
report("rendezvous", byRendezvous);
} In C++: std::ranges::lower_bound is the ring lookup, and std::ranges::max_element returns the first of equal highest scores.
Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
using System.Globalization;
using System.Text;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
const string Nodes = "ABCDE";
long[][] rings = [Ring(4), Ring(5)];
(string Name, Func<int, string, char> Place)[] schemes =
[
("hash mod N", (n, key) => Nodes[(int)(H32(key) % (uint)n)]),
("ring", (n, key) => ByRing(rings[n - 4], key)),
("rendezvous", ByRendezvous),
];
var keys = Enumerable.Range(0, 2_000).Select(i => $"user:{i}").ToList();
foreach (var (name, place) in schemes)
{
var moved = keys.Where(key => place(4, key) != place(5, key)).ToList();
string toE = moved.All(key => place(5, key) == 'E') ? "yes" : "no";
Console.WriteLine($"{name}: {moved.Count} keys move, all to E: {toE}");
}
static uint H32(string text)
{
// FNV-1a, then MurmurHash3's final mix.
uint h = 0x811C9DC5;
foreach (byte b in Encoding.UTF8.GetBytes(text))
{
h = (h ^ b) * 0x01000193;
}
h = (h ^ (h >> 16)) * 0x85EBCA6B;
h = (h ^ (h >> 13)) * 0xC2B2AE35;
return h ^ (h >> 16);
}
// 100 points a node, packed as position * 8 + node number.
static long[] Ring(int n)
{
long[] ring = [.. Enumerable.Range(0, n * 100)
.Select(k => H32($"{Nodes[k / 100]}#{k % 100}") * 8L + k / 100)];
Array.Sort(ring);
return ring;
}
static char ByRing(long[] ring, string key)
{
// The first point at or after the key, wrapping round.
int i = Array.BinarySearch(ring, H32(key) * 8L);
return Nodes[(int)(ring[(i >= 0 ? i : ~i) % ring.Length] % 8)];
}
static char ByRendezvous(int n, string key) => Nodes[..n].MaxBy(node => H32($"{node}|{key}")); In C#: uint arithmetic wraps by default, so H32 needs no mask. Array.BinarySearch returns the complement of the insertion point when the value is absent, and MaxBy returns the first of equal top scores.
Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
static const char NODES[] = "ABCDE";
static uint64_t rings[6][5 * 100]; // rings[n]: the ring of the first n nodes
static uint32_t h32(const char *text) {
// FNV-1a, then MurmurHash3's final mix.
uint32_t h = 0x811C9DC5u;
for (; *text != '\0'; text++)
h = (h ^ (unsigned char)*text) * 0x01000193u;
h = (h ^ (h >> 16)) * 0x85EBCA6Bu;
h = (h ^ (h >> 13)) * 0xC2B2AE35u;
return h ^ (h >> 16);
}
static int ascending(const void *a, const void *b) {
const uint64_t x = *(const uint64_t *)a, y = *(const uint64_t *)b;
return (x > y) - (x < y);
}
// 100 points a node, packed as position * 8 + node number.
static void build_ring(int n) {
char text[8];
for (int k = 0; k < n * 100; k++) {
snprintf(text, sizeof text, "%c#%d", NODES[k / 100], k % 100);
rings[n][k] = (uint64_t)h32(text) * 8 + (uint64_t)(k / 100);
}
qsort(rings[n], (size_t)(n * 100), sizeof rings[n][0], ascending);
}
static char by_modulo(int n, const char *key) { return NODES[h32(key) % n]; }
static char by_ring(int n, const char *key) {
// The first point at or after the key, wrapping round.
const uint64_t target = (uint64_t)h32(key) * 8;
int lo = 0, hi = n * 100;
while (lo < hi) {
const int mid = (lo + hi) / 2;
if (rings[n][mid] < target)
lo = mid + 1;
else
hi = mid;
}
return NODES[rings[n][lo % (n * 100)] % 8];
}
static char by_rendezvous(int n, const char *key) {
char best = 0, text[24];
uint32_t top = 0;
for (int i = 0; i < n; i++) {
snprintf(text, sizeof text, "%c|%s", NODES[i], key);
if (best == 0 || h32(text) > top) {
top = h32(text);
best = NODES[i];
}
}
return best;
}
static void report(const char *name, char (*place)(int, const char *)) {
int moved = 0, to_e = 1;
char key[16];
for (int i = 0; i < 2000; i++) {
snprintf(key, sizeof key, "user:%d", i);
const char was = place(4, key), now = place(5, key);
moved += was != now;
to_e = to_e && (was == now || now == 'E');
}
printf("%s: %d keys move, all to E: %s\n", name, moved, to_e ? "yes" : "no");
}
int main(void) {
build_ring(4);
build_ring(5);
report("hash mod N", by_modulo);
report("ring", by_ring);
report("rendezvous", by_rendezvous);
} In C: Ring points are uint64_t values sorted by qsort, and rings[n] holds the ring of the first n nodes. bsearch finds only an equal value, so by_ring writes its own search for the first point at or after the key.
Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main
PHP 8.5 · main.php
<?php
declare(strict_types=1);
const NODES = 'ABCDE';
/** The low 32 bits of $a * $b, in parts that fit PHP's 64-bit integers. */
function mul32(int $a, int $b): int
{
return (((($a * ($b >> 16)) & 0xFFFF) << 16) + $a * ($b & 0xFFFF)) & 0xFFFFFFFF;
}
function h32(string $text): int
{
// FNV-1a, then MurmurHash3's final mix.
$h = 0x811C9DC5;
foreach (unpack('C*', $text) as $byte) {
$h = mul32($h ^ $byte, 0x01000193);
}
$h = mul32($h ^ ($h >> 16), 0x85EBCA6B);
$h = mul32($h ^ ($h >> 13), 0xC2B2AE35);
return $h ^ ($h >> 16);
}
/** 100 points a node, packed as position * 8 + node number. */
function ring(int $n): array
{
$ring = [];
for ($k = 0; $k < $n * 100; $k++) {
$node = intdiv($k, 100);
$ring[] = h32(NODES[$node] . '#' . $k % 100) * 8 + $node;
}
sort($ring);
return $ring;
}
function byRing(int $n, string $key): string
{
// The first point at or after the key, wrapping round.
static $rings = [];
$ring = $rings[$n] ??= ring($n);
$target = h32($key) * 8;
[$lo, $hi] = [0, count($ring)];
while ($lo < $hi) {
$mid = intdiv($lo + $hi, 2);
if ($ring[$mid] < $target) {
$lo = $mid + 1;
} else {
$hi = $mid;
}
}
return NODES[$ring[$lo % count($ring)] % 8];
}
function byRendezvous(int $n, string $key): string
{
$best = NODES[0];
foreach (str_split(substr(NODES, 1, $n - 1)) as $node) {
if (h32("{$node}|{$key}") > h32("{$best}|{$key}")) {
$best = $node;
}
}
return $best;
}
$keys = array_map(fn(int $i): string => "user:{$i}", range(0, 1999));
$schemes = [
'hash mod N' => fn(int $n, string $key): string => NODES[h32($key) % $n],
'ring' => byRing(...),
'rendezvous' => byRendezvous(...),
];
foreach ($schemes as $name => $place) {
$moved = array_filter($keys, fn(string $k): bool => $place(4, $k) !== $place(5, $k));
$toE = array_all($moved, fn(string $k): bool => $place(5, $k) === 'E') ? 'yes' : 'no';
printf("%s: %d keys move, all to E: %s\n", $name, count($moved), $toE);
} In PHP: PHP's integers are signed 64-bit, so mul32 multiplies in two halves to keep the low 32 bits exact; the browser build of PHP has 32-bit integers. PHP has no binary search, so byRing writes one.
Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. To run it yourself: php main.php
Rust 1.99 · main.rs
use std::sync::LazyLock;
const NODES: [char; 5] = ['A', 'B', 'C', 'D', 'E'];
static RINGS: LazyLock<[Vec<u64>; 2]> = LazyLock::new(|| [ring(4), ring(5)]);
fn h32(text: &str) -> u32 {
// FNV-1a, then MurmurHash3's final mix.
let mut h: u32 = 0x811C_9DC5;
for &byte in text.as_bytes() {
h = (h ^ u32::from(byte)).wrapping_mul(0x0100_0193);
}
h = (h ^ (h >> 16)).wrapping_mul(0x85EB_CA6B);
h = (h ^ (h >> 13)).wrapping_mul(0xC2B2_AE35);
h ^ (h >> 16)
}
// 100 points a node, packed as position * 8 + node number.
fn ring(n: usize) -> Vec<u64> {
let mut ring = Vec::new();
for k in 0..n * 100 {
let node = k / 100;
ring.push(u64::from(h32(&format!("{}#{}", NODES[node], k % 100))) * 8 + node as u64);
}
ring.sort_unstable();
ring
}
fn by_ring(n: usize, key: &str) -> char {
// The first point at or after the key, wrapping round.
let ring = &RINGS[n - 4];
let target = u64::from(h32(key)) * 8;
NODES[(ring[ring.partition_point(|&p| p < target) % ring.len()] % 8) as usize]
}
fn by_rendezvous(n: usize, key: &str) -> char {
let mut best = NODES[0];
for &node in &NODES[1..n] {
if h32(&format!("{node}|{key}")) > h32(&format!("{best}|{key}")) {
best = node;
}
}
best
}
fn main() {
let keys: Vec<String> = (0..2_000).map(|i| format!("user:{i}")).collect();
let schemes: [(&str, fn(usize, &str) -> char); 3] = [
("hash mod N", |n, key| NODES[h32(key) as usize % n]),
("ring", by_ring),
("rendezvous", by_rendezvous),
];
for (name, place) in schemes {
let moved: Vec<&String> = keys.iter().filter(|k| place(4, k) != place(5, k)).collect();
let all_to_e = moved.iter().all(|k| place(5, k) == 'E');
let to_e = if all_to_e { "yes" } else { "no" };
println!("{name}: {} keys move, all to E: {to_e}", moved.len());
}
} In Rust: wrapping_mul makes the 32-bit wraparound explicit, partition_point is the binary search for the first point at or after the key, and LazyLock builds the two rings on first use. The strict > keeps the first of equal scores.
Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
type Place = (n: number, key: string) => string;
const NODES = "ABCDE";
function h32(text: string): number {
// FNV-1a, then MurmurHash3's final mix.
let h = 0x811c9dc5;
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193);
h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
return (h ^ (h >>> 16)) >>> 0;
}
// 100 points a node, packed as position * 8 + node number.
const ring = (n: number): number[] =>
Array.from({ length: n * 100 }, (_, k) => {
const node = Math.floor(k / 100);
return h32(`${NODES[node]}#${k % 100}`) * 8 + node;
}).sort((a, b) => a - b);
const RINGS: Record<number, number[]> = { 4: ring(4), 5: ring(5) };
const byRing: Place = (n, key) => {
// The first point at or after the key, wrapping round.
const ring = RINGS[n];
const target = h32(key) * 8;
let lo = 0;
let hi = ring.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (ring[mid] < target) lo = mid + 1;
else hi = mid;
}
return NODES[ring[lo % ring.length] % 8];
};
const byRendezvous: Place = (n, key) => {
const score = (node: string) => h32(`${node}|${key}`);
return [...NODES.slice(0, n)].reduce((best, node) => (score(node) > score(best) ? node : best));
};
const keys = Array.from({ length: 2000 }, (_, i) => `user:${i}`);
const schemes: Record<string, Place> = {
"hash mod N": (n, key) => NODES[h32(key) % n],
ring: byRing,
rendezvous: byRendezvous,
};
for (const [name, place] of Object.entries(schemes)) {
const moved = keys.filter((key) => place(4, key) !== place(5, key));
const toE = moved.every((key) => place(5, key) === "E") ? "yes" : "no";
console.log(`${name}: ${moved.length} keys move, all to E: ${toE}`);
} In TypeScript: The keys are ASCII, so each charCodeAt is the byte that the other languages hash. The standard library has no binary search, so byRing writes its own; reduce keeps the first of equal top scores.
Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import (
"cmp"
"fmt"
"slices"
)
const nodes = "ABCDE"
var rings = map[int][]uint64{4: ring(4), 5: ring(5)}
func h32(text string) uint32 {
// FNV-1a, then MurmurHash3's final mix.
h := uint32(0x811C9DC5)
for i := 0; i < len(text); i++ {
h = (h ^ uint32(text[i])) * 0x01000193
}
h = (h ^ h>>16) * 0x85EBCA6B
h = (h ^ h>>13) * 0xC2B2AE35
return h ^ h>>16
}
// 100 points a node, packed as position * 8 + node number.
func ring(n int) []uint64 {
var ring []uint64
for k := range n * 100 {
text := fmt.Sprintf("%c#%d", nodes[k/100], k%100)
ring = append(ring, uint64(h32(text))*8+uint64(k/100))
}
slices.Sort(ring)
return ring
}
func byRing(n int, key string) byte {
// The first point at or after the key, wrapping round.
ring := rings[n]
i, _ := slices.BinarySearch(ring, uint64(h32(key))*8)
return nodes[ring[i%len(ring)]%8]
}
func byRendezvous(n int, key string) byte {
return slices.MaxFunc([]byte(nodes[:n]), func(a, b byte) int {
return cmp.Compare(h32(fmt.Sprintf("%c|%s", a, key)), h32(fmt.Sprintf("%c|%s", b, key)))
})
}
func main() {
const keys = 2000
byModulo := func(n int, key string) byte { return nodes[h32(key)%uint32(n)] }
names := []string{"hash mod N", "ring", "rendezvous"}
for s, place := range []func(int, string) byte{byModulo, byRing, byRendezvous} {
moved, toE := 0, "yes"
for i := range keys {
key := fmt.Sprintf("user:%d", i)
if now := place(5, key); place(4, key) != now {
moved++
if now != 'E' {
toE = "no"
}
}
}
fmt.Printf("%s: %d keys move, all to E: %s\n", names[s], moved, toE)
}
} In Go: uint32 arithmetic wraps, slices.BinarySearch returns the index of the first point at or after the key, and slices.MaxFunc returns the first of equal top scores.
Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
let nodes = Array("ABCDE")
func h32(_ text: String) -> UInt32 {
// FNV-1a, then MurmurHash3's final mix.
var h: UInt32 = 0x811C_9DC5
for byte in text.utf8 {
h = (h ^ UInt32(byte)) &* 0x0100_0193
}
h = (h ^ (h >> 16)) &* 0x85EB_CA6B
h = (h ^ (h >> 13)) &* 0xC2B2_AE35
return h ^ (h >> 16)
}
// 100 points a node, packed as position * 8 + node number.
func ring(_ n: Int) -> [UInt64] {
let ring = (0..<n * 100).map { k in
UInt64(h32("\(nodes[k / 100])#\(k % 100)")) * 8 + UInt64(k / 100)
}
return ring.sorted()
}
let rings = [ring(4), ring(5)]
func byRing(_ n: Int, _ key: String) -> Character {
// The first point at or after the key, wrapping round.
let ring = rings[n - 4]
let target = UInt64(h32(key)) * 8
var (lo, hi) = (0, ring.count)
while lo < hi {
let mid = (lo + hi) / 2
if ring[mid] < target { lo = mid + 1 } else { hi = mid }
}
return nodes[Int(ring[lo % ring.count] % 8)]
}
func byRendezvous(_ n: Int, _ key: String) -> Character {
nodes[..<n].max { h32("\($0)|\(key)") < h32("\($1)|\(key)") } ?? nodes[0]
}
let keys = (0..<2_000).map { "user:\($0)" }
let schemes: [(String, (Int, String) -> Character)] = [
("hash mod N", { n, key in nodes[Int(h32(key) % UInt32(n))] }),
("ring", byRing),
("rendezvous", byRendezvous),
]
for (name, place) in schemes {
let moved = keys.filter { place(4, $0) != place(5, $0) }
let toE = moved.allSatisfy { place(5, $0) == "E" } ? "yes" : "no"
print("\(name): \(moved.count) keys move, all to E: \(toE)")
} In Swift: &* is the multiplication that wraps around; plain * would stop the program on overflow. The standard library has no binary search, so byRing writes one, and max(by:) keeps the first of equal scores.
Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
NODES = %w[A B C D E].freeze
def h32(text)
# FNV-1a, then MurmurHash3's final mix.
h = text.each_byte.reduce(0x811C9DC5) { |acc, byte| ((acc ^ byte) * 0x01000193) & 0xFFFFFFFF }
h = ((h ^ (h >> 16)) * 0x85EBCA6B) & 0xFFFFFFFF
h = ((h ^ (h >> 13)) * 0xC2B2AE35) & 0xFFFFFFFF
h ^ (h >> 16)
end
# 100 points a node, packed as position * 8 + node number.
def ring(count)
points = Array.new(count * 100) do |k|
(h32("#{NODES[k / 100]}##{k % 100}") * 8) + (k / 100)
end
points.sort
end
RINGS = { 4 => ring(4), 5 => ring(5) }.freeze
def by_ring(count, key)
# The first point at or after the key, wrapping round.
ring = RINGS.fetch(count)
target = h32(key) * 8
NODES[ring[ring.bsearch_index { |point| point >= target } || 0] % 8]
end
def by_rendezvous(count, key)
NODES.first(count).max_by { |node| h32("#{node}|#{key}") }
end
keys = Array.new(2_000) { |i| "user:#{i}" }
schemes = {
'hash mod N' => ->(count, key) { NODES[h32(key) % count] },
'ring' => method(:by_ring),
'rendezvous' => method(:by_rendezvous)
}
schemes.each do |name, place|
moved = keys.reject { |key| place.call(4, key) == place.call(5, key) }
to_e = moved.all? { |key| place.call(5, key) == 'E' } ? 'yes' : 'no'
puts "#{name}: #{moved.size} keys move, all to E: #{to_e}"
end In Ruby: Integers never overflow, so each step masks with 0xFFFFFFFF; bsearch_index finds the first point at or after the key, and max_by keeps the first of equal highest scores.
Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
const val NODES = "ABCDE"
fun h32(text: String): UInt {
// FNV-1a, then MurmurHash3's final mix.
var h = 0x811C9DC5u
for (byte in text.encodeToByteArray()) h = (h xor byte.toUByte().toUInt()) * 0x01000193u
h = (h xor (h shr 16)) * 0x85EBCA6Bu
h = (h xor (h shr 13)) * 0xC2B2AE35u
return h xor (h shr 16)
}
// 100 points a node, packed as position * 8 + node number.
fun ring(n: Int): List<ULong> =
List(n * 100) { k ->
h32("${NODES[k / 100]}#${k % 100}").toULong() * 8u + (k / 100).toULong()
}.sorted()
val rings = mapOf(4 to ring(4), 5 to ring(5))
fun byRing(
n: Int,
key: String,
): Char {
// The first point at or after the key, wrapping round.
val ring = rings.getValue(n)
val found = ring.binarySearch(h32(key).toULong() * 8u)
return NODES[(ring[(if (found >= 0) found else -found - 1) % ring.size] % 8u).toInt()]
}
fun byRendezvous(
n: Int,
key: String,
): Char = NODES.take(n).maxBy { h32("$it|$key") }
fun main() {
val keys = (0 until 2_000).map { "user:$it" }
val schemes: List<Pair<String, (Int, String) -> Char>> =
listOf(
"hash mod N" to { n, key -> NODES[(h32(key) % n.toUInt()).toInt()] },
"ring" to ::byRing,
"rendezvous" to ::byRendezvous,
)
for ((name, place) in schemes) {
val moved = keys.filter { place(4, it) != place(5, it) }
val toE = if (moved.all { place(5, it) == 'E' }) "yes" else "no"
println("$name: ${moved.size} keys move, all to E: $toE")
}
} In Kotlin: UInt arithmetic wraps around, binarySearch searches the sorted list of ring points, and maxBy returns the first character with the largest score.
Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
import 'dart:convert';
const nodes = 'ABCDE';
int h32(String text) {
// FNV-1a, then MurmurHash3's final mix.
var h = 0x811C9DC5;
for (final byte in utf8.encode(text)) {
h = ((h ^ byte) * 0x01000193) & 0xFFFFFFFF;
}
h = ((h ^ (h >> 16)) * 0x85EBCA6B) & 0xFFFFFFFF;
h = ((h ^ (h >> 13)) * 0xC2B2AE35) & 0xFFFFFFFF;
return h ^ (h >> 16);
}
// 100 points a node, packed as position * 8 + node number.
List<int> ring(int n) => [
for (var k = 0; k < n * 100; k++)
h32('${nodes[k ~/ 100]}#${k % 100}') * 8 + k ~/ 100,
]..sort();
final rings = {4: ring(4), 5: ring(5)};
String byRing(int n, String key) {
// The first point at or after the key, wrapping round.
final ring = rings[n]!;
final target = h32(key) * 8;
var (lo, hi) = (0, ring.length);
while (lo < hi) {
final mid = (lo + hi) ~/ 2;
if (ring[mid] < target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return nodes[ring[lo % ring.length] % 8];
}
String byRendezvous(int n, String key) {
int score(String node) => h32('$node|$key');
return nodes
.substring(0, n)
.split('')
.reduce((best, node) => score(node) > score(best) ? node : best);
}
void main() {
final keys = [for (var i = 0; i < 2000; i++) 'user:$i'];
final schemes = {
'hash mod N': (int n, String key) => nodes[h32(key) % n],
'ring': byRing,
'rendezvous': byRendezvous,
};
for (final MapEntry(key: name, value: place) in schemes.entries) {
final moved = keys.where((key) => place(4, key) != place(5, key)).toList();
final toE = moved.every((key) => place(5, key) == 'E') ? 'yes' : 'no';
print('$name: ${moved.length} keys move, all to E: $toE');
}
} In Dart: Each step masks with 0xFFFFFFFF, because Dart's int has 64 bits. dart:core has no binary search, so byRing writes one; reduce keeps the first of equal top scores.
Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
hash mod N: 1605 keys move, all to E: no ring: 506 keys move, all to E: yes rendezvous: 414 keys move, all to E: yes
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 16 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 154 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 4 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 10 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a WebAssembly build of this program (about 23 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 724 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 197 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 21 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
Only hash mod N moved keys between old nodes. Two more schemes come up often. Jump consistent hash turns a key into one of n numbered buckets with a little arithmetic and no stored state. Fixed hash slots cut the hash space into a fixed number of slots, 16,384 in Redis Cluster, and a table records which node owns each slot. Moving data then means moving whole slots.
Checkpoint
Check: The schemes compared
Practise: build the ring lookup
A ring is a sorted list of points and a binary search: find the first point at or after the key’s position, and go round to the start when you pass the end. Build one and run the sample tests:
Exercise · Medium · Python, JavaScript
Build a hash ring and find a key's owner
Finish a consistent-hash ring in ring.py (or ring.mjs). The starter code gives you ring_position(text) (ringPosition in JavaScript): the place on a ring of 2^32 positions where a text lands, a number from 0 to 4,294,967,295.
build_ring(nodes, points_per_node)returns the ring as a list of(position, node)pairs (in JavaScript,[position, node]arrays), one for every point of every node, sorted by position. Pointiof nodensits atring_position(f"{n}#{i}"), so node"A"with 3 points sits at the positions of"A#0","A#1"and"A#2".owner_at(ring, position)returns the node of the first point at or afterposition. Past the last point, the ring goes round to the first one. An empty ring has no owner: returnNone(null).
owner(ring, key) is already written: it looks up ring_position(key). For example, on the ring [(100, "A"), (200, "B"), (300, "C")], position 150 belongs to "B", position 200 to "B" and position 301 to "A".
The sample tests also look up 20,000 positions on a ring of 200,000 points, so owner_at must use binary search rather than walk the list.
Python · Starter code · ring.py
def ring_position(text):
"""Where a text lands on a ring of 2^32 positions: FNV-1a, then MurmurHash3's final mix."""
h = 0x811C9DC5
for ch in text:
h = ((h ^ ord(ch)) * 0x01000193) & 0xFFFFFFFF
h ^= h >> 16
h = (h * 0x85EBCA6B) & 0xFFFFFFFF
h ^= h >> 13
h = (h * 0xC2B2AE35) & 0xFFFFFFFF
return h ^ (h >> 16)
def build_ring(nodes, points_per_node):
"""Every point of every node as a (position, node) pair, sorted by position."""
# Replace this line with your code.
return []
def owner_at(ring, position):
"""The node of the first point at or after `position`, going round past the last point; None on an empty ring."""
# Replace this line with your code.
return None
def owner(ring, key):
"""The node that owns `key`."""
return owner_at(ring, ring_position(key)) The sample tests · test_ring.py
from ring import build_ring, owner, owner_at, ring_position
SMALL = [(100, "A"), (200, "B"), (300, "C")]
def test_build_ring():
"""places every point of every node and sorts the points by position"""
ring = build_ring(["A", "B"], 3)
assert len(ring) == 6
assert ring == sorted(ring)
assert sorted(ring) == sorted([(ring_position(f"{n}#{i}"), n) for n in "AB" for i in range(3)])
def test_owner_at():
"""takes the first point at or after the position, and goes round past the last one"""
assert [owner_at(SMALL, p) for p in [0, 99, 100, 101, 200, 299, 300]] == ["A", "A", "A", "B", "B", "C", "C"]
assert owner_at(SMALL, 301) == "A"
assert owner_at(SMALL, 2**32 - 1) == "A"
def test_edge_rings():
"""a one-point ring owns everything; an empty ring owns nothing"""
assert owner_at([(5, "solo")], 4_000_000_000) == "solo"
assert owner_at([], 7) is None
def test_join_moves_keys_only_to_the_new_node():
"""when node D joins, every key that changes owner moves to D"""
before = build_ring(["A", "B", "C"], 50)
after = build_ring(["A", "B", "C", "D"], 50)
moved = [k for k in (f"user:{i}" for i in range(3_000)) if owner(before, k) != owner(after, k)]
assert moved, "some keys should move to D"
assert all(owner(after, k) == "D" for k in moved)
def test_lookups_use_binary_search():
"""looks up 20,000 positions on a ring of 200,000 points in time, and goes round past the last point"""
step = 21_474
ring = [(i * step, f"n{i % 100}") for i in range(200_000)]
x, got, want = 12_345, [], []
for _ in range(20_000):
x = (x * 1_103_515_245 + 12_345) % 2**32
got.append(owner_at(ring, x))
first = -(-x // step) # the first point at or after x
want.append(f"n{first % 100}" if first < len(ring) else "n0")
assert got == want
assert owner_at(ring, 2**32 - 1) == "n0" JavaScript · Starter code · ring.mjs
/** Where a text lands on a ring of 2^32 positions: FNV-1a, then MurmurHash3's final mix. */
export function ringPosition(text) {
let h = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
h ^= h >>> 16;
h = Math.imul(h, 0x85ebca6b);
h ^= h >>> 13;
h = Math.imul(h, 0xc2b2ae35);
h ^= h >>> 16;
return h >>> 0;
}
/** Every point of every node as a [position, node] pair, sorted by position. */
export function buildRing(nodes, pointsPerNode) {
// Replace this line with your code.
return [];
}
/** The node of the first point at or after `position`, going round past the last point; null on an empty ring. */
export function ownerAt(ring, position) {
// Replace this line with your code.
return null;
}
/** The node that owns `key`. */
export function owner(ring, key) {
return ownerAt(ring, ringPosition(key));
} The sample tests · ring.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { buildRing, owner, ownerAt, ringPosition } from './ring.mjs';
const SMALL = [[100, 'A'], [200, 'B'], [300, 'C']];
test('places every point of every node and sorts the points by position', () => {
const ring = buildRing(['A', 'B'], 3);
assert.equal(ring.length, 6);
for (let i = 1; i < ring.length; i++) assert.ok(ring[i - 1][0] <= ring[i][0], `point ${i} is out of order`);
const want = ['A', 'B'].flatMap((n) => [0, 1, 2].map((i) => [ringPosition(`${n}#${i}`), n])).sort((a, b) => a[0] - b[0]);
assert.deepEqual(ring, want);
});
test('takes the first point at or after the position, and goes round past the last one', () => {
assert.deepEqual([0, 99, 100, 101, 200, 299, 300].map((p) => ownerAt(SMALL, p)), ['A', 'A', 'A', 'B', 'B', 'C', 'C']);
assert.equal(ownerAt(SMALL, 301), 'A');
assert.equal(ownerAt(SMALL, 2 ** 32 - 1), 'A');
});
test('a one-point ring owns everything; an empty ring owns nothing', () => {
assert.equal(ownerAt([[5, 'solo']], 4000000000), 'solo');
assert.equal(ownerAt([], 7), null);
});
test('when node D joins, every key that changes owner moves to D', () => {
const before = buildRing(['A', 'B', 'C'], 50);
const after = buildRing(['A', 'B', 'C', 'D'], 50);
const moved = Array.from({ length: 3000 }, (_, i) => `user:${i}`).filter((k) => owner(before, k) !== owner(after, k));
assert.ok(moved.length > 0, 'some keys should move to D');
for (const k of moved) assert.equal(owner(after, k), 'D', k);
});
test('looks up 20,000 positions on a ring of 200,000 points in time, and goes round past the last point', () => {
const step = 21474;
const ring = Array.from({ length: 200000 }, (_, i) => [i * step, `n${i % 100}`]);
let x = 12345;
for (let k = 0; k < 20000; k++) {
x = Number((BigInt(x) * 1103515245n + 12345n) % 4294967296n);
const first = Math.ceil(x / step);
assert.equal(ownerAt(ring, x), first < ring.length ? `n${first % 100}` : 'n0', `position ${x}`);
}
assert.equal(ownerAt(ring, 2 ** 32 - 1), 'n0');
}); A hint
In Python, bisect.bisect_left(ring, (position,)) finds the index of the first pair whose position is at or after position, because a one-element tuple sorts before every longer tuple that starts with the same number. If the index equals len(ring), wrap round to index 0. In JavaScript, sort with (a, b) => a[0] - b[0]: the default sort compares numbers as text, and keep lo and hi bounds for the search.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
The questions below cover the four ideas above, the one you know least first.
Practice that adapts to you
Practise this lesson
12 questions about 11 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Virtual nodes, weights and replicas on a ring, measured Pro: opens with a Pro, Premium or Ultimate pass
A ring with few points per node is uneven, it hands a failed node’s keys to one neighbour, and a careless replica rule puts two copies of a key on one machine. This part measures each problem and the fix that production systems use.
In this part:
- How many virtual nodes even out the load, measured
- Where a failed node’s keys go: the failure drill
- Replicas on distinct machines: the preference list
- Weights for machines of different sizes
- Place replicas on distinct nodes, with weights
- Results of the sample tests
1 diagram · 2 runnable examples (JavaScript, Python) · 1 exercise with sample tests · about 8 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Rendezvous, jump hash and hash slots in depth, with numbers Pro: opens with a Pro, Premium or Ultimate pass
Each of the other schemes removes one cost of the ring and adds another. This part runs all three, proves why weighted rendezvous hashing hands each node its weight’s share of the keys, and checks a slot function against the Redis documentation.
In this part:
- Weighted rendezvous hashing, and why its shares follow the weights
- Jump consistent hash: no memory, numbered buckets only
- Fixed hash slots: how Redis Cluster moves data
3 runnable examples (JavaScript, Python) · about 10 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Choosing a placement scheme for a design, with numbers Pro: opens with a Pro, Premium or Ultimate pass
Two facts narrow the five schemes quickly: how many nodes there are, and whether any node may disappear at any moment. This part sets the measured costs side by side, works four designs, and sizes the miss storm that a resize causes.
In this part:
- The costs side by side, for 200 nodes
- Four designs, four choices
- Adding a node without a miss storm, worked
1 diagram · 1 runnable example (Python) · about 11 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
Why does hash(key) mod N move most keys when you add a node, and how many does consistent hashing move instead?
With modulo hashing, a key keeps its node only if its hash leaves the same remainder for the old and the new node count. Going from N nodes to N + 1, that holds for 1 hash value in N + 1, so about N ÷ (N + 1) of the keys move: 80 % from 4 nodes to 5 and 90 % from 9 to 10, most of them between old nodes. A consistent-hash ring or rendezvous hashing moves only the keys that the new node takes over, about 1 ÷ (N + 1) of them, and every moved key goes to the new node. In a cache, that is the difference between losing most of the cache and losing a tenth of it.
-
Mid to senior
A 12-node cache routes keys with hash(key) mod 12. Before adding 4 nodes, you must move it to consistent hashing. It serves 60,000 hits a second, and the database behind it can absorb only 6,000 extra misses a second. How do you switch schemes, and add the nodes, without overloading the database?
-
Mid to senior
A consistent-hash ring of 6 cache nodes, with one point per node, shows one node holding 38 % of the keys and the worst p99 latency. Why, how would you even out the load, and what does your fix cost?
-
Mid to senior
A key-value store keeps 3 replicas of each key on the next 3 points of a ring with 8 virtual nodes per machine. After one machine dies, writes to some keys cannot reach a quorum of 2. Why, and what do you change?
Key takeaways
- With
hash(key) mod N, a node joining moves almost every key: 80 % from 4 nodes to 5. - A ring gives each key to the next node point clockwise, so only the new node’s stretches move, and virtual nodes even them out.
- Rendezvous hashing gives each key to its highest-scoring node: no ring to keep, and one score per node per lookup.
- Jump consistent hash and fixed hash slots are the other two schemes to know: no state for numbered buckets, and a table of slots that move whole.
References
- Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web (ACM STOC (Karger and others; course copy, Princeton University))
- Using name-based mappings to increase hit rates (IEEE/ACM Transactions on Networking (Thaler and Ravishankar))
- A Fast, Minimal Memory, Consistent Hash Algorithm (arXiv (Lamping and Veach))
- Dynamo: Amazon's Highly Available Key-value Store (ACM SOSP (authors' copy))
- Redis cluster specification (Redis)
- CLUSTER KEYSLOT (Redis documentation) (Redis)
- Sharding pattern (Azure Architecture Center) (Microsoft)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress