Database Caching at Scale · Operating Caches in Production

Observing and Testing Caches

Hit ratio per keyspace, latency, evictions, staleness — and tests that catch races before production does.

Scenario

Example company: caches were added over years by different teams. The dashboard shows one number: overall Redis hit ratio, 92%. Incidents keep happening anyway: a key prefix whose hit ratio quietly dropped to 3% after a deploy, a stale-price bug that went unnoticed for weeks.

What you see in production

Postmortems repeatedly find that the cache problem was visible in data nobody looked at. Unit tests mock the cache entirely, so races and invalidation bugs are never tested.

The naive code

def test_get_product(mocker):
    mocker.patch("app.cache.get", return_value=None)    # cache behaviour never exercised
    assert get_product(1).id == 1

Reproduce it

An overall hit ratio can hide a broken keyspace. Compute per-prefix ratios from access counters:

Overall hit ratio hides a broken keyspaceTry it yourself
stats = {          # prefix: (hits, misses) over the last hour
    "product": (9_200_000, 300_000),
    "session": (4_100_000, 20_000),
    "category": (35_000, 1_100_000),      # a deploy changed the key format: nothing hits anymore
}
hits = sum(h for h, _ in stats.values())
total = sum(h + m for h, m in stats.values())
print(f"overall hit ratio: {hits / total:.1%}")
for prefix, (h, m) in stats.items():
    ratio = h / (h + m)
    print(f"  {prefix:9} {ratio:6.1%}  {'<- ALERT' if ratio < 0.5 else ''}")

Output

overall hit ratio: 90.4%
  product    96.8%  
  session    99.5%  
  category    3.1%  <- ALERT

Root cause

Aggregate metrics average away the failures that matter, and caches are usually tested only as "works when mocked". Cache correctness problems are concurrency and time problems — they need instrumentation per keyspace and tests that control interleavings and clocks.

The fix

Instrument per keyspace (prefix): hits, misses, latency, payload size, errors, evictions, and staleness probes for critical data.

def cached(prefix, key, loader, ttl):
    start = time.perf_counter()
    raw = r.get(f"{prefix}:{key}")
    metrics.timing("cache.get.ms", (time.perf_counter() - start) * 1000, tags={"prefix": prefix})
    if raw is not None:
        metrics.increment("cache.hit", tags={"prefix": prefix})
        return json.loads(raw)
    metrics.increment("cache.miss", tags={"prefix": prefix})
    value = loader()
    r.set(f"{prefix}:{key}", json.dumps(value), ex=ttl)
    return value

Dashboards to have: hit ratio per prefix, Redis latency and ops per shard, memory and evictions, database QPS attributed to cache misses, invalidation pipeline lag, staleness probe failures.

Trade-offs

  • Per-key metrics explode cardinality — aggregate by prefix and sample individual hot keys.
  • Real-Redis tests (Testcontainers) are slower than mocks but catch serialization, TTL and script bugs; keep a small, focused set.
  • Staleness probes cost a database read each — sample.

Detect it in production

  • Alerts on per-prefix hit-ratio drops after deploys, on eviction spikes, and on staleness probe failures.
  • A cache section in every postmortem template: which metrics would have shown it earlier?

Key points

  • Measure hit ratio, latency, size and errors per keyspace — not just overall.
  • Probe staleness for critical data continuously.
  • Test caches with real Redis, controlled interleavings and fake clocks.

Practice questions

Why can a 92% overall hit ratio hide a serious caching problem?

Answer

It averages keyspaces with very different traffic; a low-volume prefix can drop to near 0% (e.g. after a key format change) without moving the overall number much.

What is a staleness probe?

Answer

A periodic check that reads a sample of cached values through the normal path and compares them (or their versions) with the database, alerting when they differ beyond the allowed window.

How do you test time- and race-dependent cache behaviour?

Answer

Use real Redis (e.g. Testcontainers) for serialization/TTL/script behaviour, inject a fake clock/ticker for expiry, and control interleavings (pause a reader between steps) to test races.

Open in the app to mark lessons complete, take notes and track your progress.