studyDDIAChapter 7 · Sharding

One Random Range per Node Is Lumpy; 16 Vnodes Flatten It, 256 Nearly Perfect

Consistent hashing with one random range per node leaves the busiest of 10 nodes owning 2.8× its fair share — "random boundaries, so roughly even" is wrong. Give each node V small random ranges (virtual nodes) and the imbalance falls to 1.38× at V=16 (Cassandra) and 1.10× at V=256 (ScyllaDB). The relative spread shrinks like 1/√V.


Concept

Consistent hashing places node boundaries at random points on a hash ring. The original scheme gives each of 10 nodes one random range; the intuition is "random boundaries, so roughly even load." Run it and the busiest node owns 2.8× its fair share. Now give each node V small random ranges instead — virtual nodes ("vnodes") — and the lumps average out: at V=16 (Cassandra's default) the busiest node is only 1.38× over, at V=256 (ScyllaDB) only 1.10×. The relative spread (coefficient of variation) shrinks like 1/√V — the law of large numbers averaging V independent range-widths per node. Same random boundaries, same uniform keys; the only knob is how many slices each node owns.

Provenance

Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 7 — Sharding, §"Sharding by hash range" (Figure 7-6):

Cassandra splits the hash range into contiguous ranges with randomly chosen boundaries [...] Since these random boundaries are unlikely to split the load perfectly evenly, Cassandra assigns several ranges (16 by default) to each node; ScyllaDB uses 256. Having many small ranges per node means the imbalances even out.

This exercise measures exactly that evening-out: the load imbalance and its coefficient of variation as the number of ranges per node climbs 1 → 256.

Prerequisites

The whole result is one fact about sums of random variables. Each line notes where it shows up.

  1. Consistent hashing puts nodes on a ring — keys and nodes hash to points on [0, 2**32); a node owns the ring arc up to the next boundary. It shows up as the sorted random boundary points forming contiguous ranges. (Carries the setup, not the surprise.)
  2. Uniform hashing → load ∝ width owned — a good hash spreads keys uniformly over the ring, so the fraction of keys a node gets equals the fraction of the ring it covers. It shows up as "load = total width of a node's ranges" — no key distribution needed, just geometry.
  3. Coefficient of variation (CV = std/mean) — the relative spread of a distribution, unitless, so you can compare spread across different V. It shows up as the CV column that shrinks with V. (This is the axis the result lives on.)
  4. Variance of a sum / law of large numbers — for V roughly-independent range widths, the sum's mean grows like V but its standard deviation grows like √V, so CV = std/mean ∝ √V/V = 1/√V. This is the load-bearing one — it is the 1/√V law and the reason vnodes work.
Where to learn the prerequisites Consistent / ring hashing (#1–#2): DDIA §"Sharding by hash range"; Karger et al., "Consistent Hashing and Random Trees" (1997) for the ring and the vnode ("virtual node") idea. CV, variance of a sum, LLN (#3–#4): any intro-probability text — e.g. Blitzstein & Hwang, Introduction to Probability (the full book is free as a PDF), the "expectation and variance" and "law of large numbers" chapters. The one identity to hold: Var(sum of n iid) = n·Var(one), so std ∝ √n while mean ∝ n. If only one is new, make it #4 — that √n-vs-n split is the entire mechanism.
Environment these numbers came from Deterministic, so the numbers reproduce exactly. Captured on macOS 26.5.2 (Darwin 25.5.0), arm64, Python 3.15.0a8, standard library only — no Docker, no deps. 10 nodes, hash space [0, 2**32), ranges assigned round-robin over the sorted ring boundaries. Imbalance and CV averaged over a fixed list of 20 seeds (SEEDS = range(20)) to tame single-sample noise; nothing is process-random, so every run prints the same table.

Mental model

Keys are uniform over the ring, so a node's load is simply the total width of the hash ranges it owns. The fair share is 1/10 of the ring for every node. Two ways to slice:

We measure two things per V: imbalance = max_node_load / mean_node_load (how far over fair share the busiest node is — the one that determines whether the cluster tips over) and CV = std/mean (the relative spread across all nodes). The whole thing is one script, code/vnodes_variance.py.

Setup

cd study/ddia/ch07
python3 code/vnodes_variance.py

Do not read the output yet — make each prediction first.


Step 1 · one random range per node

Predict. 10 nodes, each owning one random slice of the hash ring (V=1). Keys are uniform, so load = width owned. Random boundaries feel fair — so how far above its 10% fair share is the busiest node? (imbalance = max/mean, where 1.0 would be perfectly even)

How far over fair share is the busiest node at V=1?
10 nodes on a 2**32 hash ring; keys uniform, so load = width owned. Each node owns V random ranges (V=1 is the original single-boundary scheme). Averaged over 20 fixed seeds. Fair share per node = 10% of the ring. V | imbalance max/mean | CV (std/mean) | 1/sqrt(V) -----+--------------------+---------------+---------- 1 | 2.804 | 0.846 | 1.000 2 | 2.372 | 0.651 | 0.707 4 | 1.858 | 0.439 | 0.500 16 | 1.378 | 0.212 | 0.250 64 | 1.216 | 0.123 | 0.125 256 | 1.105 | 0.058 | 0.062

2.804 — the busiest node owns nearly 2.8× its fair share. "Random boundaries → roughly even" is wrong. With a single slice per node the load is one draw of a high-variance width (CV = 0.846), so some node reliably ends up hoarding almost three nodes' worth of keys. That is precisely why nobody ships V=1.

Step 2 · 16 vnodes (Cassandra's default)

Predict. Same ring, same random boundaries, but now each node owns 16 small ranges instead of one. The busiest node's load is the sum of 16 widths. Does its imbalance drop to near 1.0, or is 16 slices still lumpy? Read the V=16 row.

What does 16 vnodes per node do to the imbalance?
16 | 1.378 | 0.212 | 0.250

1.378 — the busiest node is now only 38% over fair share, and the spread has collapsed from 0.846 to 0.212. Summing 16 independent widths lets fat and thin slices cancel. It is not perfect — 16 is a deliberate compromise — but it turns a 2.8× hot node into a 1.4× one for the price of tracking 16 ranges instead of 1.

Step 3 · 256 vnodes, and the 1/√V law

Predict. Push to V=256 (ScyllaDB's default). Look at the last two columns of the full table: the measured CV and the printed 1/√V. Will CV keep tracking 1/√V — i.e. does every 4× in V roughly halve the spread? What is CV at V=256?

Does CV keep tracking 1/√V down to V=256?
256 | 1.105 | 0.058 | 0.062 Read it down the last two columns: CV tracks 1/sqrt(V) closely. V=1 busiest node ~ its imbalance above the fair share; V=16 (Cassandra) CV ~ 25%; V=256 (ScyllaDB) CV ~ 6%. More, smaller ranges -> flatter load.

CV = 0.058 at V=256 — a 6% relative spread, and the busiest node is only 1.10× over. Trace the CV column: 0.846 → 0.439 → 0.212 → 0.058 as V goes 1 → 4 → 16 → 256. Each 16× in V (which should give 1/4 the CV) does almost exactly that. The measured CV isn't literally 1/√V — it's about 0.85/√V, the 0.85 set by the 10-node geometry — but it has the same shape: CV·√V stays flat (~0.85–0.95) across every row. That flat product is the law.


What you should see

Why

Start from what a node's load is. With V ranges per node, its load is the sum of V range widths, L = w_1 + w_2 + ... + w_V. Random boundaries make each width a random variable with mean μ ≈ HASH_SPACE / (NODES·V) — smaller as V grows, because you're cutting the ring into more pieces. The mean load is V·μ, which is constant in V (it's always HASH_SPACE / NODES, the fair share — every node owns 1/10 of the ring no matter how you slice it).

The spread is where V bites. Treat the V widths as roughly independent. Variance of a sum of independent terms adds: Var(L) ≈ V·σ², so the standard deviation grows like √V·σ. Each individual width also shrinks as V grows (σ ∝ μ ∝ 1/V), so put it together:

CV = std / mean ≈ (√V · σ) / (V · μ) = (√V / V) · (σ/μ) ∝ 1/√V

The σ/μ of a single width is a constant (~0.85 for this 10-node ring), and √V / V = 1/√V. That's the whole result: the mean load is pinned to fair share, but the relative wobble around it falls off as 1/√V. More, smaller ranges per node = more independent terms in the sum = the law of large numbers grinding the lumps flat. This is exactly why the vendors picked what they picked: V=16 buys CV ≈ 0.85/4 ≈ 21% (a decent 1.4× worst case), and V=256 buys CV ≈ 0.85/16 ≈ 5% (a nearly flat 1.1× worst case) — quadrupling V for each halving of spread, paying only in the number of ranges each node has to track.

The boundary — vnodes fix data skew, not load skew The 1/√V law assumes keys are uniform over the hash space. Vnodes cure boundary-placement skew: the unevenness that comes from where the random cuts happen to fall. They do nothing for request skew. If one key is red-hot — a celebrity's user id, a trending=true sentinel — it hashes to exactly one point on the ring and lands on exactly one vnode on one node, and slicing that node's territory into 256 pieces doesn't split a single key. That node still eats all the traffic. Hot keys are a different problem with different fixes (add a random suffix to spread the key, or cache/replicate it) — see the hot-key exercise. Vnodes flatten the map; they can't flatten a spike.

Go deeper

Sources: DDIA 2e, Ch. 7, §"Sharding by hash range" (Figure 7-6) · Karger et al., "Consistent Hashing and Random Trees" (1997) · Blitzstein & Hwang, Introduction to Probability (variance of sums, law of large numbers).