Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Probabilistic & System-Scale Structures  ›  Lesson

Consistent Hashing

Consistent Hashing 26 minPlace nodes and keys on the same hash ring; a key belongs to the first node clockwise, so adding a node only steals one arc
You're building a piece ofQuarry — the storage engine
This piece — keys_moved_on_add(): Adds a storage node while moving as little data as possible.
Scenario Quarry shards its data across storage nodes and needs adding a node — a routine, frequent operation — to reshuffle as little data as possible.
Your task
Build keys_moved_on_add(keys, nodes, new_node, seed). Return how many of keys change which node they're assigned to (via assign_keys, a ring-based assignment) when new_node joins the ring. Example: keys_moved_on_add(['k1','k2','k3','k4','k5','k6','k7','k8'], ['A','B'], 'C', seed=1) -> 1.

Consistent Hashing

In short: plain sharding with hash(key) % num_nodes reassigns almost every key the moment num_nodes changes, because the modulus itself changed. Consistent hashing places both nodes and keys on the same hash ring (a fixed range like 0..359, wrapping around), and assigns each key to the first node it meets going clockwise. Adding a node only steals the arc of keys between it and the previous node clockwise — every other key's nearest node never changes. With few nodes, one node's arc can still end up much bigger than another's by chance; virtual nodes (each real node placed at many positions on the ring) fix that by averaging away the luck.

A hash ring with three nodes A, B, C in clockwise order B, D, C, A; adding node D only steals the shaded arc of keys between B and D — those keys used to belong to C, the next node clockwise, and A and B's keys never move

step through it
1import hashlib
2 
3def ring_position(item, seed, ring_size=24): # a small ring for a readable trace
4 h = int(hashlib.md5(f"{seed}:{item}".encode()).hexdigest(), 16)
5 return h % ring_size
6 
7def assign(keys, nodes, seed, ring_size=24):
8 positions = sorted((ring_position(n, seed, ring_size), n) for n in nodes)
9 for k in keys:
10 kp = ring_position(k, seed, ring_size)
11 owner = next((n for pos, n in positions if pos >= kp), positions[0][1])
12 print(f"key {k} (ring pos {kp}) -> {owner}")
13 
14nodes = ["A", "B", "C"]
15print("-- before adding D --")
16assign(["k1", "k3", "k4", "k6"], nodes, seed=3)
17print("-- after adding D --")
18assign(["k1", "k3", "k4", "k6"], nodes + ["D"], seed=3)

Only k6 moves — from C to the new D, because D's ring position (16) sits between B (12) and C (19), right where k6 (15) already was. k1, k3 and k4 land on positions D can't reach, so their owners don't change at all.

Add a node without reshuffling the whole cluster

Quarry compares how many of 8 keys actually move when a third node joins, under consistent hashing versus plain modulo sharding:

python

Consistent hashing moves 1 key; modulo sharding moves 7 of the same 8 — because hash(key) % 2 and hash(key) % 3 agree on almost nothing, even though only one node was added.

Flatten node load with virtual replicas

With only one ring position per node, luck decides how big each node's arc is — a small cluster can end up badly unbalanced. Quarry places each real node at many ring positions instead, and the imbalance shrinks:

python

One position per node gives A 53 keys and C only 14 — a 39-key spread out of 100. Twenty positions per node brings that down to a 20-key spread: more independent coin-flips per node average out the luck, without moving a single key off the ring's basic rule.

A common mistake: counting ring positions when you mean to count nodes. A#0 through A#19 are twenty entries on the ring, but they are still one node — A — for every purpose except "which arc does this key land in":

What does this print?
nodes = ["A", "B", "C"]
replicas = 20
ring_entries = len(nodes) * replicas
print(ring_entries == len(nodes))

Quick practice

What does this print?
positions = [(40, "A"), (200, "B"), (310, "C")]
key_pos = 350
owner = next((n for pos, n in positions if pos >= key_pos), positions[0][1])
print(owner)
What does this print?
counts_plain = {"A": 59, "B": 32, "C": 9}
counts_virtual = {"A": 34, "B": 39, "C": 27}
def spread(counts):
    return max(counts.values()) - min(counts.values())
print(spread(counts_plain) > spread(counts_virtual))

🎯 Your turn

Build keys_moved_on_add(keys, nodes, new_node, seed): return how many of keys change which node they're assigned to when new_node joins the ring.

  • keys_moved_on_add(['k1','k2','k3','k4','k5','k6','k7','k8'], ['A','B'], 'C', seed=1) → 1

Hint: assign_keys first — sort (ring_position(node), node) pairs, then each key goes to the first node whose position is >= the key's, wrapping to the first node otherwise. Then just compare before/after.

Press ▶ Run, try the Live App chips, then ✓ Check.

Practice — level up

0/5 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upPlace something on the ring

Write ring_position(item, seed, ring_size), a stable (not Python's hash()) position in [0, ring_size) for item, using hashlib.md5. ring_position('A', 1, 360) -> 134.

hash-tables
DrillThe naive alternative

Write modulo_shard(key, num_nodes, seed): a stable hash(key) % num_nodes-style shard index, using the same hashlib.md5 approach. modulo_shard('k1', 2, 1) -> 1.

hash-tables
BuildAssign every key to a node

Write assign_keys(keys, nodes, seed, ring_size=360), returning a dict mapping each key to the node whose ring position is the first one at or after the key's own position (wrapping to the first node if none is). assign_keys(['k1','k5'], ['A','B'], 1) -> {'k1': 'A', 'k5': 'B'}.

consistent-hashing
BossVirtual nodes flatten the load

Write assign_keys_virtual(keys, nodes, seed, replicas), like assign_keys but each node occupies replicas positions on the ring (label each as f'{node}#{i}' for i in range(replicas)), all mapping back to the real node name. assign_keys_virtual(['k1','k2','k3'], ['A','B'], 1, 3) -> {'k1': 'A', 'k2': 'A', 'k3': 'B'}.

consistent-hashing
CapstoneHow bad is modulo sharding, exactly?

Write keys_moved_modulo(keys, num_nodes, seed): how many of keys change shard index (via modulo_shard) when num_nodes grows by one. keys_moved_modulo(['k1',...,'k8'], 2, 1) -> 7.

consistent-hashing
keys_moved_on_add.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
keys_moved_on_add(keys, nodes, new_node, seed) → intHow many keys change owner when new_node joins the ring, out of the whole key set.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.