Software Engineering26 juin 202610 min read

Caching Patterns That Save Your Database

Your database is the most expensive place to answer the same question twice. Here are the caching patterns, Redis configs, and failure modes that separate fast systems from dead ones.

Par Innovation T Team


Your database is the most expensive place in your stack to answer the same question twice. Most scaling incidents we see follow the same script: traffic grows, identical queries pile up, and the primary tips over doing work it already did 200 milliseconds ago. Caching is how you stop paying full price for repeated reads, but a badly designed cache corrupts data quietly and fails loudly, usually at peak traffic.

The real job of a cache

A cache is not "make it faster." A cache is a deliberate decision to serve slightly stale data in exchange for latency and throughput. If you cannot state how stale each cached value is allowed to be, you do not have a caching strategy, you have a bug generator.

Three numbers define every caching decision:

  • Hit ratio. Below roughly 80 percent, the cache is often not paying for its own complexity. In our experience, well designed read caches on real workloads sit in the 95 to 99 percent range.
  • Staleness budget. A product price might tolerate 60 seconds of staleness. An account balance tolerates zero. Write this down per entity before you write any code.
  • Cost of a miss. A miss that triggers a 5 millisecond indexed lookup is fine. A miss that triggers a 900 millisecond aggregation across four tables is a stampede waiting to happen.

Redis is the default tool for this job for a reason: sub-millisecond reads, rich data structures, atomic operations, and battle tested clustering. But the tool matters less than the pattern. Get the pattern wrong and Redis will just help you serve wrong answers faster.

The four core patterns

Cache-aside: the default for a reason

The application owns the logic. On read: check Redis, on miss go to the database, then populate Redis with a TTL. On write: update the database, then delete the cache key. Not update, delete. Deleting forces the next reader to fetch fresh data, which sidesteps a whole class of race conditions where two concurrent writers leave the cache holding the older value.

async function getProduct(id: string): Promise<Product> {
  const cached = await redis.get(`product:${id}`);
  if (cached) return JSON.parse(cached);

  const product = await db.products.findById(id);
  if (product) {
    await redis.set(`product:${id}`, JSON.stringify(product), "EX", 300);
  }
  return product;
}

async function updateProduct(id: string, patch: Partial<Product>) {
  await db.products.update(id, patch);
  await redis.del(`product:${id}`); // delete, never set
}

Tradeoffs: every miss pays a double round trip, and the first request after invalidation is always slow. There is also a narrow race: a reader can fetch stale data from the database, get delayed, then write it to Redis after a writer has already deleted the key. TTLs cap the damage. This is why cache-aside without TTLs is negligence, not optimization.

Read-through: move the logic behind an interface

Same behavior as cache-aside, but the cache layer itself fetches from the database on a miss. The application only ever talks to the cache abstraction. Redis does not do this natively, so you build it as a thin data-access layer or use a library that wraps it. The win is consistency: one place implements TTLs, serialization, and stampede protection, instead of fifteen slightly different copies scattered across services.

Write-through: pay latency for freshness

Every write goes to the cache and the database in the same operation, so reads never see a miss for recently written data. The cost is write latency, since each write now touches two systems, and wasted memory on data that is written but rarely read. Write-through pairs well with read-through, and it fits workloads where read-after-write consistency matters: user profile edits, settings, anything where the user immediately reloads the page and expects to see their change.

Write-behind: fast, and genuinely dangerous

Writes land in Redis, get acknowledged immediately, and a background process flushes them to the database in batches. Throughput is spectacular. So is the failure mode: if Redis loses data before the flush, those writes are gone forever. Use it only for data you can afford to lose, view counters, analytics events, presence signals. Never for orders, payments, or anything a lawyer might ask about. If you want durable async writes, use a real queue and a proper event pipeline instead, which we cover in event-driven architecture.

Invalidation without lying to your users

The two hard problems joke exists because most teams treat invalidation as an afterthought. Three techniques cover almost every real case:

  • TTL as the backstop. Every key gets a TTL, no exceptions. Even "immutable" data gets one, because immutable data has a habit of becoming mutable the week after launch. Add jitter, plus or minus 10 to 20 percent, so a deploy-time cache warm does not expire ten thousand keys in the same second.
  • Explicit invalidation on write. Delete affected keys inside the same code path as the database write. If a write touches an entity that appears in list views, invalidate those too, which leads to the next point.
  • Versioned key namespaces. Instead of hunting down every list and aggregate that includes product 42, keep a version counter: products:v:{n}. Bump the counter on any write, and build read keys as products:list:{n}:{filters}. Old keys become unreachable and expire naturally via TTL. You trade some memory for the guarantee that you never serve a stale list.

What we avoid: pattern-based deletion with KEYS or unbounded SCAN loops in the hot path. KEYS blocks the Redis event loop, and on a busy instance that pause is an outage. If you find yourself needing wildcard invalidation, that is the design telling you to use versioned namespaces.

Stampede protection: the pattern that saves you at 9 a.m.

A cache stampede is what happens when a popular key expires and 3,000 concurrent requests all miss at once, all hit the database with the same expensive query, and all try to rebuild the same key. The database, sized for a 98 percent hit ratio, gets 50 times its normal load in one second. This is the single most common way a cache takes down the database it was supposed to protect.

Three defenses, in order of effort:

