Databases

Redis Caching Patterns That Actually Work Under Production Load

Learn practical Redis caching patterns: cache-aside, write-through, write-behind, TTLs, eviction policy, null caching, and hot key defense.

TechViz Hub

Redis caching layer architecture between application and database

Why You Need a Caching Layer

Your PostgreSQL database can handle roughly 5,000–10,000 queries per second on modern hardware. Your application serves 50,000 reads per second. Without a cache, every read hits the database — parsing SQL, acquiring locks, reading disk blocks, and returning results.

Response times climb from 2ms to 200ms. Connection pools exhaust. The database locks up. Users see spinners.

Redis solves this by storing frequently accessed query results in memory. Reads that took 5–20ms from the database now take 0.1–0.5ms from Redis.

But introducing a cache creates a new set of problems: stale data, cache misses under load, memory pressure, and catastrophic failure modes that can take down the very database you’re trying to protect.

My rule of thumb Start with cache-aside and jittered TTLs unless you have a concrete consistency reason not to. Most teams need fewer clever cache patterns than they think.

The mental model: Redis Caching

Cache-Aside (Lazy Loading)

The most common pattern. The application checks Redis first. On a cache hit, the data is returned directly. On a cache miss, the application queries the database, writes the result to Redis with a TTL, and returns the response.

GET user:1234 → Redis HIT → return cached JSON
GET user:5678 → Redis MISS → SELECT * FROM users WHERE id=5678
                            → SET user:5678 <json> EX 300
                            → return JSON

Write-Through

Every database write also writes to Redis synchronously. Reads always hit Redis. This guarantees cache consistency but increases write latency — every INSERT or UPDATE must complete both the database write and the Redis SET before returning to the client.

Write-Behind (Write-Back)

The application writes to Redis first, and a background process asynchronously syncs changes to the database. This offers the lowest write latency but risks data loss if Redis crashes before the sync completes.

Decisions that matter

  • TTL (Time-to-Live): 30 seconds to 24 hours depending on data volatility. User profiles: 5–15 minutes. Product catalog: 1–6 hours. Feature flags: 30–60 seconds.
  • maxmemory-policy: Determines what happens when Redis runs out of memory. volatile-lru evicts keys with TTLs (least recently used first). allkeys-lru evicts any key. noeviction returns errors on writes.
  • Serialization format: JSON is debuggable but large. MessagePack or Protobuf are 40–60% smaller but opaque. Choose based on debugging needs vs. memory cost.
  • Key naming convention: Use {entity}:{id}:{field} patterns (e.g., user:1234:profile) for clarity and SCAN compatibility.
  • Connection pool size: Match your application thread count. Too few connections → thread blocking. Too many → Redis maxclients exhaustion.

Where it breaks: Cache Penetration

The failure mode: Malicious or buggy requests query keys that will never exist in the cache (e.g., user:-1, product:99999999). Every request is a cache miss, bypassing Redis entirely and slamming the database with queries that return empty results.

What you will notice:

  • Cache hit ratio drops below 50% despite high traffic
  • redis-cli INFO stats shows keyspace_misses climbing much faster than keyspace_hits
  • Database CPU spikes on queries that return zero rows
  • Application logs show high volume of requests for non-existent IDs

What I would do:

  • Cache null results: Store SET user:99999999 null EX 60 so subsequent requests for the same ID hit the cache instead of the database. Use short TTLs to avoid permanently caching “not found.”
  • Bloom filter gate: Before querying Redis or the database, check a Bloom filter that tracks all valid IDs. If the ID is definitely not in the set, return 404 immediately.
  • Input validation: Reject obviously invalid IDs (negative numbers, UUIDs with wrong format) at the API layer before they reach the caching logic.

Hint Try loading the “Cache Penetration” scenario in the Redis Caching visualization to see how Bloom filters block invalid keys before they reach the database.

Where it breaks: Cache Breakdown (Hot Key Expiration)

The failure mode: A single extremely popular cache key (e.g., the homepage feed, a trending product) expires. Thousands of concurrent requests simultaneously experience a cache miss and all query the database with the same heavy query. The database chokes under the load.

What you will notice:

  • Database query latency spikes sharply at regular intervals (aligned with TTL expiration)
  • Redis keyspace_misses shows sudden bursts followed by a single SET and return to normal
  • Application error logs show connection pool exhausted during burst windows
  • The same expensive SQL query appears hundreds of times in the PostgreSQL slow query log

What I would do:

  • Mutex lock: When a cache miss occurs, the first thread acquires a distributed lock (SET lock:homepage NX EX 10) and rebuilds the cache. Other threads wait or serve stale data.
  • Background refresh: Never let hot keys expire. A background cron job refreshes them 30 seconds before TTL expiration.
  • Stale-while-revalidate: Serve the expired (stale) value immediately while triggering an async rebuild in the background.

Warning If your hot key serves thousands of requests per second, even a 1-second TTL gap can cause a stampede. Prefer background refresh over short TTLs for your most popular keys.

Where it breaks: Cache Avalanche (Mass Expiration)

The failure mode: Hundreds of cache keys were set at the same time (e.g., during application startup or after a cache flush) with the same TTL. They all expire simultaneously, causing a massive wave of cache misses that overwhelms the database.

What you will notice:

  • Periodic database load spikes every N minutes (matching the common TTL value)
  • Redis dbsize drops suddenly, then gradually recovers
  • Application latency increases globally, not just for one endpoint
  • Memory usage on Redis drops and then slowly climbs back

What I would do:

  • Jittered TTLs: Add random jitter to TTL values: TTL = baseTTL + random(0, baseTTL * 0.2). This spreads expirations across a time window instead of a single moment.
  • Staggered warm-up: On application startup, warm the cache in batches with delays between each batch instead of loading everything at once.
  • Layered caching: Use a local in-process cache (Caffeine, Guava) as L1 with a 10–30 second TTL, and Redis as L2. Even if Redis has a mass expiration, the L1 absorbs the immediate burst.

The tradeoff

PatternProsCons
Cache-AsideSimple, cache is optional (system works without it)Application owns invalidation logic, stale data window
Write-ThroughAlways consistent, no stale readsHigher write latency, caches data that may never be read
Write-BehindLowest write latencyData loss risk if Redis crashes, complex sync logic
Short TTL (30s)Fresh data, quick recovery from stale stateHigher cache miss rate, more database load
Long TTL (6h)High hit ratio, minimal database loadStale data persists longer, manual invalidation needed
Null cachingPrevents penetration attacksMemory overhead for non-existent keys

Hint For most applications, start with cache-aside and a 5-minute TTL with jitter. Optimize from there based on your hit ratio — target 90%+ using redis-cli INFO stats.

A practical debugging checklist

  1. Check hit ratio: redis-cli INFO stats | grep keyspace — aim for 90%+ hit rate
  2. Monitor memory: redis-cli INFO memory — check used_memory vs maxmemory
  3. Find hot keys: redis-cli --hotkeys (requires maxmemory-policy to be set)
  4. Check eviction: redis-cli INFO stats | grep evicted_keys — non-zero means memory pressure
  5. Slow log: redis-cli SLOWLOG GET 10 — identify commands taking >10ms
  6. TTL distribution: redis-cli DEBUG OBJECT <key> — check remaining TTL of suspicious keys

Redis caching visualization showing cache hits, misses, and TTL expiration

Try it yourself

Understanding cache behavior requires watching data flow through the system under different conditions. Try these interactive visualizations: