Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 30 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free core, Pro depth: 9 of 30 minutes free
Free: Hash mod N moves almost every key when a node joins · The ring: a key belongs to the next node clockwise · Rendezvous hashing: the highest score wins · 2 more sections. With a pass: 3 in-depth parts and 3 interview answers, about 21 minutes.

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

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:

What moves when a fifth node joins Python · core/remap.py
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

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

  1. Question 1 of 2 Keys are placed by hash(key) mod N. Of the 20 hash values 0 to 19, how many keep their node when N grows from 4 to 5?

    Type a number.

    A hint

    A value keeps its node when its remainder is the same for 4 and for 5.

    Show the answer: Question 1 of 2

    Answer: 4 hash values

    The remainders agree only for 0, 1, 2 and 3, where the value is its own remainder for both counts. The other 16 values change node, so 80 % of the keys move, as the run measured with 79.9 %.

  2. Question 2 of 2 In the run, hash mod N moved 79.9 % of the keys when E joined. Where did most of them go?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Between the old nodes A to D, which gained nothing from the change

    A remainder that changes points to any node, old or new. The run measured 59.9 % of the keys moving between old nodes; in a cache each of them is a miss, though the new node took only a fifth of the work.

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:

A hash ring with nodes A to D; node E joins between B and C, and only the keys on the arc from B to E move, all of them to E.A key belongs to the next point clockwisepositions 0 to 2^32 − 1, wrapping round at the topclockwiseABCDEk1k2k3k4E joins: only keys between B and E move, all to Ek2 moves from C to E; k1, k3 and k4 keep their node

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:

  1. k1, between A and B, belongs to B.
  2. 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.
  3. k3, between E and C, still belongs to C.
  4. 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.

Interactive model Consistent hashing: servers and keys on one ring

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

  1. Question 1 of 2 On a ring with one point per node, the clockwise order is A, B, C, D. Node B leaves. Which keys change owner?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: B's keys only, the stretch from A up to B, and they all go to C

    A node that leaves hands its stretch to the next node clockwise, and every other key keeps its owner. With a single point per node that neighbour takes the whole stretch.

  2. Question 2 of 2 In the run, a ring with one point per node left its busiest node with 47.1 % of the keys. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Four or five random points cut the circle into stretches of very different lengths

    With one point per node the stretches have random lengths. Giving each node many points, its virtual nodes, makes each node's share the sum of many short stretches, which evens out.

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:

Interactive · step by step Rendezvous hashing scores one key on every node Step through the four scores of the key user:2, the fifth node joining, a key that stays and a node that leaves.
Rendezvous hashing scores one key on every node, step 8 of 8. Showing The key (user:4), Node A (score 3,057,577,012), Node B (score 1,293,935,854), Node C (score 444,320,313), Node D (score 1,549,533,969), Node E (score 1,304,290,658). In focus: Node D.The keyuser:4Node Ascore 3,057,577,012Node Bscore 1,293,935,854Node Cscore 444,320,313Node Dscore 1,549,533,969Node Escore 1,304,290,658second choice

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. A wins. A's score is the highest of the four, so A stores user:2.
  5. 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.
  6. 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.
  7. 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.
  8. 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

  1. Question 1 of 2 For the key user:2, node A scores 4,003,376,559, the highest of A to D. Node E joins and scores 4,190,020,205 for the same key. Where does user:2 go?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: To E, because E now has the highest score for it

    A new node wins exactly the keys for which it scores highest. user:2 is one of them, so it moves straight to E, and no key moves between old nodes.

  2. Question 2 of 2 For the key user:4 the scores are A 3,057,577,012, D 1,549,533,969, E 1,304,290,658, B 1,293,935,854 and C 444,320,313. Node A leaves. Where does user:4 go?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: To D, the node with the second-highest score

    When a node leaves, only its own keys move, each to the node that scored second for it. For user:4 that is D.

The rules differ in what a lookup costs and what a resize moves

Three ways to give a key to a node
Criterion Hash mod NConsistent hashingRendezvous hashing
Lookup One divisionA binary search of the sorted pointsOne score per node; the top score wins
Client state Each client keeps the node countEach client keeps every pointEach client keeps the node list
Node joins Most keys moveOnly the keys of its new stretch moveOnly the keys it now wins move
Node leaves Most keys moveIts stretch goes to the next nodeIts keys go to their second choice
Node sizes All nodes count the sameA bigger node gets more pointsA bigger node gets a higher weight
When to choose A node count that never changesMany nodes that come and goTens 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:

