Cache Hit Rate Drops After Adding a Node: Consistent Hashing

Caching · Intermediate · 7 min read · published

This article was written by Claude (Anthropic) and published automatically. Published by Osama Fayyaz.

What this solves: You add one cache server and hit rate collapses while the database melts. See why hash(key) % N remaps most keys, and how a hash ring moves only a small slice.

The Forces at Play

You add one cache node to handle growth and your cache hit rate drops from 95% to near zero. Database CPU spikes and p99 latency explodes. This happens precisely because you added capacity.

The culprit is almost always the innocent-looking routing line node = nodes[hash(key) % len(nodes)].

Modulo routing is perfect while N is fixed. Change N and the mapping of nearly every key changes with it. Going from 4 to 5 nodes, a key stays put only when h % 4 == h % 5. That holds for 4 out of every 20 hash values, so 80% of keys move. Going from 10 to 11 nodes moves about 91%.

A moved key is a cold miss. Every one of those misses becomes a database read at the same moment.

So you are caught between competing pressures:

Consistent hashing gives you all four with a cheap, client-side calculation.

The Shape

The pattern works like this:

flowchart LR
    subgraph Client["App process (every instance runs the same code)"]
        K["key: user:42"] --> H["stable hash<br/>xxhash64 -> 0x7A1F..."]
        H --> L["binary search sorted token list<br/>first token >= 0x7A1F"]
        M["membership source<br/>config / service discovery"] --> R["ring builder<br/>150 vnode tokens per node"]
        R --> T["sorted token table<br/>0x11..->A#7, 0x3C..->C#2,<br/>0x7B..->B#91, 0xA0..->A#33 ..."]
        T --> L
    end
    L -->|"token 0x7B.. = B#91"| B[("Cache node B")]
    L -.-> A[("Cache node A")]
    L -.-> C[("Cache node C")]
    D[("New node D")] -. "joins: inserts 150 tokens,<br/>each steals a thin arc from a neighbour" .-> T

The important point is that all intelligence lives in the client. Cache nodes are ordinary Redis or memcached servers with no idea a ring exists.

The ring is just a sorted array of (token, node) pairs. Every app instance can rebuild it independently, as long as each one sees the same membership list and uses the same hash function.

How Data Flows Through It

Here is one read of user:42, followed by a scale-up.

  1. Startup. Service discovery returns nodes [A, B, C]. The ring builder hashes "A#0" through "A#149", and the same for B and C. It then sorts all 450 tokens.
  2. Lookup. xxhash64("user:42") returns 0x7A1F.... A binary search finds the next token, 0x7B02..., which is labelled B#91. The client sends GET user:42 to node B. This costs O(log V) and needs no network hop.
  3. Miss path. On a miss, the client reads from the database and writes the value back to B.
  4. Scale-up. Node D appears in discovery, and every client rebuilds its ring with 600 tokens. D's 150 tokens land in random spots. Each token claims only the thin arc between itself and the previous token.
  5. Effect. Roughly 1/4 of keys now resolve to D, taken evenly from A, B and C. The other ~75% resolve exactly where they did before. user:42 most likely still maps to B and stays a hit.

Compare that with modulo routing, where the same change would have moved about 75% of keys onto different nodes.

A minimal ring:

import bisect, hashlib

def h(s: str) -> int:  # stable across processes and languages
    return int.from_bytes(hashlib.blake2b(s.encode(), digest_size=8).digest(), "big")

class Ring:
    def __init__(self, nodes, vnodes=150):
        pairs = sorted((h(f"{n}#{i}"), n) for n in sorted(nodes) for i in range(vnodes))
        self.tokens = [t for t, _ in pairs]
        self.owners = [n for _, n in pairs]

    def node_for(self, key: str) -> str:
        i = bisect.bisect_left(self.tokens, h(key)) % len(self.tokens)  # wrap around
        return self.owners[i]

What Each Piece Owns

The design also leaves some things out on purpose. Nobody moves data when membership changes. Moved keys simply miss once and refill. That is fine for a cache, and it is precisely why a cache is the ideal fit for this pattern.

Where It Breaks Down

Too few virtual nodes.

Clients that disagree about membership.

Flapping nodes.

Hot keys.

Operational burden.

When This Is Overkill

A single cache node. If one node with a replica fits your working set, skip sharding entirely. Most apps with a few GB of hot data live happily here.

Redis Cluster or a managed equivalent. These already solve the problem server-side with 16,384 fixed hash slots. Resharding moves slots, and smart clients follow MOVED redirects. Don't layer your own ring on top.

Fixed fleets. If the node count never changes, plain modulo is fine. Its only weakness is resizing.

Simpler alternatives. If you do need client-side sharding, rendezvous (HRW) hashing gives the same minimal disruption with no token table: score each node with hash(key, node) and pick the highest. It is ideal for under ~50 nodes. Jump consistent hash is even leaner, but it only supports adding or removing nodes at the end of the list.

The signal you've outgrown modulo. Hit rate craters and database load spikes every time you scale, deploy a node change or lose a server. Add the ring at that point, before autoscaling makes resizes routine.

Key takeaway: If clients pick a cache node with hash(key) % N, every resize remaps almost every key. Use a ring with 100–200 virtual nodes per server and a stable, process-independent hash, so a resize moves only about 1/N of keys.

Real-world challenge

A Python API with 8 gunicorn workers per pod shards session data across 6 Redis nodes using a home-grown consistent hash ring. Redis node metrics look healthy and evenly loaded. Even so, the app-level cache hit rate sits around 15%, and it falls further whenever pods restart. Every node holds roughly 6x more keys than there are active sessions. Nobody changed the node list. What is going on, and how do you fix it?

Diagnosis

The tell is that the nodes hold about 6x more keys than there are sessions, and the load is still even. The same logical key is being written to different nodes by different processes. In other words, the processes disagree about the ring.

The node list is identical everywhere, so the disagreement has to come from the hash function. The ring code uses Python's built-in hash(). For str, that hash is salted per process via PYTHONHASHSEED. This has two effects:

Restarts re-roll the salt, which is why the hit rate drops further after a deploy.

Fix

Use a stable, deterministic hash for both tokens and keys:

import hashlib

def stable_hash(s: str) -> int:
    return int.from_bytes(hashlib.blake2b(s.encode(), digest_size=8).digest(), "big")

Then: