Consistent Hashing for a Cache Tier

Updated · By

Hash each node to many points on a ring, called virtual nodes, and keep the points sorted. A key belongs to the first point at or after its own hash, wrapping around, which a binary search finds in O(log v) for v points. Adding or removing a node moves only the keys in that node's arcs, about 1/n of them.

hardPattern: Binary searchFrom Devana's Microsoft question set

Problem

A cache tier spreads keys across nodes. With hash(key) % n, adding one node changes n and moves almost every key, so the whole cache goes cold at once. Implement consistent hashing instead, so membership changes move as few keys as possible.

Write a class ConsistentHash(nodes, virtual_nodes=150) with get_node(key), add_node(node) and remove_node(node). Explain what fraction of keys moves when a node joins or leaves, and how you keep the load even.

class ConsistentHash:
    def __init__(self, nodes: list[str], virtual_nodes: int = 150): ...
    def get_node(self, key: str) -> str | None: ...
    def add_node(self, node: str) -> None: ...
    def remove_node(self, node: str) -> None: ...

This is one of Devana's own questions, written in the style of Microsoft's interviews. It is not a question leaked from Microsoft, and Devana is not affiliated with Microsoft.

Constraints

  • A position on the ring is the first 32 bits of the MD5 of a label: node#i for virtual node i, the key itself for a key.
  • A key maps to the first virtual node at or after its position, wrapping to the start of the ring.
  • Lookups are O(log v) for v virtual nodes in total.
  • Adding a node may only move keys onto that node; removing one may only move that node's own keys.
  • With no nodes, get_node returns None.

Examples

Example 1

Input:  ring = ConsistentHash(["a", "b", "c"])
        then add_node("d") and look up 1,000 keys again
Output: about a quarter of the keys move, every one of them to d

The new node's points take over arcs of the ring from the others. Keys in those arcs move to d; every other key stays where it was. When we ran this code, 230 of 1,000 keys moved; with modulo hashing, 750 would have.

Example 2

Input:  ConsistentHash(["a", "b", "c"], virtual_nodes=1)
Output: a lopsided split

Three random points cut the ring into arcs of very different lengths. When we ran it on 10,000 keys, one node got 72% of them. With 150 points per node, the busiest of four nodes carried 10% more than its share.

Hints

Open one at a time, and try the problem again after each.

Hint 1

Why does hash(key) % n move almost every key when n changes?

Hint 2

Put nodes and keys on the same circle of hash values. Which node should own a key so that adding a node only affects its neighbors?

Hint 3

Keep the node positions in a sorted array. Finding the first position at or after a key's hash is a binary search.

Hint 4

One point per node gives uneven arcs. What happens to the spread if each node has 150 points?

Approach: from brute force to optimal

Modulo hashing

hash(key) % n is O(1) and spreads keys evenly, but a key stays put only if its hash leaves the same remainder under both node counts. Going from n to n + 1 nodes moves about n/(n + 1) of the keys: three in four going from 3 to 4.

Complexity: O(1) per lookup; about 75% of keys move going from 3 nodes to 4

A sorted ring and a binary search

Place each node at virtual_nodes positions, sort the positions, and remember which node owns each. A key's owner is the first position at or after its hash: bisect_left, with an index past the end wrapping to 0.

A new node only claims the arcs that end at its own positions, so only keys in those arcs move, about 1/(n + 1) of them, and all of them move to the new node. Removing a node hands each of its arcs to the next position clockwise.

Complexity: O(log(n × r)) per lookup for n nodes with r points each; O(n × r) space; O(n × r × log(n × r)) to rebuild on a membership change

How to say it in the interview

Open with the failure of modulo hashing and put a number on it: going from 3 to 4 nodes moves about three keys in four. Then describe the ring, the binary search for a key's owner, and why only the new node's arcs move. Finish with virtual nodes as the fix for balance, and say how you would measure it.

Solution

Python

import hashlib
from bisect import bisect_left

def ring_position(label):
    return int(hashlib.md5(label.encode()).hexdigest()[:8], 16)