One program · 14 languages Three placement rules, before and after a fifth node joins

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

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

  1. Question 1 of 2 What does each client keep in order to find a key's node under rendezvous hashing?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The list of nodes

    Hash mod N keeps a count, a ring keeps every point, and rendezvous hashing keeps the node list and scores every node on each lookup.

  2. Question 2 of 2 In the three-rule program, 1,605 of 2,000 keys moved under hash mod N, 506 under the ring and 414 under rendezvous hashing. What did the yes or no at the end of each line report?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Whether every key that moved went to the new node E

    Only hash mod N moved keys between old nodes. The ring and rendezvous hashing sent every moved key to E, which is the minimal movement both promise.

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.

  1. 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. Point i of node n sits at ring_position(f"{n}#{i}"), so node "A" with 3 points sits at the positions of "A#0", "A#1" and "A#2".
  2. owner_at(ring, position) returns the node of the first point at or after position. Past the last point, the ring goes round to the first one. An empty ring has no owner: return None (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.

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.

  1. Question 1 of 12 A cache cluster routes keys with hash(key) mod N and grows from 9 nodes to 10. About what percentage of the keys change node?

    Type a number.

    Show the answer: Question 1 of 12

    Answer: 90 % (anything from 89 to 91 counts)

    A key stays only when its hash leaves the same remainder divided by 9 and by 10. In every run of 90 hash values that is true for 9 of them, the ones whose remainder is the hash itself, so 10 % stay and 90 % move. A ring or rendezvous hashing would move about a tenth, all of it to the new node.

  2. Question 2 of 12 Why does hash(key) mod N move keys when N changes?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: A key's node is the remainder of its hash, and the remainder usually changes when the divisor changes

    The answer of hash mod N depends on N. From 4 nodes to 5, only 4 hash values in every 20 keep their remainder, so 80 % of the keys move.

  3. Question 3 of 12 A cache cluster on hash mod N grows from 4 nodes to 5 at peak, and the database behind it slows down at once. Why?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: About 80 % of the keys now live on another node, where they are not cached yet, so most reads miss at once

    In a cache, a moved key is a miss. With modulo hashing a resize moves most keys, so most reads miss together; a ring or rendezvous hashing would move about a fifth.

  4. Question 4 of 12 On a ring with one point per node, node E joins between nodes B and C (clockwise, B comes first, then E, then C). Which keys change owner?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: The keys between B and E, which move from C to E

    A key belongs to the first node point clockwise from it. Keys between B and E now meet E before C, so they move from C to E. Keys between E and C still meet C first, and every other key keeps its owner.

  5. Question 5 of 12 Why do real rings give each node many points, its virtual nodes?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: One point per node leaves stretches of very different lengths; many points even out each node's share

    With one point per node the stretches are uneven, so real rings give each node many points, and a larger machine gets more of them.

  6. Question 6 of 12 Two of five ring nodes have twice the memory of the others. How do you give them a fitting share of the keys?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: Give each of them twice as many points on the ring

    A larger machine gets more virtual nodes. Its share of the ring, and so of the keys, grows with its points.

  7. Question 7 of 12 A client places keys on 50 cache servers with rendezvous hashing. What does one lookup compute?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: A score for each of the 50 servers, keeping the highest

    Rendezvous hashing keeps no ring: every server is scored with a hash of the pair (server, key), and the highest score wins. That is 50 hashes per lookup, cheap at this size, and the reason the scheme is avoided for many thousands of nodes.

  8. Question 8 of 12 Under rendezvous hashing, one of eight nodes leaves. Which keys move, and where?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: Only that node's keys, each to the node that scored second for it

    When a node leaves, only its own keys move, each to its second choice, and those choices are spread over the remaining nodes.

  9. Question 9 of 12 A router must place each request among 20,000 storage nodes, many thousands of times a second. Why is plain rendezvous hashing a poor fit?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: Every lookup scores every node, 20,000 hashes a request

    Rendezvous hashing trades state for computation: no ring, but one score per node on every lookup, which is what rules it out at many thousands of nodes.

  10. Question 10 of 12 A set of 12 partitions is fixed for the life of the system and never resized. Which rule is good enough, and why?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: Hash mod N, because its only weakness, moving most keys when N changes, never comes up

    The schemes differ in what a resize moves. Without resizes, the simplest rule, one division, does the job.

  11. Question 11 of 12 What does jump consistent hash keep in order to map a key to a bucket?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: Nothing beyond the number of buckets; a few steps of arithmetic give the bucket

    Jump consistent hash turns a key into one of n numbered buckets with a few steps of arithmetic and no stored state.

  12. Question 12 of 12 In Redis Cluster, how is data moved when a node is added?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Whole slots move to the new node, and a table records which node owns each slot

    Fixed hash slots cut the hash space into a fixed number of slots, 16,384 in Redis Cluster, with a table of owners, so a resize moves whole slots.

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.

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).

Read a complete sample first: Design a unique ID generator

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.

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.

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

  1. 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.

  2. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

  4. 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?

    Model answer, Pro. First step and follow-ups

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.