Request coalescing. In-process, make concurrent callers share one in-flight promise per key. One database query serves all 3,000 waiters. This is 15 lines of code and every service should have it.

Distributed locks for rebuilds. Across instances, let one worker rebuild while everyone else serves the stale value or waits briefly:

const lock = await redis.set(`lock:${key}`, instanceId, "NX", "EX", 10);
if (lock) {
  const fresh = await rebuildFromDb(key);
  await redis.set(key, serialize(fresh), "EX", ttl);
  await redis.del(`lock:${key}`);
} else {
  return staleValueOrShortWait(key);
}

Probabilistic early expiration. Store the value with its rebuild cost and expiry timestamp, and have each reader occasionally refresh it before it actually expires, with probability increasing as expiry approaches. Popular keys get rebuilt by one early reader instead of stampeded by thousands at the deadline. This is the XFetch approach, and it composes with soft TTLs: serve stale for a grace window while a background refresh runs.

Hot keys, big keys, and other Redis landmines

  • Hot keys. One celebrity profile or one homepage payload can pin a single Redis shard at 100 percent CPU while the rest of the cluster idles. Fixes: replicate the key across N suffixed copies and read randomly, or add a tiny in-process cache (even 1 to 5 seconds of local LRU) in front of Redis for the top of the distribution.
  • Big keys. A 4 MB JSON blob per read saturates network bandwidth long before Redis runs out of CPU. Split large objects into hashes and fetch only needed fields with HMGET, or compress with a fast codec before storing.
  • Serialization tax. JSON parse and stringify on multi-hundred-kilobyte payloads can cost more than the Redis round trip. Measure it. MessagePack or protobuf often cut both size and CPU meaningfully.
  • Missing key penalty. If an entity does not exist, cache that fact too (a short-TTL sentinel), or attackers and buggy clients will hammer your database with lookups for IDs that will never hit.

Redis configuration that actually matters

Defaults are not a strategy. For a pure cache, four settings do most of the work:

maxmemory 6gb
maxmemory-policy allkeys-lfu
lazyfree-lazy-eviction yes
appendonly no
  • maxmemory-policy: allkeys-lfu evicts by access frequency and is the right default for caches. volatile-lru variants only evict keys with TTLs, and if you ever write a key without one, Redis can hit maxmemory and start refusing writes. That failure looks like an application bug and wastes hours.
  • Persistence off for pure caches. RDB snapshots and AOF rewrites cost forks and disk I/O. A cache should be rebuildable from the source of truth. If losing Redis loses data, it was not a cache, and it needs a different design.
  • Separate cache from data. Never mix cache keys and durable data (sessions, queues, counters that matter) in one instance, because the correct eviction and persistence settings for each are opposites.
  • Watch the right metrics. Hit ratio, evicted keys per second, p99 command latency, and connected clients. A falling hit ratio with rising evictions means you are memory starved, and no code change will fix a sizing problem. Wire these into your dashboards the same way you treat any tier-one dependency, as we describe in observability with logs, metrics, and traces.

The decision checklist we run on every project

  1. Profile first. Pull the top 20 queries by total time from pg_stat_statements or your slow query log. Caching query number 40 is effort spent on noise.
  2. Classify each candidate by read/write ratio and staleness budget. Read-heavy plus tolerant of staleness: cache it. Write-heavy or zero staleness budget: fix the query or the schema instead.
  3. Pick the pattern. Cache-aside for most things, write-through where read-after-write matters, write-behind only for loss-tolerant streams.
  4. Define keys and TTLs in one module. Key naming drift (user:1 vs users:1 vs u:1) is how you end up with three stale copies of the same entity.
  5. Add stampede protection before launch, not after the first incident. Coalescing plus a rebuild lock is the minimum.
  6. Decide the degraded mode. When Redis is down, does the app go to the database directly (and can the database survive that), or does it serve errors? Add a circuit breaker so a Redis outage does not become a database outage two minutes later.
  7. Load test the miss path. Flush the cache in staging and replay production traffic. If the system cannot survive a cold cache, you have built a hard dependency, not an optimization.

Step 7 is the one teams skip, and it is the one that matters. A cache with a 99 percent hit ratio hides a database that can only handle 1 percent of your traffic. One bad deploy that changes key names, one FLUSHALL in the wrong terminal, and you find out.

Know when caching is the wrong answer

Caching papers over read pressure. It does not fix a missing index, an N+1 query, or a table that needs partitioning. If your working set is small and your queries are bad, fix the queries: that work compounds, cache hit ratios do not. And when reads outgrow a single primary even with healthy caching, the next moves are replicas, partitioning, and sharding, which we break down in database scaling patterns. Cache-aside plus a read replica covers a very long runway for most products. Know where your runway ends before you reach it.

How Innovation T can help

Innovation T designs and ships this layer for production systems: pattern selection, Redis topology and sizing, stampede protection, invalidation design, and the load tests that prove the miss path survives. We have done it for e-commerce catalogs, SaaS dashboards, and API platforms where the database was weeks from falling over.

If your p99 is climbing or your primary is running hot, talk to us. See our software and cloud engineering services or contact the team for an architecture review.

#caching#Redis#performance#architecture

Prêt à construire avec Innovation T ?

Qu'il s'agisse de sécurité, de croissance ou d'ingénierie, notre équipe peut vous aider à livrer dans les meilleures conditions.