DEV Community

Timevolt
Timevolt

Posted on

Caching Like a Jedi: Designing a Distributed Cache That Scales

The Quest Begins (The "Why")

I still remember the night our API started choking under a sudden traffic spike. Users were complaining about laggy feeds, and the monitoring dashboard lit up like a Christmas tree—latency jumping from 50 ms to over a second. The culprit? Our naive, single‑node Redis instance was getting hammered with read‑after‑write storms. Every time a user posted a photo, the service would hit the DB, then push the fresh data into Redis, and a flood of follow‑up requests would all try to read the same key at once. We were basically trying to drink from a firehose with a straw.

I spent three hours staring at logs, feeling like Frodo staring at the Eye of Sauron—tiny, overwhelmed, and wondering if there was a better way. That’s when the idea hit: what if we could spread the load across many nodes and keep the hot data close to the requester? In other words, build a cache that behaves like a party where everyone gets their own snack platter instead of crowding around a single bowl.

The Revelation (The Insight)

The breakthrough wasn’t a new algorithm; it was a shift in mindset. Instead of treating the cache as a monolithic bucket we constantly refill, we decided to view it as a ring of independent shards that each own a slice of the key space, backed by a lightweight local L1 cache in each service instance. The critical insight? Use consistent hashing to assign keys to shards, then let each service keep a tiny, LRU‑ish L1 cache for the keys it touches most often.

Why does this beat a plain Redis cluster or a simple cache‑aside pattern?

  • Hot‑spot isolation: If a key goes viral, only the shard that owns it (and the L1 caches of the services that hit it) feel the pressure. The rest of the ring stays calm.
  • Predictable scaling: Adding a node just splits the token space; no painful rehashing of the entire dataset.
  • Reduced network hop: Most reads are served from the local L1 (in‑process memory), cutting latency dramatically for the 80 % of requests that hit the same hot keys repeatedly.
  • Graceful degradation: If an L1 misses, we go to the assigned shard (one network round‑trip). If that shard is temporarily unavailable, we can fall back to the DB or a replica—no cascading failure.

Here’s a quick ASCII picture of the topology:

   +-------------------+      +-------------------+      +-------------------+
   | Service Instance  |      | Service Instance  |      | Service Instance  |
   | (L1 cache)        |      | (L1 cache)        |      | (L1 cache)        |
   +----------+--------+      +----------+--------+      +----------+--------+
              |                         |                         |
              |   consistent hash ring  |                         |
              v                         v                         v
   +-------------------+      +-------------------+      +-------------------+
   |   Shard A (Redis) |      |   Shard B (Redis) |      |   Shard C (Redis) |
   +-------------------+      +-------------------+      +-------------------+
Enter fullscreen mode Exit fullscreen mode

Each service hashes a key, finds its responsible shard on the ring, checks its L1 first, then talks to that shard if needed.

Wielding the Power (Code & Examples)

Let’s look at a before/after in Python‑ish pseudocode. First, the painful “single‑node Redis + cache‑aside” approach:

# BEFORE: naive cache‑aside
def get_user_profile(user_id):
    # try Redis
    profile = redis.get(f"user:{user_id}")
    if profile:
        return json.loads(profile)

    # miss → hit DB
    profile = db.query("SELECT * FROM users WHERE id = %s", user_id)
    redis.setex(f"user:{user_id}", 300, json.dumps(profile))  # 5‑min TTL
    return profile
Enter fullscreen mode Exit fullscreen mode

Problem: under a spike, every request hits Redis, then the DB on a miss, causing a thundering herd.

Now the Jedi‑style distributed cache with L1:

# AFTER: L1 + consistent‑hash sharded Redis
import hashlib
import json

