TechLead
Lesson 7 of 30
7 min read
System Design

Consistent Hashing

Learn how consistent hashing works, why it solves the rehashing problem, virtual nodes for balance, and implement it in TypeScript with real-world use cases

The Problem with Basic Hashing

In a distributed system, you often need to assign data to servers. The naive approach is to use a simple hash function: server = hash(key) % number_of_servers. This works well when the number of servers is fixed, but fails catastrophically when servers are added or removed.

Suppose you have 4 servers and 100,000 keys distributed using hash(key) % 4. If you add a fifth server, the formula becomes hash(key) % 5, and the vast majority of keys will map to a different server. For a distributed cache, this means almost every cached item becomes a miss, causing a massive "stampede" to the underlying database.

// The problem with naive modular hashing
function naiveHash(key: string, numServers: number): number {
  let hash = 0;
  for (let i = 0; i < key.length; i++) {
    hash = (hash * 31 + key.charCodeAt(i)) & 0x7fffffff;
  }
  return hash % numServers;
}

// With 4 servers
const server4 = naiveHash('user:123', 4);  // e.g., server 2

// Add a 5th server - almost ALL keys remap!
const server5 = naiveHash('user:123', 5);  // e.g., server 3 (DIFFERENT!)

// With N keys and going from K to K+1 servers,
// approximately (K/(K+1)) * N keys need to move.
// For 4 -> 5 servers: 80% of all keys are remapped!

How Consistent Hashing Works

Consistent hashing, introduced by David Karger et al. in 1997, solves this problem by using a hash ring instead of modular arithmetic. Here is how it works:

  1. Create a hash ring: Imagine the output space of your hash function (e.g., 0 to 2^32-1) arranged in a circle (ring).
  2. Place servers on the ring: Hash each server's identifier (e.g., IP address or name) and place it on the ring at the resulting position.
  3. Place keys on the ring: Hash each data key and place it on the ring.
  4. Assign keys to servers: Each key is assigned to the first server encountered when walking clockwise from the key's position on the ring.

Why This Is Better

When a server is added, only the keys between the new server and the previous server on the ring (counterclockwise) need to be moved. When a server is removed, only its keys need to be reassigned to the next server clockwise. On average, only K/N keys are affected (where K is the total number of keys and N is the number of servers), compared to nearly all keys with modular hashing.

Virtual Nodes (Vnodes)

A basic consistent hash ring has a problem: with a small number of servers, the distribution of keys can be very uneven. One server might be responsible for a much larger arc of the ring than another, receiving disproportionately more data and traffic.

Virtual nodes solve this by mapping each physical server to multiple positions on the ring. Instead of placing "Server A" at one point, you place "Server A - vnode 1", "Server A - vnode 2", "Server A - vnode 3", etc. at different positions. This spreads each server's responsibility across multiple arcs of the ring, leading to a much more even distribution.

  • With 100-200 virtual nodes per physical server, the distribution becomes very even.
  • Virtual nodes also make rebalancing easier: when a new physical server is added, its virtual nodes take over small portions of the ring from multiple existing servers, rather than a single large chunk from one server.
  • Servers with more capacity can be assigned more virtual nodes, giving them a proportionally larger share of the ring.

Full TypeScript Implementation

import crypto from 'crypto';

class ConsistentHash<T> {
  private ring: Map<number, T> = new Map();
  private sortedKeys: number[] = [];
  private virtualNodes: number;

  constructor(virtualNodes: number = 150) {
    this.virtualNodes = virtualNodes;
  }

  // Generate a numeric hash from a string
  private hash(key: string): number {
    const md5 = crypto.createHash('md5').update(key).digest('hex');
    // Use first 8 hex characters for a 32-bit hash
    return parseInt(md5.substring(0, 8), 16);
  }

  // Add a node (server) to the ring
  addNode(node: T): void {
    for (let i = 0; i < this.virtualNodes; i++) {
      const virtualKey = `${String(node)}:vnode${i}`;
      const hashValue = this.hash(virtualKey);
      this.ring.set(hashValue, node);
      this.sortedKeys.push(hashValue);
    }
    // Keep sorted for binary search
    this.sortedKeys.sort((a, b) => a - b);
  }

