KyoMel / Yogyakarta, Indonesia

Term 334334 / 344

Consistent Hashing

Month
October 2026
Day
2
Source
2026/oct-term.md

Definition:

Consistent hashing maps each key to a node without a global lookup table. It uses one rule: hash the keys and the nodes onto the same circle, then send each key to the first node clockwise from the key. The circle is the range [0, 2^32) or [0, 2^64).

The design goal is churn control. When one node joins or leaves, only the keys that belong to that node move. Every other key stays on its node. That single property explains why the technique sits under caches, sharded databases, CDNs, and LLM routers.

The naive alternative is modulo hashing: slot = h(key) mod N. It is trivial and it breaks on membership change. Change N and almost every key lands on a different node. Every local cache entry, session binding, and warm connection dies at the same time.

MODULO HASHING vs CONSISTENT HASHING          (4 nodes, then add 1 node)
═══════════════════════════════════════      ═════════════════════════════════════
 slot = h(key) mod N                          nodes AND keys share one circle
 N lives inside every client                  key → first node clockwise

   slots from 4 nodes                         4 points on the ring, 0 .. 2^32
   ┌─────┬─────┬─────┬─────┐                       B
   │  0  │  1  │  2  │  3  │                     ╱   ╲
   └─────┴─────┴─────┴─────┘                   A       C     key u_104 hashes
    ▲     ▲     ▲     ▲                        ╲       ╱     to the tick mark,
    │     │     │     │                         D  ◀──╯      so it goes to D.
   3 keys 5 keys 4 keys 8 keys
   (evenly by luck, never by                       ┌──────────────┐
   design)                                         │  A─B─C─D     │
                                                   │  clockwise   │
   ADD node 4 → slot = h(k) mod 5                  └──────────────┘

   re-check all 20 keys                         ADD node E on the D→A arc

   key   h(k)  mod 5   was  now                    D ─── A   becomes  D ─ E ─ A
   ───────────────────────────
   u_101  55     0     D    A  ✗ moved              only the keys inside the
   u_102  56     1     A    B  ✗ moved              D→A arc move to E.
   u_103  57     2     B    C  ✗ moved
   u_104  58     3     C    D  ✗ moved              no other key moves.
   ... 80% of all keys move                         at most 1/4 of keys move
                                                    here, and the rest are
   ~every cache entry is now cold                   untouched.

The repair is a hash ring with virtual nodes. A single point per node gives a node a random and often huge arc. Many points per node (16, 100, 256 — the number is a tuning knob) chop the ring into small arcs and drive the load error down.

WHY ONE POINT PER NODE IS NOT ENOUGH
═══════════════════════════════════════════════════════════════════
 ONE POINT PER NODE                   MANY POINTS PER NODE

 arcs are random and lopsided         each node owns many small arcs

   A ████████████████████  61%          A ██████  26%
   B ██                     8%          B ██████  25%
   C ▪                      2%          C █████   24%
   D █████████             29%          D ██████  25%

   A melts. C idles.                    Load spread is even. A node
   Removing C moves 61% of keys.        that leaves moves its ~1/N only.

The ring is old and boring in the best way. Karger and his co-authors published it in 1997 for web caches. Amazon’s Dynamo paper used it in 2007 with virtual nodes. Cassandra, Riak, Chord, Voldemort, and the memcached client libketama shipped it. Envoy’s ring_hash load balancer pads a ring to a minimum size (1024 entries by default). Cassandra turns virtual nodes on by default (num_tokens, 16 in recent releases, 256 in older ones). The hash is usually xxHash or MurmurHash, not MD5, because the router must stay cheap.

One honest boundary: the ring balances keys, not load. Hash output is uniform, but key popularity is not. One hot key still lands on one node, and that node still overloads.

THE FAMILY — HOW A KEY PICKS ITS NODE
═══════════════════════════════════════════════════════════════════════════
                    needs a sorted   minimal      best use
                    ring?            movement?
 ─────────────────────────────────────────────────────────────────────────
 Modulo hashing     no               no           fixed pool, no resize
                                                   (e.g. 16384 fixed slots in
                                                   Redis Cluster)
 Consistent         yes              yes          caches, DHTs, sharded
 hashing                                           stores, sticky routing
 Rendezvous / HRW   no               yes          pick max(h(key,node)).
 hashing (1998)                                    O(N) per lookup, no ring
                                                   to build or store
 Jump hash (2014)   no               yes          constant memory, but
                                                   numbered buckets only
 Bounded-load CH    yes              yes          ring plus a capacity rule.
 (2018)                                            Kills hot spots. Adds a
                                                   clockwise walk
 ─────────────────────────────────────────────────────────────────────────
 Rule of thumb: any balanced scheme must move about 1/N of the keys when one
 node joins N nodes. The ring reaches that floor and adds no coordination.

Consistent hashing with bounded loads (CHWBL) closes the hot-spot gap. Mirrokni, Thorup, and Zadimoghaddam proposed it in 2018. Each node gets a hard capacity near the mean:

