Skip to main content

“Wait… Why Are We Modding by the Number of Nodes?”

A Quick Developer’s Journey to Consistent Hashing


1. The Naive Starting Point

You’re building a distributed cache or key-value store. You need to decide: which node holds which key?

First thought (and it sounds perfectly reasonable):

“We just need relatively balanced buckets… each bucket is one node… so we’ll hash the key and mod by the number of nodes.”

# Naive hashing
def get_node(key: str, nodes: list[str]) -> str:
return nodes[hash(key) % len(nodes)]

Boom, done, right? Keys are evenly spread across all nodes. No central lookup table. No coordination. It feels elegant.


2. The First Pain Point

Everything is fine until one day: a node dies (or you add a new one). Suddenly:

node = hash(key) % (node_count ± 1)

And you realize… Almost every key just moved to a new node.

  • Node A didn’t just get a few new keys.
  • Node B didn’t just lose a small slice.
  • The entire dataset just shuffled like a deck of cards.

Statistically:

  • Adding/removing a node moves ~(N-1)/N of all keys.
  • That’s basically all of them for big clusters.

3. The First Hacky Fix: Buckets

Your inner monologue kicks in:

“Wait, what if each node didn’t equal one bucket? What if we had lots of buckets and just mapped those to nodes?”

Example:

  • Use 500 buckets (a constant).
  • Assign them to your 5 nodes (100 buckets each).
# Fixed number of slots
SLOT_COUNT = 500
slot_to_node = {}

# Assign buckets to nodes (e.g., round-robin)
for slot in range(SLOT_COUNT):
slot_to_node[slot] = nodes[slot % len(nodes)]

def get_node(key: str) -> str:
slot = hash(key) % SLOT_COUNT
return slot_to_node[slot]

Now, when a node fails:

  • You only reassign its buckets.
  • The other buckets (and their keys) stay put.

4. Buckets Need to Be Scattered, Not Clumped

But wait: there’s a subtlety here. If all of Node 1’s buckets are adjacent (say 0–20, 21–40, 41–60, etc.), then when Node 1 fails:

  • All those adjacent buckets move together → effectively one big chunk.
  • You haven’t actually solved the load-balancing problem.

The Fix: Scatter the Buckets

Assign each node multiple randomly distributed buckets across the hash space:

  • Node A: [0–20, 100–120, 300–320]
  • Node B: [20–40, 120–140, 320–340]
  • Node C: [40–60, 140–160, 340–360]

Now, when Node A fails:

  • Its buckets are spread out.
  • All other nodes share the load instead of one unlucky neighbor.

5. The “Ohhh…” Moment: Consistent Hashing

Congratulations, you’ve basically discovered:

  • Consistent Hashing → deterministic key-to-range mapping.
  • Virtual Nodes → fancy term for “a node owns multiple scattered ranges.”

The “hash ring” people draw is just a prettier visualization of this:

  • Nodes (or virtual nodes) are points on the ring.
  • Keys land somewhere on the ring.
  • Each key goes to the next node clockwise.

6. Picking the Constant

One subtle detail:

What do we mod by?

  • In naive hashing, it was node_countbad.
  • In bucketized hashing, it’s a fixed constant (like 500 or 10,000).
  • Nodes can change, but your hash space doesn’t.

That’s why consistent hashing is called consistent:

  • Adding/removing nodes only shifts keys in those affected ranges.
  • Everything else stays consistent.

7. Bonus: Jump Consistent Hash

If you’re feeling fancy, check out Jump Consistent Hash (from Google):

  • No big slot table.
  • O(1) time to compute which node a key belongs to.
  • Still only remaps ~1/N keys when node count changes.

Takeaway

The whole point of consistent hashing is to break free from the naive thought:

“We’ll just mod by the number of nodes.”

Instead:

  • Use a fixed hash space (lots of small buckets).
  • Scatter buckets among nodes.
  • Remap only the affected buckets when nodes change.

That’s how you get scalable, fault-tolerant, load-balanced key distribution.