  // Remove a node from the ring
  removeNode(node: T): void {
    for (let i = 0; i < this.virtualNodes; i++) {
      const virtualKey = `${String(node)}:vnode${i}`;
      const hashValue = this.hash(virtualKey);
      this.ring.delete(hashValue);
      this.sortedKeys = this.sortedKeys.filter(k => k !== hashValue);
    }
  }

  // Find the node responsible for a given key
  getNode(key: string): T | undefined {
    if (this.sortedKeys.length === 0) return undefined;

    const hashValue = this.hash(key);

    // Binary search for the first node clockwise from the hash
    let low = 0;
    let high = this.sortedKeys.length - 1;

    // If hash is beyond the last node, wrap around to the first
    if (hashValue > this.sortedKeys[high]) {
      return this.ring.get(this.sortedKeys[0]);
    }

    while (low < high) {
      const mid = Math.floor((low + high) / 2);
      if (this.sortedKeys[mid] < hashValue) {
        low = mid + 1;
      } else {
        high = mid;
      }
    }

    return this.ring.get(this.sortedKeys[low]);
  }

  // Get N nodes for replication (for when you want to store copies on multiple servers)
  getNodes(key: string, count: number): T[] {
    if (this.sortedKeys.length === 0) return [];

    const nodes: T[] = [];
    const seen = new Set<string>();
    const hashValue = this.hash(key);

    let startIndex = this.sortedKeys.findIndex(k => k >= hashValue);
    if (startIndex === -1) startIndex = 0;

    let index = startIndex;
    while (nodes.length < count && seen.size < this.ring.size) {
      const node = this.ring.get(this.sortedKeys[index])!;
      const nodeKey = String(node);
      if (!seen.has(nodeKey)) {
        nodes.push(node);
        seen.add(nodeKey);
      }
      index = (index + 1) % this.sortedKeys.length;
    }

    return nodes;
  }
}

// Usage example
const ring = new ConsistentHash<string>(150);

// Add cache servers
ring.addNode('cache-server-1');
ring.addNode('cache-server-2');
ring.addNode('cache-server-3');

// Route keys to servers
console.log(ring.getNode('user:1001'));  // -> "cache-server-2"
console.log(ring.getNode('user:1002'));  // -> "cache-server-1"
console.log(ring.getNode('user:1003'));  // -> "cache-server-3"

// Add a new server - only ~1/4 of keys will be remapped
ring.addNode('cache-server-4');

// Get 3 replicas for redundancy
const replicas = ring.getNodes('user:1001', 3);
console.log(replicas); // -> ["cache-server-2", "cache-server-3", "cache-server-1"]

Use Cases for Consistent Hashing

1. Distributed Caches

The original motivation for consistent hashing. Systems like Memcached use consistent hashing in their client libraries to determine which cache server stores each key. When a cache server is added or removed, only a fraction of keys are remapped, preventing a cache stampede.

2. Load Balancers

Some load balancers use consistent hashing to ensure requests from the same client always go to the same backend server (session affinity) without sticky sessions. Nginx supports consistent hashing with its hash directive.

3. Database Sharding

Consistent hashing is used to distribute data across database shards in systems like Apache Cassandra and Amazon DynamoDB. Cassandra uses a consistent hash ring where each node is assigned a range of token values.

4. Content Delivery Networks

CDNs use consistent hashing to determine which edge server caches which content. When an edge server goes down, its content is redistributed to neighboring servers on the ring, minimizing cache misses.

Consistent Hashing in Real Systems

System Usage Virtual Nodes
CassandraData partitioning across nodes256 vnodes per node (default)
DynamoDBPartition assignmentYes (internal)
Memcached (clients)Key-to-server mappingConfigurable (100-200 typical)
RiakData distribution64 vnodes (default)
Nginx (upstream hash)Request routing160 per server

Key Considerations

  • Hash function quality: Use a cryptographic hash (MD5, SHA-1) or a high-quality non-cryptographic hash (MurmurHash, xxHash) for even distribution. Do not use simple hash functions that produce clustered values.
  • Number of virtual nodes: More virtual nodes give better distribution but use more memory. 100-200 is a good starting point. Some systems use 256.
  • Heterogeneous servers: Assign more virtual nodes to servers with greater capacity. A server with 2x the memory should get 2x the virtual nodes.
  • Replication: To replicate data, store copies on the next N distinct physical nodes clockwise from the key on the ring (as shown in the getNodes method above).

Continue Learning