Module P-7·36 min read

The rebalancing problem, virtual nodes, and exactly how Redis Cluster and Cassandra decide which node owns your key.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module P-7 — Consistent Hashing

What this module covers: Why hash(key) % N makes adding a single cache node an outage-grade event, and what the ring construction replaces it with. We cover the ring itself and the ~K/N movement guarantee, virtual nodes and the two genuinely separate problems they solve, bounded-load variants for a hot key, how Redis Cluster's 16,384 explicit hash slots differ from a pure ring and what happens to clients mid-resharding, how Cassandra's token ranges and vnodes compare, and why a rebalance is really a partial cache flush wearing a topology-change costume. It ends with the honest limit: this technique places keys and does nothing else.


Adding One Cache Node Should Not Be an Incident

The pager reason looks like this. Eleven o'clock on a busy evening, someone adds a node to the memcached tier because the working set has outgrown ten machines. Within thirty seconds the database's CPU is pinned, p99 has gone from 40 ms to eight seconds, and the application is timing out on endpoints that have nothing to do with the feature being launched. Nobody deployed code. Nobody changed a query. Somebody added capacity.

The mechanism is one line of client code that nearly every cache client shipped by default at some point:

ts

Ask what happens to a key when nodes.length goes from 10 to 11. The key keeps its home only if hash(key) % 10 === hash(key) % 11, and for a well-distributed hash the probability of that is roughly 1/11. Which means about 91% of keys now resolve to a node that has never seen them. Not evicted — the data is still sitting in RAM on the old node — just unreachable, because every client is now asking the wrong machine.

That is a near-total cache flush, delivered instantaneously, at peak. Module F-12 already made the point that caching a slow query converts a performance problem into an availability problem; this is the moment the bill arrives. The database is now serving the entire read workload cold, and it was provisioned to serve a small percentage of it.

Generalise the arithmetic, because it is the whole motivation for this module:

ChangeKeys that keep their nodeKeys that move
N = 4 → 5~1/5 (20%)~80%
N = 10 → 11~1/11 (9%)~91%
N = 100 → 101~1/101 (1%)~99%

The property is perverse: the bigger your cluster, the worse a single-node change hurts. Growth makes the operation more dangerous, exactly when you need to do it more often. And it is not only planned additions — a node crashing, a node being restarted for a kernel patch, an autoscaler removing one instance, all of these change N.

What we want instead is a mapping where changing N moves only the keys that have to move: roughly K/N of them, the new node's fair share. That is what consistent hashing provides.


The Ring: Hash Nodes and Keys Into the Same Space

The trick is to stop treating node identity as an index into an array, and start treating it as a value in the same space as the keys.

  1. Pick a hash function with a fixed output range — say 32 or 64 bits. Treat that range as a circle: the largest value wraps around to zero.
  2. Hash each node's identifier ("cache-03:11211") into that circle. Each node is now a point on the ring.
  3. Hash each key into the same circle.
  4. A key belongs to the first node encountered walking clockwise from the key's position.

That is the entire construction. What makes it work is that node positions and key positions are computed independently — a key's position never changes, and a node's position never changes, so the only thing that can reassign a key is a node appearing or disappearing near it.

Analogy: it is a circular corridor of hotel rooms with no numbers, only doorways at fixed positions. Every guest is dropped at a random spot in the corridor and told to walk clockwise until they reach a door. Open a new door halfway along an empty stretch, and the only guests who change rooms are the ones standing in that stretch — everyone else still walks to the same door they always did. Modulo hashing, by contrast, renumbers every room in the building the moment you add one.

Concretely: add node X to a ring of N nodes. X lands somewhere on the circle and takes over the arc between itself and the node counter-clockwise of it. The keys in that arc used to belong to X's clockwise successor. Only that one successor gives up keys, and only about K/(N+1) of them. Every other node is untouched. Remove a node, and only its clockwise successor gains keys.