capacity = ceil( c * m / n ),   c = 1 + ε > 1

A key that finds its primary node full walks clockwise to the first node with room. Now placement is still sticky, but no node passes the cap. The known cost is cascaded overflow: when several nodes in a row are full, all their spill lands on the next free node, and that node fills faster. Later work fixes the cascade with random jumps instead of a clockwise walk.

Honest limits:

  • Balance of keys is not balance of load. Skew in key popularity, request size, or per-node cost defeats a perfect ring. Measure load per node, not key count.
  • Bounded loads trade cache for safety. A spilled request lands on a node with a cold cache. That is a real cost. It is the central tension in LLM serving (see Example).
  • Every client must see the same ring version. A stale ring sends a key to the wrong node. Version the ring and publish it atomically.
  • The ring does not resize cheaply in every design. Rebuilding a few thousand vnode points is cheap. Rebuilding a full lookup table, as Maglev does, is a global operation.
  • A repartition is not free. Removing a node does load its successor with the whole departed arc until data moves or the cache refills.

Example:

A platform serves a 70B model on 24 vLLM replicas behind an LLM gateway. Agent workloads share long prefixes: the same 2,400-token system prompt, the same tool schemas, and a per-tenant LoRA adapter. vLLM keeps the prefill work of those prefixes in a KV cache (prefix caching).

Round-robin routing destroys that reuse. The prefix lands on R7, then R2, then R9, then R7 again. Each replica holds only a small piece of the prefix, KV blocks get evicted under pressure, and the shared 2,400 tokens are prefilled again and again. Time to first token (TTFT) stays high and unstable.

The fix is to route on the prefix instead of a random id, and to keep a load cap so affinity does not create a hot replica.

THE ROUTER — PREFIX AFFINITY WITH A LOAD CAP
═══════════════════════════════════════════════════════════════════════
 request: prefix P (2,400 tokens) + adapter "bantukode"

   h(P + adapter) ─────────────────────▶ position on the ring
                                              │
                                              ▼
                                        primary replica R3
                                        ┌──────────────────────┐
                                        │ load = 178% of cap   │
                                        └──────────┬───────────┘
                                                   │  cap rule:
                                                   │  walk clockwise
                                                   ▼
                                        R4  load = 91%  ──▶  serve here
                                                             (prefix cold,
                                                              extra prefill,
                                                              but no overload)
 rules in one config file:
   strategy        PrefixHash          (hash prefix + adapter name)
   meanLoadFactor  125                 (cap = 1.25 × mean load)
   replication     256                 (vnode points per replica)

Before and after, on the same fleet:

RANDOM ROUTING                       RING HASH + 1.25 LOAD CAP
══════════════════════════════       ══════════════════════════════════════
 prefix hits a different replica      prefix always maps to one replica,
 on almost every request              and spills only when that replica is
                                      above the cap
 KV hit rate      ~64%                KV hit rate      ~93%
 TTFT at 1,200    baseline            TTFT at 1,200    ~95% lower
 concurrent reqs                      throughput        ~127% higher
 at saturation    baseline            capacity          ~2.3× at a 3.5s p99

The last row is not a toy. A 2026 study (CacheRoute) reports 93.2% served KV hit and 2.3× the capacity of the strongest baseline on 60 H100 GPUs with Llama-3.3-70B in FP8. KubeAI’s PrefixHash benchmark reports the 95% TTFT cut and the 127% throughput gain at 1,200 concurrent requests.

The same study also publishes the counterexample, and it matters more than the win. On two 32B workloads, the recoverable prefix work was too small. Affinity then bought almost no KV reuse, while its residual load skew stayed. Capacity fell instead of rising. So the rule is not “always pin keys”. The rule is “pin keys only where the workload gives the pin something back”.

Six rules carry the pattern:

  • Hash the thing you want to keep together. Session id, tenant id, prefix, or shard key. Never a random request id.
  • Freeze the hash input. Add a version tag to the key. One silent change to the key format invalidates the whole cache on deploy.
  • Use virtual nodes. A ring with one point per node behaves worse than random. Start at 100 to 256 points.
  • Add a load cap. Pure affinity converts key skew into hot spots. A cap near 1.25× mean load keeps the ring useful at saturation.
  • Watch the right signals. Served cache hit rate, per-node imbalance, and p99. A drop in a single node’s hit rate shows a ring bug early.
  • Test on your own workload. Published speedups belong to the workload that produced them. Replay your own traffic before you enable affinity.

The punchline: consistent hashing is not a load balancer. It is a placement function with a minimal-movement guarantee. It keeps a key on the same node across membership changes, and it costs one hash and one binary search. Everything else — the vnode count, the load cap, the spill rule — is a knob you have to set against real traffic. Get the key right, keep the ring identical on every client, and put a ceiling on each node. Then adding the 25th replica is a boring event, which is the entire point.

Keep reading