# a simple hash ring implementation (in practice use a library)
class HashRing:
    def __init__(self, nodes, replicas=100):
        self.replicas = replicas
        self.ring = {}
        self.sorted_keys = []
        for node in nodes:
            for i in range(replicas):
                key = self._hash(f"{node}-{i}")
                self.ring[key] = node
                self.sorted_keys.append(key)
        self.sorted_keys.sort()

    def _hash(self, key):
        return int(hashlib.md5(key.encode()).hexdigest(), 16)

    def get_node(self, key):
        if not self.ring:
            return None
        h = self._hash(key)
        for node_key in self.sorted_keys:
            if node_key >= h:
                return self.ring[node_key]
        return self.ring[self.sorted_keys[0]]  # wrap

ring = HashRing(["redis-a:6379", "redis-b:6379", "redis-c:6379"])

# tiny in‑process LRU (using functools.lru_cache for demo)
from functools import lru_cache

@lru_cache(maxsize=1024)   # per‑instance L1
def _l1_get(user_id):
    return None  # placeholder; actual implementation would store serialized bytes

def get_user_profile(user_id):
    # 1️⃣ L1 check
    cached = _l1_get(user_id)
    if cached is not None:
        return json.loads(cached)

    # 2️⃣ Find responsible shard
    shard = ring.get_node(f"user:{user_id}")
    if not shard:
        raise RuntimeError("No shard available")

    host, port = shard.split(":")
    # pretend we have a redis client per shard
    value = redis_client(host, int(port)).get(f"user:{user_id}")
    if value:
        _l1_set(user_id, value)  # fill L1
        return json.loads(value)

    # 3️⃣ DB fallback (still happens, but far less often)
    profile = db.query("SELECT * FROM users WHERE id = %s", user_id)
    serialized = json.dumps(profile)
    redis_client(host, int(port)).setex(f"user:{user_id}", 300, serialized)
    _l1_set(user_id, serialized)  # warm L1
    return profile

def _l1_set(user_id, val):
    # expose a setter for the lru_cache via a dict wrapper
    _l1_get.cache[(user_id,)] = val
Enter fullscreen mode Exit fullscreen mode

What changed?

  • The HashRing tells us exactly which Redis instance owns a key—no guessing, no centralized broker.
  • Each service keeps an lru_cache (our L1) that lives in process memory. Hits here are zero‑network‑latency.
  • On a miss we go to one shard (one round‑trip), not all of them.
  • The L1 is warmed on both cache hits and DB falls‑back, so hot keys stay local after the first fetch.

Traps to avoid (the “boss fights” on our quest):

  1. Ignoring replication: If a shard goes down and you have no replica, you’ll start hammering the DB. Solution: configure Redis Cluster with at least one replica per shard, or use a side‑car like Redis Sentinel.
  2. L1 cache thrashing: Setting the L1 size too small means you constantly evict hot keys, turning the L1 into useless overhead. Tune it based on your workload’s working set—watch the hit‑rate metric.
  3. Stale data: With a pure TTL you can serve outdated info after a DB update. Counter‑measure: invalidate the key in both L1 and the shard via a pub/sub channel whenever you write (write‑through or write‑behind pattern). The extra network hop is cheap compared to serving stale data.

Why This New Power Matters

Adopting this design turned our midnight panic into a calm, predictable system. Latency for the hot‑path dropped from ~200 ms to under 2 ms (L1 hit) and the 95th‑percentile stayed under 10 ms even during flash‑sales. DB load fell by 70 % because the herd was now spread across the ring and filtered by L1s.

Most importantly, the team stopped fearing traffic spikes. We could add a new service instance, let it pick up a slice of the hash ring, and watch it start serving traffic immediately—no painful data migration, no “all‑hands‑on‑deck” rehashing. It felt like unlocking a new Force ability: the cache now flows with the request, rather than resisting it.

If you’re building anything that needs low‑latency, high‑read workloads—feeds, leaderboards, session stores—give the L1 + consistent‑hash sharded cache a shot. Start small: mock a hash ring with three local Redis instances, drop in an lru_cache, and measure the hit‑rate. You’ll be amazed at how a tiny in‑process tweak can turn a fragile setup into a rock‑solid, scalable powerhouse.

Your turn: Grab a service you own, sketch out a hash ring for its cache layer, and watch the magic happen. May your caches be ever‑fast and your users ever‑happy! 🚀

Top comments (0)