That "only one neighbour is affected" property is simultaneously the ring's headline feature and, as we will see, its most important defect.

Here is the minimal version, which is worth being able to write from memory:

ts

Lookup is O(log(N × replicas)) — a binary search over a few thousand sorted integers, which is nanoseconds and entirely irrelevant to your latency budget. The cost that does matter is memory and rebuild time: 200 nodes × 160 points is 32,000 entries to sort whenever membership changes, which is fine, and is why nobody rebuilds the ring on the hot path.


Virtual Nodes Fix Two Problems That Have Nothing to Do With Each Other

The naive ring — one point per node — is unusable in practice, and it fails for two reasons that people routinely conflate.

Problem one: with few nodes, the arcs are wildly uneven. Hashing five node names into a circle does not produce five equal arcs. It produces five arcs whose lengths are, statistically, close to exponentially distributed: the longest is typically several times the shortest, and with five nodes it is unremarkable for one node to own 35–40% of the keyspace while another owns 5%. Since ownership share is load share, one machine is at 90% utilisation and paging while another is idle. Module F-4's utilisation curve is unforgiving about that asymmetry — the hot node's queueing delay explodes long before the cluster's average utilisation looks alarming.

Worse, the imbalance is arbitrary and sticky. It is a property of the hash of your hostnames. Renaming a node reshuffles it. You cannot reason about it, only measure it.

Problem two: a failed node dumps its entire load on exactly one survivor. This is the direct consequence of the ring's headline property. Node C dies; every one of C's keys now walks clockwise to D. D's load doubles instantly. If D was running at 60% before, it is now saturated, and if D falls over, its combined load lands on E. That is a cascading failure with a clean mechanism, triggered by a single hardware fault.

Virtual nodes solve both. Instead of hashing each node once, hash it V times with distinct suffixes (cache-03#0cache-03#159) and give every resulting point the same owner. The ring now has N × V points, and:

  • Distribution smooths out. Load imbalance falls roughly as 1/√(N × V). Going from 1 point per node to ~100–200 points per node takes typical worst-node overshoot from "several times the mean" down to single-digit percentages. The libketama scheme that memcached clients standardised on uses 160 points per server for precisely this reason.
  • Failure spreads. A dead node's 160 arcs each hand off to whichever node happens to sit clockwise of that arc — which, with enough points, is nearly all the other nodes in roughly equal measure. A node death now raises everyone's load by 1/(N−1) instead of doubling one machine's. This is the more important of the two benefits and the one usually left out of the explanation.

Problem three, which virtual nodes solve almost by accident: heterogeneous capacity. Real clusters are not uniform. You have three older machines with 32 GB and five newer ones with 128 GB, or you are mid-migration between instance types. With one point per node the ring has no way to express "this machine should take four times the traffic." With virtual nodes it is trivial — give it four times the points.

ts

This is why you should think of virtual nodes as the mechanism and weighting as a first-class feature, not a hack.

The cost, named: virtual nodes multiply ring size, which multiplies the memory the ring occupies in every client and the time to rebuild it on membership change. More significantly, in a replicated store they make replica placement harder. If replication means "the next R distinct nodes clockwise," then with hundreds of vnodes per node the R nodes following a given arc are effectively random — which is good for spread and bad for correlated-failure risk, because random selection will happily place two replicas of the same range in the same rack or availability zone. Systems that care fix this with topology-aware placement (skip nodes in a rack you have already used), and that constraint fights the even-distribution goal. Cassandra's later token-allocation work exists specifically because purely random vnode placement produced uneven replica distribution.

Why this matters in production: the single most common consistent-hashing bug in a homegrown implementation is too few virtual nodes. The system works fine in staging with three nodes because you are not measuring per-node hit ratio, and in production one shard runs 3× hotter than the others forever. Instrument per-node key count and per-node hit ratio from day one; the ring's imbalance is invisible in aggregate metrics.

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.