Design a Distributed Cache

Difficulty: Advanced

Question

Design a distributed caching system like Redis Cluster or Memcached. How would you handle data partitioning, replication, and eviction?

Answer

A distributed cache stores data across multiple nodes to provide fast access, horizontal scalability, and fault tolerance.

Core design decisions: 1. Partitioning: How to distribute keys across nodes (consistent hashing) 2. Replication: How many copies of each key (primary + replicas) 3. Eviction: What to remove when memory is full (LRU, LFU, TTL) 4. Consistency: How to keep replicas in sync (async replication, quorum reads) 5. Failure handling: What happens when a node dies (failover, data redistribution)

Redis Cluster approach: - 16384 hash slots distributed across nodes - Each key maps to a slot: CRC16(key) mod 16384 - Each slot has a primary and one or more replicas - Automatic failover: replica promoted to primary on node failure

Code examples

Distributed Cache Core

// Distributed cache node
class CacheNode {
  constructor(maxMemory = 1024 * 1024 * 1024) { // 1GB default
    this.store = new Map();
    this.maxMemory = maxMemory;
    this.usedMemory = 0;
    this.accessOrder = new Map(); // For LRU tracking
  }

  get(key) {
    const entry = this.store.get(key);
    if (!entry) return null;

    // Check TTL
    if (entry.expiresAt && entry.expiresAt < Date.now()) {
      this.delete(key);
      return null;
    }

    // Update LRU access time
    this.accessOrder.set(key, Date.now());
    return entry.value;
  }

  set(key, value, ttlSeconds) {
    const size = this.estimateSize(key, value);

    // Evict if necessary
    while (this.usedMemory + size > this.maxMemory) {
      this.evictLRU();
    }

    const entry = {
      value,
      size,
      expiresAt: ttlSeconds ? Date.now() + ttlSeconds * 1000 : null,
      createdAt: Date.now()
    };

    // Update memory tracking
    if (this.store.has(key)) {
      this.usedMemory -= this.store.get(key).size;
    }
    this.store.set(key, entry);
    this.usedMemory += size;
    this.accessOrder.set(key, Date.now());

    return true;
  }

  delete(key) {
    const entry = this.store.get(key);
    if (entry) {
      this.usedMemory -= entry.size;
      this.store.delete(key);
      this.accessOrder.delete(key);
    }
  }

  evictLRU() {
    let oldestKey = null;
    let oldestTime = Infinity;

    for (const [key, time] of this.accessOrder) {
      if (time < oldestTime) {
        oldestTime = time;
        oldestKey = key;
      }
    }

    if (oldestKey) this.delete(oldestKey);
  }

  estimateSize(key, value) {
    return key.length + JSON.stringify(value).length;
  }
}

Each cache node manages its own memory with LRU eviction. When memory is full, the least recently accessed entry is removed. TTL-based expiry runs lazily on access (no background scanner needed for simple cases).

Cache Cluster with Partitioning

// Distributed cache cluster using consistent hashing
class CacheCluster {
  constructor() {
    this.hashRing = new ConsistentHash(150); // 150 virtual nodes
    this.nodes = new Map(); // nodeId -> CacheNode
    this.replicationFactor = 2; // primary + 1 replica
  }

  addNode(nodeId, node) {
    this.nodes.set(nodeId, node);
    this.hashRing.addServer(nodeId);
  }

  removeNode(nodeId) {
    this.hashRing.removeServer(nodeId);
    this.nodes.delete(nodeId);
    // Keys naturally redistribute to remaining nodes
    // on next cache miss + refill
  }

  // Get the primary and replica nodes for a key
  getNodesForKey(key) {
    const allNodes = this.hashRing.getServerList(key, this.replicationFactor);
    return {
      primary: allNodes[0],
      replicas: allNodes.slice(1)
    };
  }

  async get(key) {
    const { primary } = this.getNodesForKey(key);
    const node = this.nodes.get(primary);
    return node.get(key);
  }

  async set(key, value, ttl) {
    const { primary, replicas } = this.getNodesForKey(key);

    // Write to primary
    const primaryNode = this.nodes.get(primary);
    primaryNode.set(key, value, ttl);

    // Async replication to replicas
    for (const replicaId of replicas) {
      const replicaNode = this.nodes.get(replicaId);
      if (replicaNode) {
        replicaNode.set(key, value, ttl); // Fire and forget
      }
    }

    return true;
  }

  async delete(key) {
    const { primary, replicas } = this.getNodesForKey(key);
    this.nodes.get(primary)?.delete(key);
    for (const replicaId of replicas) {
      this.nodes.get(replicaId)?.delete(key);
    }
  }
}

// Usage
const cluster = new CacheCluster();
cluster.addNode('node-1', new CacheNode());
cluster.addNode('node-2', new CacheNode());
cluster.addNode('node-3', new CacheNode());

await cluster.set('user:123', userData, 300);
const user = await cluster.get('user:123');

The cluster uses consistent hashing to distribute keys across nodes. Each key is replicated to multiple nodes for fault tolerance. When a node fails, its keys are still available on replicas.

Cache Warming & Monitoring

// Cache warming: pre-populate cache on deployment
class CacheWarmer {
  async warmUp(cluster) {
    console.log('Warming cache...');

    // 1. Load hot keys from access logs
    const hotKeys = await db.query(`
      SELECT cache_key, access_count
      FROM cache_access_log
      WHERE timestamp > NOW() - INTERVAL '1 hour'
      GROUP BY cache_key
      ORDER BY SUM(access_count) DESC
      LIMIT 10000
    `);

    // 2. Pre-populate cache
    for (const { cache_key } of hotKeys) {
      const value = await fetchFromSource(cache_key);
      if (value) {
        await cluster.set(cache_key, value, 3600);
      }
    }

    console.log(`Warmed ${hotKeys.length} keys`);
  }
}

// Monitoring metrics
class CacheMonitor {
  constructor(cluster) {
    this.hits = 0;
    this.misses = 0;
    this.evictions = 0;
  }

  recordHit()  { this.hits++; }
  recordMiss() { this.misses++; }
  recordEviction() { this.evictions++; }

  getStats() {
    const total = this.hits + this.misses;
    return {
      hitRate: total > 0 ? (this.hits / total * 100).toFixed(2) + '%' : 'N/A',
      missRate: total > 0 ? (this.misses / total * 100).toFixed(2) + '%' : 'N/A',
      totalRequests: total,
      evictions: this.evictions,
      memoryUsage: this.getMemoryUsage()
    };
  }

  getMemoryUsage() {
    let total = 0;
    let used = 0;
    for (const node of cluster.nodes.values()) {
      total += node.maxMemory;
      used += node.usedMemory;
    }
    return {
      used: `${(used / 1e9).toFixed(2)}GB`,
      total: `${(total / 1e9).toFixed(2)}GB`,
      percentage: `${(used / total * 100).toFixed(1)}%`
    };
  }
}

// Key metrics to alert on:
// - Hit rate < 80% -> cache is not effective
// - Memory > 90% -> risk of excessive evictions
// - Eviction rate spike -> possible cache thrashing
// - Latency p99 > 10ms -> network or memory issues

Cache warming pre-loads frequently accessed data after a deployment or node restart, avoiding a thundering herd of cache misses. Monitoring hit rate, memory usage, and eviction rate ensures the cache is performing effectively.

Key points

Concepts covered

Distributed Cache, Consistent Hashing, Eviction Policies, Cache Coherence, Redis Cluster