class ConsistentHash:
    def __init__(self, nodes, virtual_nodes=150):
        self.virtual_nodes = virtual_nodes
        self.nodes = set(nodes)
        self._rebuild()

    def _rebuild(self):
        ring = sorted(
            (ring_position(f"{node}#{i}"), node)
            for node in self.nodes
            for i in range(self.virtual_nodes)
        )
        self.positions = [position for position, _ in ring]
        self.owners = [node for _, node in ring]

    def add_node(self, node):
        self.nodes.add(node)
        self._rebuild()

    def remove_node(self, node):
        self.nodes.discard(node)
        self._rebuild()

    def get_node(self, key):
        if not self.positions:
            return None
        # The first virtual node at or after the key, wrapping past the end.
        i = bisect_left(self.positions, ring_position(key))
        return self.owners[i % len(self.owners)]

JavaScript (Node.js)

const crypto = require("node:crypto");

function ringPosition(label) {
  return parseInt(crypto.createHash("md5").update(label).digest("hex").slice(0, 8), 16);
}

class ConsistentHash {
  constructor(nodes, virtualNodes = 150) {
    this.virtualNodes = virtualNodes;
    this.nodes = new Set(nodes);
    this.rebuild();
  }

  rebuild() {
    const ring = [];
    for (const node of this.nodes) {
      for (let i = 0; i < this.virtualNodes; i++) ring.push([ringPosition(node + "#" + i), node]);
    }
    ring.sort((p, q) => p[0] - q[0] || (p[1] < q[1] ? -1 : p[1] > q[1] ? 1 : 0));
    this.positions = ring.map(([position]) => position);
    this.owners = ring.map(([, node]) => node);
  }

  addNode(node) {
    this.nodes.add(node);
    this.rebuild();
  }

  removeNode(node) {
    this.nodes.delete(node);
    this.rebuild();
  }

  getNode(key) {
    if (this.positions.length === 0) return null;
    // The first virtual node at or after the key, wrapping past the end.
    const target = ringPosition(key);
    let lo = 0, hi = this.positions.length;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (this.positions[mid] < target) lo = mid + 1;
      else hi = mid;
    }
    return this.owners[lo % this.owners.length];
  }
}

Both versions are tested against the same cases, including every example on this page.

  • Rebuilding the sorted ring costs O(v log v), which is fine because nodes join and leave rarely while lookups happen constantly. For frequent changes, insert and remove positions in place instead.
  • MD5 is used for spread, not security. Any well-mixed hash works, as long as every client uses the same one.
  • Equal positions are rare; when they happen, they are ordered by node name, so every client builds the same ring.

Follow-up questions

How many virtual nodes do you need for acceptable balance, and why that many?

The spread of each node's share shrinks roughly with the square root of its number of points, so going from 10 to 160 points per node cuts the imbalance about four times. Measure it: hash a large sample of real keys and compare the most loaded node with the average.

What happens to the cached data for the keys that moved?

Those keys miss on their new node and are refilled from the source. That is the cost being minimized: about 1/(n + 1) of the cache goes cold instead of nearly all of it. For a planned change, you can warm the new node from the old owners first.

How would you handle nodes with different capacities?

Give each node points in proportion to its capacity. A node with twice the memory gets twice the virtual nodes and so about twice the keys.

Where does the ring live?

Every client builds the same ring from the same membership list, kept in a coordination service, so a lookup needs no extra network hop. A version number on the membership lets a client notice it is out of date.

Common mistakes

  • Using hash(key) % n and calling it consistent hashing.
  • Forgetting the wrap-around, so keys hashing past the last point have no owner.
  • Using Python's built-in hash for strings, which is randomized per process, so two clients disagree about where keys live.
  • One point per node, and assuming the load is even.

Practice it in a mock interview

Reading a solution and explaining one under questioning are different skills. The button below starts this problem as a voice mock interview with Josh, Devana's AI interviewer, with an editor that runs your code.

Practice this problem in a mock interview

Practice this problem out loud

Josh, Devana's AI interviewer, runs this exact problem as a voice mock interview, asks follow-ups like the ones above, and scores you out of 100. The Free plan has 3 interviews a month, no card needed.

Practice this problem in a mock interview