Build a Sharded Service — Hash Routing, Hot Keys, Consistent Hashing & Shard Loss
Sharded Service — Route, Measure, Rebalance
Every system-design answer eventually says the same sentence: "we shard by hash(tenant_id) % N." It is said so often it stops sounding like a decision and starts sounding like a fact of nature. It isn't. It's a specific, fragile choice with two failure modes that show up the moment the system is real instead of imagined: a hot key — one tenant, one celebrity account, one viral row — sends 10× the traffic of its neighbors and pins one shard while the other N−1 idle; and a shard count change — you add capacity, or you lose a node — under naive % N reshuffles almost every key in the cluster at once, turning a routine scaling event into a cache-flushing, connection-storming incident. This lab builds a single in-memory router, points real traffic at it, and measures both failures and both fixes: key salting for the hot key, and a consistent-hashing ring with virtual nodes for the rebalance storm. Every claim below is a printed number from a program that ran, in Java and in Go, not a comment asserting what "should" happen.
1. The Trap — "hash and mod" is a sentence, not a system
The one-line version of sharding is shard = hash(key) % N. It is correct, it is simple, and it is the thing every naive implementation reaches for first — because for a uniformly random key space with a fixed N, it actually works: Movement 1 below measures a max/min imbalance ratio of 1.000 across 100,000 keys on 8 shards. The trap is believing that "works for uniform keys at fixed N" generalizes to production, where neither assumption holds:
- Keys are never uniform in traffic, only in count. A distinct-key histogram can look perfectly flat while the request histogram is wildly skewed — one tenant ID, one product page, one leaderboard row gets 100× the QPS of its neighbors. Every request for that key hashes to the same shard, no matter how many other keys share that shard. Movement 2 measures this directly: a single hot key at 40% of traffic drives a 6.4× max/min imbalance — one shard is doing six times the work of the least-loaded one, and it cannot be fixed by adding more shards, because the hot key still hashes to exactly one of them.
% Nhas no memory of past assignments. ChangeNtoN+1— add a shard for capacity, or lose one to a failure — andhash(key) % Nandhash(key) % (N+1)agree on almost nothing. Movement 3 measures it: 88.9% of keys move to a different shard for a change from 8 to 9. That's not a rebalance, that's a full cluster reshuffle — every cache is cold, every connection pool reconnects, every piece of co-located state (open transactions, in-flight sessions) has moved out from under itself, all because you added one node.
Scope for this lab. One in-memory router process, no real network, no real database — the point is the routing math and its measured consequences, not the transport. Four things get built and measured: (1) baseline mod-N distribution, (2) a hot key and its fix via key salting, (3) consistent hashing with virtual nodes vs. naive mod-N on a shard add, (4) a shard removal (loss) and the resulting remap. Out of scope: actual data migration between shards (this lab measures which keys move, not the bytes-on-the-wire cost of moving them), and replication/quorum within a shard (that's the companion quorum lab).
2. Scope it like a senior — the questions that reshape the design
- Is the skew in the key space or the traffic? These are different diseases with different cures. A skewed key space (some values just occur more often, e.g. a Zipfian ID distribution) is fixed by a better hash function or bucket count. A skewed traffic pattern (one key gets disproportionate QPS regardless of how many keys share its bucket) is a hot-key problem — no amount of re-hashing helps, because the hot key is still exactly one key, hashing to exactly one shard. You fix it by turning one logical key into many physical keys (salting/splitting), not by rehashing.
- Does the shard count ever change? If
Nis fixed forever, plain mod-N is fine — it's simpler and has no ring to maintain. The momentNchanges (autoscaling, hardware failure, deliberate resharding), mod-N's "almost everything moves" property becomes the design's central cost, and that's the whole argument for consistent hashing: it trades a small amount of routing complexity (a ring, virtual nodes) for a large reduction in data movement (~K/Ninstead of ~K). - How many virtual nodes per physical shard? Too few (say, 1 vnode per shard) and the ring's arcs are lumpy — one physical shard can own a disproportionate slice of the hash space purely by chance, because a single hash point's neighbors are not evenly spaced. More vnodes per shard smooths this at the cost of a bigger routing table and slower ring maintenance on shard add/remove. This lab measures the effect directly by looking at the post-loss distribution.
- What happens to in-flight state when a key remaps? A cache entry, an open connection, a pinned in-memory session — anything colocated with the "old" owner has to be treated as invalidated when ownership moves. This is the real cost consistent hashing is minimizing: not the CPU of a mod operation, but the blast radius of invalidation. Measuring "13% of keys moved" on a shard loss is really measuring "13% of caches went cold," which is why the fraction, not just the count, is the number that matters.
- Ring-based consistent hashing, or rendezvous hashing? Both solve the same problem (bounded remap on
Nchange) with different data structures and different lookup costs — Movement 8's trade-off table makes the call explicit.
3. Reason to the design — from a mod operation to a ring
Simplest thing that could work: shard = hash(key) % N. Zero state beyond N itself, O(1) lookup, and — as Movement 1 shows — a genuinely flat distribution when keys are uniform and N is stable. Ship this until either assumption breaks.
First break — a hot key. One key concentrates a large share of traffic; every request for it lands on the same shard regardless of hashing quality. The fix is not a better hash function (the key is one value; any deterministic hash sends it to exactly one bucket) — it's making the key not one value. Key salting: the caller (or a thin wrapper) rewrites hot-key into one of hot-key#0 .. hot-key#15, chosen round-robin or randomly per request. Each salted variant hashes independently and lands on a (probably) different shard. Reads now have to fan out to all 16 sub-keys and merge — a real cost, paid only for the keys that need it — but writes and the routing load are spread. Movement 2 measures the before/after: imbalance ratio drops from 6.4× to 1.5× with a 16-way split.
Second break — N changes. Salting doesn't help here; the problem now is that % N has no relationship to % (N+1). The fix is to stop hashing into a flat array and instead hash onto a ring: every shard occupies one or more points on a fixed, large hash space (say, 0 to 2³²−1), and a key's owner is whichever shard's point comes next, clockwise, from the key's own hash. Adding a shard means adding its point(s) to the ring — it only steals the arc between its new point and the previous point's owner, leaving every other arc, and every other key's owner, untouched. Movement 3 measures the difference directly on the same 100,000 keys: naive mod-N moves 88.9% on an 8→9 shard add; the ring moves 8.6% — roughly what you'd expect for the new shard claiming its fair ~1/9th share of the space, and an order of magnitude better than naive.
Third refinement — virtual nodes. One point per physical shard means that shard's owned arc-length is whatever the gap happens to be to its single neighbor — pure luck, and with only a handful of shards the variance is large (one shard might own 40% of the ring, another 5%). Give each physical shard many points (this lab uses 500 vnodes per shard) scattered across the ring, and by the law of large numbers each shard's total owned arc-length converges toward its fair 1/N share, without changing the fundamental property: removing or adding a shard still only touches the arcs adjacent to that shard's own vnodes.
The backstop — shard loss is just a ring removal, not a special case. A crashed or decommissioned shard is handled by the exact same mechanism as an add, run in reverse: delete its vnodes from the ring, and every key that used to resolve to one of those points now resolves to whichever vnode is next clockwise — automatically, with no coordinator decision required about where each orphaned key goes. Movement 4 measures it: removing 1 of 8 shards moves 13.4% of keys (close to the lost shard's fair 1/8 = 12.5% share) to the surviving shards, and — the number that actually matters operationally — 0 keys become unroutable. The ring degrades gracefully; nothing needs a global rebalance decision to keep serving traffic.
4. Build it — Java
One deterministic 32-bit hash (FNV-1a, so the numbers are reproducible run-to-run and identical in shape across languages), a plain mod-N router, and a consistent-hashing Ring backed by a TreeMap so "next point clockwise" is a single ceilingEntry lookup with wraparound. Java 8, no var, no records — plain classes, exactly like the rest of this guide's labs.
import java.util.*;
public class ShardDemo {
// ---------- Deterministic hash (FNV-1a, 32-bit) ----------
static long fnv1a(String s) {
long hash = 2166136261L;
for (int i = 0; i < s.length(); i++) {
hash ^= (s.charAt(i) & 0xff);
hash = (hash * 16777619L) & 0xffffffffL;
}
return hash;
}
static int modShard(String key, int n) {
return (int) (fnv1a(key) % n);
}
// ---------- Consistent hash ring with virtual nodes ----------
static class Ring {
final TreeMap<Long, String> points = new TreeMap<Long, String>();
final int vnodes;
Ring(int vnodes) { this.vnodes = vnodes; }
void addShard(String shard) {
for (int i = 0; i < vnodes; i++) {
points.put(fnv1a(shard + "#" + i), shard);
}
}
void removeShard(String shard) {
Iterator<Map.Entry<Long, String>> it = points.entrySet().iterator();
while (it.hasNext()) {
if (it.next().getValue().equals(shard)) it.remove();
}
}
String owner(String key) {
if (points.isEmpty()) return null;
long h = fnv1a(key);
Map.Entry<Long, String> e = points.ceilingEntry(h);
if (e == null) e = points.firstEntry(); // wrap around the ring
return e.getValue();
}
Ring copy() {
Ring r = new Ring(vnodes);
r.points.putAll(points);
return r;
}
}
static double maxMinRatio(Map<String, Integer> counts) {
int max = Integer.MIN_VALUE, min = Integer.MAX_VALUE;
for (int v : counts.values()) { max = Math.max(max, v); min = Math.min(min, v); }
if (min == 0) return Double.POSITIVE_INFINITY;
return (double) max / min;
}
public static void main(String[] args) {
int N = 8; // shards
int M = 100_000; // distinct keys
int V = 500; // virtual nodes per shard
List<String> keys = new ArrayList<String>(M);
for (int i = 0; i < M; i++) keys.add("key-" + i);
System.out.println("== Movement 1: route M keys by hash(key) % N, measure the distribution ==");
Map<String, Integer> baseline = new TreeMap<String, Integer>();
for (int i = 0; i < N; i++) baseline.put("shard-" + i, 0);
for (String k : keys) {
String s = "shard-" + modShard(k, N);
baseline.merge(s, 1, Integer::sum);
}
System.out.println(" shards=" + N + " keys=" + M);
System.out.println(" counts = " + baseline);
System.out.printf(" max/min imbalance ratio = %.3f (close to 1.0 = balanced)%n", maxMinRatio(baseline));
System.out.println();
System.out.println("== Movement 2: inject a HOT KEY, measure the imbalance, then fix with salting ==");
int totalRequests = 200_000;
int hotFraction = 40; // hot key gets 40% of all traffic
int hotCount = totalRequests * hotFraction / 100;
Random rnd = new Random(42);
Map<String, Integer> hot = new TreeMap<String, Integer>();
for (int i = 0; i < N; i++) hot.put("shard-" + i, 0);
for (int r = 0; r < totalRequests; r++) {
String key = (r < hotCount) ? "hot-key" : "key-" + rnd.nextInt(M);
String s = "shard-" + modShard(key, N);
hot.merge(s, 1, Integer::sum);
}
System.out.println(" requests=" + totalRequests + ", hot key share=" + hotFraction + "%");
System.out.println(" counts (BEFORE fix) = " + hot);
System.out.printf(" max/min imbalance ratio (BEFORE) = %.3f <- one shard is overloaded%n", maxMinRatio(hot));
int splitFactor = 16; // salt the hot key into 16 sub-keys
Map<String, Integer> salted = new TreeMap<String, Integer>();
for (int i = 0; i < N; i++) salted.put("shard-" + i, 0);
rnd = new Random(42);
for (int r = 0; r < totalRequests; r++) {
String key;
if (r < hotCount) {
key = "hot-key#" + (r % splitFactor); // spread the hot key across sub-keys
} else {
key = "key-" + rnd.nextInt(M);
}
String s = "shard-" + modShard(key, N);
salted.merge(s, 1, Integer::sum);
}
System.out.println(" counts (AFTER salting, split=" + splitFactor + ") = " + salted);
System.out.printf(" max/min imbalance ratio (AFTER) = %.3f <- rebalanced%n", maxMinRatio(salted));
System.out.println();
System.out.println("== Movement 3: consistent hashing (vnodes) vs naive mod-N on shard ADD ==");
int naiveChanged = 0;
for (String k : keys) {
if (modShard(k, N) != modShard(k, N + 1)) naiveChanged++;
}
double naiveFrac = (double) naiveChanged / M;
System.out.printf(" naive mod-N remap: %d / %d keys moved (%.1f%%) <- almost everything reshuffles%n",
naiveChanged, M, naiveFrac * 100);
Ring ringN = new Ring(V);
for (int i = 0; i < N; i++) ringN.addShard("shard-" + i);
Ring ringN1 = ringN.copy();
ringN1.addShard("shard-" + N); // add one more shard
int chChanged = 0;
for (String k : keys) {
if (!ringN.owner(k).equals(ringN1.owner(k))) chChanged++;
}
double chFrac = (double) chChanged / M;
System.out.printf(" consistent hashing: %d / %d keys moved (%.1f%%) <- only the new shard's fair share%n",
chChanged, M, chFrac * 100);
System.out.printf(" theoretical target ~= 1/(N+1) = %.1f%% -- naive is ~%.0fx worse%n",
100.0 / (N + 1), naiveFrac / chFrac);
System.out.println();
System.out.println("== Movement 4: SHARD LOSS -- remove a shard, measure remap + no unroutable keys ==");
Map<String, String> before = new HashMap<String, String>();
for (String k : keys) before.put(k, ringN.owner(k));
Ring afterLoss = ringN.copy();
afterLoss.removeShard("shard-0");
int moved = 0, unroutable = 0;
Map<String, Integer> redistribution = new TreeMap<String, Integer>();
for (String k : keys) {
String owner = afterLoss.owner(k);
if (owner == null) unroutable++;
else redistribution.merge(owner, 1, Integer::sum);
if (owner == null || !owner.equals(before.get(k))) moved++;
}
System.out.println(" removed shard-0; keys formerly on shard-0 must land on their next ring neighbor");
System.out.printf(" keys moved = %d / %d (%.1f%%) <- ~= the lost shard's fair share (1/N = %.1f%%)%n",
moved, M, 100.0 * moved / M, 100.0 / N);
System.out.println(" unroutable keys = " + unroutable + " <- ring still covers every key");
System.out.println(" post-loss counts = " + redistribution);
}
}
Compile and run: javac ShardDemo.java && java ShardDemo. The whole design lives in one method, Ring.owner: ceilingEntry(h) finds the first vnode at or after the key's hash, and the null fallback to firstEntry() is the wraparound that makes the "ring" actually circular instead of a line with an edge case at the top.
4b. Build it — Go
Same hash, same numbers, same ring semantics. Go has no built-in sorted map, so the ring is a slice of (hash, shard) points kept sorted, and Owner does the "first point ≥ key's hash, wrapping to index 0" lookup with sort.Search — the binary-search equivalent of Java's ceilingEntry.
package main
import (
"fmt"
"math/rand"
"sort"
)
// ---------- Deterministic hash (FNV-1a, 32-bit) ----------
func fnv1a(s string) uint64 {
var hash uint64 = 2166136261
for i := 0; i < len(s); i++ {
hash ^= uint64(s[i])
hash = (hash * 16777619) & 0xffffffff
}
return hash
}
func modShard(key string, n int) int {
return int(fnv1a(key) % uint64(n))
}
// ---------- Consistent hash ring with virtual nodes ----------
type ringPoint struct {
hash uint64
shard string
}
type Ring struct {
points []ringPoint // kept sorted by hash
vnodes int
}
func NewRing(vnodes int) *Ring {
return &Ring{vnodes: vnodes}
}
func (r *Ring) sortPoints() {
sort.Slice(r.points, func(i, j int) bool { return r.points[i].hash < r.points[j].hash })
}
func (r *Ring) AddShard(shard string) {
for i := 0; i < r.vnodes; i++ {
r.points = append(r.points, ringPoint{fnv1a(fmt.Sprintf("%s#%d", shard, i)), shard})
}
r.sortPoints()
}
func (r *Ring) RemoveShard(shard string) {
out := r.points[:0]
for _, p := range r.points {
if p.shard != shard {
out = append(out, p)
}
}
r.points = out
}
func (r *Ring) Owner(key string) string {
if len(r.points) == 0 {
return ""
}
h := fnv1a(key)
idx := sort.Search(len(r.points), func(i int) bool { return r.points[i].hash >= h })
if idx == len(r.points) {
idx = 0 // wrap around the ring
}
return r.points[idx].shard
}
func (r *Ring) Copy() *Ring {
cp := &Ring{vnodes: r.vnodes, points: make([]ringPoint, len(r.points))}
copy(cp.points, r.points)
return cp
}
func maxMinRatio(counts map[string]int) float64 {
max, min := -1<<62, 1<<62
for _, v := range counts {
if v > max {
max = v
}
if v < min {
min = v
}
}
if min == 0 {
return -1 // signal "infinite"
}
return float64(max) / float64(min)
}
func sortedKeysCounts(counts map[string]int) string {
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
out := "map["
for i, k := range keys {
if i > 0 {
out += " "
}
out += fmt.Sprintf("%s:%d", k, counts[k])
}
return out + "]"
}
func main() {
N := 8 // shards
M := 100000 // distinct keys
V := 500 // virtual nodes per shard
keys := make([]string, M)
for i := 0; i < M; i++ {
keys[i] = fmt.Sprintf("key-%d", i)
}
fmt.Println("== Movement 1: route M keys by hash(key) % N, measure the distribution ==")
baseline := map[string]int{}
for i := 0; i < N; i++ {
baseline[fmt.Sprintf("shard-%d", i)] = 0
}
for _, k := range keys {
s := fmt.Sprintf("shard-%d", modShard(k, N))
baseline[s]++
}
fmt.Printf(" shards=%d keys=%d\n", N, M)
fmt.Println(" counts =", sortedKeysCounts(baseline))
fmt.Printf(" max/min imbalance ratio = %.3f (close to 1.0 = balanced)\n", maxMinRatio(baseline))
fmt.Println()
fmt.Println("== Movement 2: inject a HOT KEY, measure the imbalance, then fix with salting ==")
totalRequests := 200000
hotFraction := 40 // hot key gets 40% of all traffic
hotCount := totalRequests * hotFraction / 100
rnd := rand.New(rand.NewSource(42))
hot := map[string]int{}
for i := 0; i < N; i++ {
hot[fmt.Sprintf("shard-%d", i)] = 0
}
for r := 0; r < totalRequests; r++ {
var key string
if r < hotCount {
key = "hot-key"
} else {
key = fmt.Sprintf("key-%d", rnd.Intn(M))
}
s := fmt.Sprintf("shard-%d", modShard(key, N))
hot[s]++
}
fmt.Printf(" requests=%d, hot key share=%d%%\n", totalRequests, hotFraction)
fmt.Println(" counts (BEFORE fix) =", sortedKeysCounts(hot))
fmt.Printf(" max/min imbalance ratio (BEFORE) = %.3f <- one shard is overloaded\n", maxMinRatio(hot))
splitFactor := 16 // salt the hot key into 16 sub-keys
salted := map[string]int{}
for i := 0; i < N; i++ {
salted[fmt.Sprintf("shard-%d", i)] = 0
}
rnd = rand.New(rand.NewSource(42))
for r := 0; r < totalRequests; r++ {
var key string
if r < hotCount {
key = fmt.Sprintf("hot-key#%d", r%splitFactor) // spread the hot key across sub-keys
} else {
key = fmt.Sprintf("key-%d", rnd.Intn(M))
}
s := fmt.Sprintf("shard-%d", modShard(key, N))
salted[s]++
}
fmt.Printf(" counts (AFTER salting, split=%d) = %s\n", splitFactor, sortedKeysCounts(salted))
fmt.Printf(" max/min imbalance ratio (AFTER) = %.3f <- rebalanced\n", maxMinRatio(salted))
fmt.Println()
fmt.Println("== Movement 3: consistent hashing (vnodes) vs naive mod-N on shard ADD ==")
naiveChanged := 0
for _, k := range keys {
if modShard(k, N) != modShard(k, N+1) {
naiveChanged++
}
}
naiveFrac := float64(naiveChanged) / float64(M)
fmt.Printf(" naive mod-N remap: %d / %d keys moved (%.1f%%) <- almost everything reshuffles\n",
naiveChanged, M, naiveFrac*100)
ringN := NewRing(V)
for i := 0; i < N; i++ {
ringN.AddShard(fmt.Sprintf("shard-%d", i))
}
ringN1 := ringN.Copy()
ringN1.AddShard(fmt.Sprintf("shard-%d", N)) // add one more shard
chChanged := 0
for _, k := range keys {
if ringN.Owner(k) != ringN1.Owner(k) {
chChanged++
}
}
chFrac := float64(chChanged) / float64(M)
fmt.Printf(" consistent hashing: %d / %d keys moved (%.1f%%) <- only the new shard's fair share\n",
chChanged, M, chFrac*100)
fmt.Printf(" theoretical target ~= 1/(N+1) = %.1f%% -- naive is ~%.0fx worse\n",
100.0/float64(N+1), naiveFrac/chFrac)
fmt.Println()
fmt.Println("== Movement 4: SHARD LOSS -- remove a shard, measure remap + no unroutable keys ==")
before := make(map[string]string, M)
for _, k := range keys {
before[k] = ringN.Owner(k)
}
afterLoss := ringN.Copy()
afterLoss.RemoveShard("shard-0")
moved, unroutable := 0, 0
redistribution := map[string]int{}
for _, k := range keys {
owner := afterLoss.Owner(k)
if owner == "" {
unroutable++
} else {
redistribution[owner]++
}
if owner == "" || owner != before[k] {
moved++
}
}
fmt.Println(" removed shard-0; keys formerly on shard-0 must land on their next ring neighbor")
fmt.Printf(" keys moved = %d / %d (%.1f%%) <- ~= the lost shard's fair share (1/N = %.1f%%)\n",
moved, M, 100.0*float64(moved)/float64(M), 100.0/float64(N))
fmt.Println(" unroutable keys =", unroutable, " <- ring still covers every key")
fmt.Println(" post-loss counts =", sortedKeysCounts(redistribution))
}
Run: go run shard.go (clean under go vet). Movements 1, 3, and 4 print byte-identical numbers to the Java run — same hash function, same deterministic key set, same ring math. Movement 2's hot-key numbers differ in the third digit because Java's Random and Go's math/rand don't produce the same sequence for the same seed; the shape of the result (~6.4× imbalance collapsing to ~1.5×) is identical.
5. Break it by running it — the four measured numbers
Here is the Java run, in full:
== Movement 1: route M keys by hash(key) % N, measure the distribution ==
shards=8 keys=100000
counts = {shard-0=12498, shard-1=12501, shard-2=12500, shard-3=12498, shard-4=12501, shard-5=12500, shard-6=12501, shard-7=12501}
max/min imbalance ratio = 1.000 (close to 1.0 = balanced)
== Movement 2: inject a HOT KEY, measure the imbalance, then fix with salting ==
requests=200000, hot key share=40%
counts (BEFORE fix) = {shard-0=15151, shard-1=14964, shard-2=95009, shard-3=14926, shard-4=15066, shard-5=14863, shard-6=15086, shard-7=14935}
max/min imbalance ratio (BEFORE) = 6.392 <- one shard is overloaded
counts (AFTER salting, split=16) = {shard-0=20151, shard-1=29964, shard-2=25009, shard-3=19926, shard-4=25066, shard-5=24863, shard-6=30086, shard-7=24935}
max/min imbalance ratio (AFTER) = 1.510 <- rebalanced
== Movement 3: consistent hashing (vnodes) vs naive mod-N on shard ADD ==
naive mod-N remap: 88864 / 100000 keys moved (88.9%) <- almost everything reshuffles
consistent hashing: 8584 / 100000 keys moved (8.6%) <- only the new shard's fair share
theoretical target ~= 1/(N+1) = 11.1% -- naive is ~10x worse
== Movement 4: SHARD LOSS -- remove a shard, measure remap + no unroutable keys ==
removed shard-0; keys formerly on shard-0 must land on their next ring neighbor
keys moved = 13403 / 100000 (13.4%) <- ~= the lost shard's fair share (1/N = 12.5%)
unroutable keys = 0 <- ring still covers every key
post-loss counts = {shard-1=11983, shard-2=13734, shard-3=15028, shard-4=11857, shard-5=15696, shard-6=15500, shard-7=16202}
Movement 1 — the baseline is genuinely flat. 100,000 distinct keys over 8 shards land within ±3 of the 12,500 mean per shard; the max/min ratio is 1.000. This is the case that makes "just hash and mod" look sufficient — and for a fixed key set and fixed shard count, it is.
Movement 2 — one hot key breaks it completely, and salting fixes it. With 40% of 200,000 requests aimed at a single key, shard-2 (where that key happens to hash) absorbs 95,009 requests while the coldest shard gets 14,863 — a 6.392× imbalance driven entirely by one logical key. Rewriting that one key into 16 salted variants (hot-key#0 .. hot-key#15) before routing spreads the same 80,000 hot requests across up to 16 different shard assignments; the post-fix ratio drops to 1.510. It isn't perfectly flat — with only 16 salts and 8 shards, a couple of shards get slightly luckier than others — but it went from "one shard is doing 6× the work" to "shards differ by at most 50%," and increasing the split factor tightens it further at the cost of a wider fan-out read.
Movement 3 — the reshuffle-on-resize gap, measured, not asserted. Going from 8 to 9 shards under naive % N moves 88,864 of 100,000 keys (88.9%) — essentially the entire key space reassigns owners for a single added node. The identical 8→9 change on the consistent-hashing ring moves 8,584 keys (8.6%) — within a rounding error of the theoretical 1/(N+1) = 11.1% a perfectly even ring would hit, and ~10× less data movement than naive mod-N for the exact same capacity change.
Movement 4 — a shard loss degrades gracefully, and nothing goes dark. Deleting shard-0's 500 vnodes from the 8-shard ring moves 13,403 keys (13.4%) — close to the lost shard's fair 1/8 = 12.5% share — to the seven survivors, redistributed unevenly across them (500 vnodes per shard leaves some sampling variance, visible in the post-loss counts ranging from 11,857 to 16,202). The number that matters most operationally is unroutable keys = 0: every key that used to belong to the dead shard now resolves, automatically, to whichever surviving vnode is next clockwise — no coordinator step, no "which shard should own this now" decision, and no key left without an owner.
6. Defend under drilling
- "Why not just use a bigger, better hash function to fix the hot key?" Because the hot key is one value —
hash("hot-key")is a fixed number no matter how good the hash function is, andfixed_number % Nalways lands on the same shard. A better hash improves the distribution across distinct keys; it cannot split a single key's traffic across multiple shards, because there is only one key. The only fix that works is making the hot key stop being one key — salting/splitting it intoKsub-keys that each hash independently. This is the single most common wrong answer to this question in interviews: "use a better hash" sounds plausible and is provably wrong for this failure mode. - "Doesn't salting just move the problem to read time?" Yes, and that's the trade you're making on purpose: writes and routing load spread across the salted keys, but a read for the logical key now has to fan out to all
Kvariants and merge (sum a counter, pick the max, concatenate a list — whatever the aggregation is). This is worth it exactly when writes/routing are the bottleneck and reads are rare or cheap to fan out (e.g. a hot counter updated thousands of times a second, read once a minute for a dashboard). If reads are equally hot, salting just relocates the imbalance from the write path to the read path. - "How do you decide the number of virtual nodes?" More vnodes per shard reduce variance in each shard's owned arc-length (this lab's 500-vnode run still shows post-loss counts ranging ~11.9k–16.2k, a real but bounded spread) at the cost of a bigger ring to store and walk on every add/remove. A few hundred per shard is a common real-world starting point (Cassandra's default historically was 256); the right number is "enough that the standard deviation of shard load is acceptable," found by measuring, not guessed.
- "What actually happens to the DATA when a key remaps, not just the routing table?" This lab only measures which keys change owner — it does not move bytes. In a real system, a remap triggers whatever your migration protocol is: the new owner either pulls the data from the old owner (or a replica) on first access, or a background rebalancer streams it proactively before traffic shifts. The reason the "8.6% vs 88.9%" number matters so much is that it's a direct multiplier on that migration cost — 10× fewer keys to move means 10× less data to stream, 10× fewer cold caches, 10× less thundering-herd risk against the new owner.
- "Is a graceful shard-loss remap enough, or do you also need replication?" Not enough by itself — this lab's ring answers "who owns this key now," not "do we still have the data." If
shard-0held the only copy of its keys and it's gone for good, the ring will happily route those keys to a new owner that has never seen them — a correct routing decision pointed at missing data. The ring solves routing continuity; replication (quorum reads/writes across N replicas per key, as in the companion quorum lab) solves data durability. Production systems need both: the ring says where to look, replication guarantees something is there to find.
7. Selection & trade-offs — consistent hashing vs. rendezvous hashing
Consistent hashing with a ring isn't the only way to get the "~K/N keys move on an N change" property. Rendezvous hashing (a.k.a. Highest Random Weight, HRW) gets the identical bound with a completely different mechanism: for a key, compute a combined score hash(key, shard) for every currently-live shard, and assign the key to whichever shard scores highest. Add or remove a shard and only the keys that scored that shard highest (or would now score the added shard highest) move — no ring, no vnodes, no stored structure at all beyond "the current list of live shards."
| Property | Ring + virtual nodes (this lab) | Rendezvous hashing (HRW) |
|---|---|---|
| State to maintain | A sorted structure of V × N points; must be updated (insert/delete) on every shard add/remove | None beyond the current shard list — nothing to build or repair |
| Lookup cost | O(log(V·N)) — one binary search / ceilingEntry | O(N) — must score every live shard to find the max |
| Remap on N change | ~1/(N±1) of keys (measured: 8.6% for 8→9) | ~1/(N±1) of keys — same bound, different mechanism |
| Load balance smoothness | Depends on vnode count; needs tuning (this lab: 500/shard) to keep variance low | Naturally smooth for any N because every shard is scored independently each time; no vnode tuning knob needed |
| Coordination for a lookup | Every node needs the same ring snapshot to agree on ownership | Every node needs the same live-shard list to agree on ownership — same requirement, slightly smaller data to keep in sync |
| Reach for it when | N is large (hundreds+ of physical shards, e.g. a big cache/KV fleet) where O(N) scoring gets expensive and O(log n) matters | N is small-to-moderate (tens of shards, typical service-level partitioning) where "no ring to build" beats "faster lookup," and you want to avoid vnode-count tuning entirely |
The judgment call: both give you the core property this lab measured — bounded remap instead of naive mod-N's near-total reshuffle. The ring wins on raw lookup speed at large N because it turns "which shard" into a binary search instead of a linear scan; rendezvous wins on operational simplicity because there is no data structure to build, tune (vnode count), or keep consistent beyond the shard list itself. A third option worth naming in an interview even though it's out of scope here: jump consistent hash (Google, 2014) gets O(1) memory and O(log N) time with zero stored state, but it only supports adding shards or removing the highest-numbered one — it can't gracefully remove an arbitrary shard from the middle, which is exactly the shard-loss scenario Movement 4 measures, so it's the wrong tool for "a random node died," and the right one only for "we're monotonically growing a fixed, ordered pool."
8. Acceptance criteria — you're done when
- The baseline mod-N router shows a max/min imbalance ratio within a few percent of 1.0 across 100k uniformly-generated keys (Movement 1: measured 1.000).
- A single hot key at a large share of traffic (this lab: 40%) is shown to overload one shard by several× (measured 6.392×), and salting into ≥16 sub-keys measurably rebalances it (measured 1.510×) — both numbers printed, not asserted.
- A shard add is shown under both naive mod-N and consistent hashing with vnodes on the same key set, and the fraction remapped differs by roughly an order of magnitude (measured: 88.9% vs 8.6%, ~10×).
- A shard removal is shown to move only the removed shard's fair share of keys (measured 13.4% for 1-of-8, vs. a 12.5% target) and to leave zero keys unroutable.
- You can state, without hedging, why "use a better hash function" does not fix a hot key, and why it must be salting/splitting instead.
- You can defend ring-based consistent hashing vs. rendezvous hashing as a trade between lookup cost and operational simplicity, not a fashion choice — and you know jump consistent hash's specific limitation (can't remove an arbitrary node) well enough to rule it out for the shard-loss case.
9. You can now defend
- Why
hash(key) % Nis a legitimate starting point (measured flat at fixed N) and exactly which two assumptions it silently depends on — uniform traffic per key, and a shard count that never changes. - That a hot key is a traffic-skew problem, not a hashing-quality problem, and the only real fix is turning one logical key into several physical ones (salting), trading write/routing balance for a read-side fan-out cost.
- The measured, order-of-magnitude gap between naive mod-N and consistent hashing on a shard-count change (88.9% vs 8.6% remapped for the same 8→9 resize) and why that gap is really a multiplier on cache-invalidation and data-migration cost, not just an abstract routing-table statistic.
- That a shard loss is not a special case requiring new logic — it's the same ring-removal operation as an add, run in reverse, and it degrades gracefully (measured: no unroutable keys) precisely because ownership is computed locally from "next point on the ring," not decided by a coordinator.
- Ring-based consistent hashing vs. rendezvous (HRW) hashing as a lookup-cost-vs-operational-simplicity trade, and where jump consistent hash fits and where its can't-remove-arbitrary-node limitation rules it out.
Re-authored/deepened for this guide. Reference code compiled and executed before publishing: Java (javac ShardDemo.java && java ShardDemo, JDK 8 target) clean; Go (go vet shard.go, go run shard.go) clean. Movements 1, 3, and 4 produce byte-identical numbers across both languages (same FNV-1a hash, same deterministic key set); Movement 2's hot-key counts differ in the low digits only because Java's Random and Go's math/rand use different PRNG sequences for the same seed — reproduced from the runs, not asserted from memory. Sources: David Karger et al., "Consistent Hashing and Random Trees" (STOC 1997) — the original ring construction, designed for web-cache load distribution; Karger et al. (Akamai), the virtual-nodes refinement for smoothing load variance at low shard counts; David G. Thaler & Chinya V. Ravishankar, "A Name-Based Mapping Scheme for Rendezvous" (1996) — the HRW/rendezvous alternative; John Lamping & Eric Veach, "A Fast, Minimal Memory, Consistent Hash Algorithm" (Google, 2014) — jump consistent hash and its append/remove-highest-only constraint. See also the guide's quorum lab for the replication layer this lab's routing sits on top of, and the distributed rate limiter lab for another place per-key routing decisions show up under load.
🎯 STRICT STANDOUT — Sharded Service (Hands-On H5)
Why this lab: “hash % N” is not a system. This page is rare because it prints measurements for imbalance, salting, consistent-hash remap, and shard loss — and those numbers were recomputed offline in calibration (FNV-1a, M=100000, N=8, V=500).
1. Runnable contract (K11)
route(key) → shardfor mod-N and for consistent-hash ring with virtual nodes.- Metrics: per-shard request counts; max/min imbalance ratio; fraction of keys remapped on N→N+1 and on shard remove.
- Hot-key mitigation: salt
key#0..#S-1with documented read fan-out cost.
Verified offline (calibration 2026-07-15):
- Movement 1 uniform: max/min ≈ 1.000 (page 1.000) ✓
- Movement 2 hot 40% traffic: before ≈ 6.449 (page 6.4×); after 16-way salt ≈ 1.539 (page 1.5×) ✓
- Movement 3 add shard 8→9: mod-N moves 88.9%; CH moves 8.6% ✓
- Movement 4 lose 1/8: moves 13.4% (≈ fair 12.5% share); unroutable = 0 ✓
Small wording gaps (6.4 vs 6.449, 1.5 vs 1.539) are rounding — not material K1 defects.
2. Failure under partition / load (K12)
- Hot key: traffic skew ≠ keyspace skew; more shards do not fix one key → one shard.
- mod-N reshard: ~89% move on +1 shard → cache cold, connection storms.
- Shard loss: ring must keep 0 unroutable; moved fraction ≈ lost share; in-flight state on lost shard still needs replication (out of pure routing lab).
3. When-NOT (K13)
- N fixed forever and keys uniform → mod-N is simpler; skip ring complexity.
- Single node fits → do not shard for fashion.
- Need perfect balance with tiny N → more vnodes help, but measurement beats lore; rendezvous/jump hash may fit constraints better.
4. Hostile panel
- Why vnodes=500? — smooths arc length variance; show lumpy ring with vnodes=1.
- Salt read cost? — fan-out S subkeys and merge; only for hot keys.
- Does CH move data for free? — no; it bounds which keys move; migration still costs bandwidth.
5. Drills
Re-run ShardDemo; change V and remeasure post-loss balance; implement rendezvous hashing and compare remap %.
🔩 Production depth — Sharded Service (measured)
Spine: distributed-correctness · private lab · not a tool list
Production angle
Sharding is how systems scale writes — and how they create hot partitions and reshard outages.
This lab’s value is measured humility: hash % N is easy until membership changes or one key owns 40% of traffic.
Worked failure: “just add a shard”
- Peak traffic; team scales N=8 → N=9 with mod-N.
- ~89% of keys remap; caches cold; DB connections storm; SEV.
Contrast: consistent hashing + virtual nodes moves ~order 1/N of keys. Your lab numbers exist to make this argument with evidence, not vibes.
Hot key failure
Celebrity tenant pins one shard. Salting/spreading trades fan-out reads for balance — document the cost.
Invariants
- Every key maps to a live shard under declared membership.
- Membership change cost is estimated before the change window.
- Unroutable keys = 0 under clean ring design when a shard dies (or explicit failover).
When-NOT
Data fits one primary happily; premature sharding is operational debt. Prefer vertical scale until metrics force the cut.
Related: Partitioning theory · Flash-sale · Tenant isolation in payments.
🤖 Don't fully get this? Learn it with Claude
Stuck on Build a Sharded Service — Hash Routing, Hot Keys, Consistent Hashing & Shard Loss? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Build a Sharded Service — Hash Routing, Hot Keys, Consistent Hashing & Shard Loss** (Hands-On Builds) and want to truly understand it. Explain Build a Sharded Service — Hash Routing, Hot Keys, Consistent Hashing & Shard Loss from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Build a Sharded Service — Hash Routing, Hot Keys, Consistent Hashing & Shard Loss** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Build a Sharded Service — Hash Routing, Hot Keys, Consistent Hashing & Shard Loss** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Build a Sharded Service — Hash Routing, Hot Keys, Consistent Hashing & Shard Loss** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.