← DBMS
Choosing a Data Store

Redis & Caching — Cache-Aside, Write-Through, Invalidation & Stampedes

Watch: "Basic Caching Techniques Explained - Spatial, Temporal, Distributed, Write-Through, Write-Back,Aside" — Hussein Nasser

Redis is an in-memory data-structure server that most backends use first as a cache in front of a slower database. Interviewers probe it because caching looks trivial and hides the hardest problems in the stack: keeping two copies of the same data consistent, surviving the moment the cache is empty or gone, and not letting a popular key expiring take the database down with it. For a Backend or AI engineer it also matters beyond caching, since Redis commonly serves as session store, rate limiter, queue, distributed lock, and a cache for expensive LLM or embedding calls.

Deep Dive: Why Redis Is Fast, and What It Is Not

Typical read without cache:                 With cache-aside:

app -> network -> DB -> disk/page cache      app -> network -> Redis (RAM)   HIT
       parse, plan, execute, lock                       |
       (roughly milliseconds)                           +-- MISS -> DB -> write to Redis

Redis data types:  string, hash, list, set, sorted set (ZSET), stream, bitmap, HyperLogLog ...
Command execution: one main thread runs commands one at a time
                   (I/O can use extra threads in newer versions, but commands still
                   execute serially) -> each single command is atomic.

Redis is fast for structural reasons rather than magic: the working set lives in RAM, the protocol is a trivial request/response format, data structures are chosen for cheap operations, and the single-threaded command loop avoids locks entirely. The same design defines its limits. Data must fit in memory (so it is expensive per gigabyte compared to disk), one slow command such as KEYS * or a huge SMEMBERS blocks every other client, and a single instance is bounded by one core for command execution. Persistence (RDB snapshots and/or an append-only file) and replication exist, but they are asynchronous or configurable in strength, so Redis is generally treated as a fast derived store, not the system of record for data you cannot afford to lose.

Deep Dive: Cache-Aside vs Write-Through vs Write-Behind

CACHE-ASIDE (lazy loading; the app orchestrates)
  read : cache.get(k) -> hit? return
         miss -> db.read(k) -> cache.set(k, v, TTL) -> return
  write: db.write(k, v) -> cache.delete(k)        (invalidate, do not update)

WRITE-THROUGH (writes go through the cache layer)
  write: cache.set(k, v) AND db.write(k, v) as one synchronous step
         (the cache layer or your code does both before acknowledging)
  read : cache.get(k) -> almost always a hit for written keys

WRITE-BEHIND / WRITE-BACK
  write: cache.set(k, v) -> ack immediately -> flush to DB asynchronously later
         (fast writes, but data in the cache but not yet flushed is lost if it dies)

Cache-aside is the default because it is simple and self-healing: the cache holds only what was actually requested, and losing it just causes misses, never wrong data. Its costs are a miss penalty on the first read of each key and a window of staleness, since the cache and database are updated as two separate steps. Write-through keeps the cache warm and fresh for written keys and removes the miss-after-write, but every write pays for two stores, it caches data that may never be read, and it is only as consistent as the two-step write is atomic (a crash between the two leaves them different). Write-behind buys the lowest write latency by turning the cache into a buffer in front of the database, which trades durability for speed and is rarely appropriate for money or inventory.

Deep Dive: Cache Invalidation and the Stale-Write Race

Why cache-aside deletes on write instead of updating the cache:

  Reader R                          Writer W
  -------------------------------   -------------------------------
  cache.get(k)          -> MISS
  db.read(k)            -> old
                                    db.write(k, new)
                                    cache.delete(k)
  cache.set(k, old, TTL)            <- stale value written AFTER the delete
  ... k now holds "old" until TTL expires or the next invalidation

Rare (needs the reader to stall between its DB read and cache write), but real.
Mitigations: short TTL as a safety net, versioned keys, or delayed double-delete.

Invalidation is hard because there is no atomic operation across "database" and "cache", so any scheme leaves some window where they differ. Deleting on write is preferred to updating the cache in place because two concurrent writers can update the cache in the opposite order from the database, leaving a value that is wrong indefinitely. Even delete-on-write has the reader race above. In practice you combine layers: invalidate on write for freshness, and always set a TTL so a missed or raced invalidation self-corrects, and accept bounded staleness for the data types where that is tolerable (a product description) while not caching, or caching very briefly, data where it is not (a payment balance).

Deep Dive: Cache Stampede and How to Stop It

Hot key "home:feed" expires at t0. 5,000 requests/sec arrive.

  t0+0ms   req 1 : MISS -> query DB (slow, 200 ms)
  t0+1ms   req 2 : MISS -> query DB      (cache not refilled yet)
  ...
  t0+200ms req 1000: MISS -> query DB     -> ~1000 identical heavy queries at once

Defences:
  1. single-flight / lock : one caller rebuilds, the rest wait or serve stale
  2. TTL jitter           : add random seconds so keys do not all expire together
  3. early / probabilistic refresh : refresh shortly BEFORE expiry
  4. serve-stale-while-revalidate  : return the old value while one worker refreshes
  5. request coalescing in the app process (per-instance single-flight)

A stampede (thundering herd) happens because a cache miss is not free: the moment a hot entry disappears, every concurrent request independently concludes "someone must rebuild this" and all hit the database together, often the exact expensive query the cache existed to avoid. It also comes in a mass form, where many keys set with the same TTL expire in the same second (for example after a deploy that warmed everything at once). Single-flight fixes the concentrated case by letting exactly one request rebuild while others wait or use a stale copy; jitter fixes the mass case by spreading expirations. A distributed version in Redis uses SET lock:key token NX PX 5000 and a compare-and-delete to release, with the database, not the lock, remaining the source of truth so a lock that expires early costs some duplicate work, not corruption.

Key Pointers

How It Works

  1. A request arrives and the application builds a cache key from the query, for example user:42:profile, ideally with a version or schema prefix.
  2. The application calls GET on Redis. On a hit it returns immediately without touching the database.
  3. On a miss it reads from the database (the source of truth), then stores the result with SET key value EX ttl, with jitter added to the TTL.
  4. When the underlying data changes, the write goes to the database first and then the application deletes the cached key so the next read reloads fresh data.
  5. If memory runs out, Redis follows the configured maxmemory-policy (for example allkeys-lru or volatile-ttl) and evicts keys, so the cache degrades gracefully instead of failing writes; the default policy in many builds is noeviction, which rejects writes instead.
  6. If a hot key expires, single-flight logic lets one request rebuild it while others wait briefly or receive the stale value, so the database sees one query, not thousands.

Try It Yourself

# Python 3: cache-aside with a dict standing in for Redis (fake clock so TTL is instant)
import time
DB = {"user:1": {"name": "Asha", "plan": "free"}}
db_reads = 0

class Cache:
    def __init__(self, clock=time.monotonic):
        self.data, self.clock = {}, clock
    def get(self, key):
        item = self.data.get(key)
        if item is None: return None
        value, expires = item
        if self.clock() >= expires:
            del self.data[key]          # lazy expiry
            return None
        return value
    def set(self, key, value, ttl):
        self.data[key] = (value, self.clock() + ttl)
    def delete(self, key):
        self.data.pop(key, None)

now = [0.0]
cache = Cache(clock=lambda: now[0])

def read_user(key):                      # cache-aside READ
    global db_reads
    v = cache.get(key)
    if v is not None: return v, "HIT"
    db_reads += 1
    v = DB[key]
    cache.set(key, v, ttl=60)
    return v, "MISS"

def update_plan(key, plan):              # write path: DB first, then invalidate
    DB[key] = {**DB[key], "plan": plan}
    cache.delete(key)

print("read 1:", read_user("user:1"))
print("read 2:", read_user("user:1"))
update_plan("user:1", "pro")
print("read 3 (after update+invalidate):", read_user("user:1"))
DB["user:1"] = {"name": "Asha", "plan": "enterprise"}   # change DB with NO invalidation
print("read 4 (DB changed, no invalidate):", read_user("user:1"))
now[0] = 61.0                            # TTL passes
print("read 5 (after TTL expiry):", read_user("user:1"))
print("total DB reads:", db_reads)
# Real output from python3:
# read 1: ({'name': 'Asha', 'plan': 'free'}, 'MISS')
# read 2: ({'name': 'Asha', 'plan': 'free'}, 'HIT')
# read 3 (after update+invalidate): ({'name': 'Asha', 'plan': 'pro'}, 'MISS')
# read 4 (DB changed, no invalidate): ({'name': 'Asha', 'plan': 'pro'}, 'HIT')     <- stale
# read 5 (after TTL expiry): ({'name': 'Asha', 'plan': 'enterprise'}, 'MISS')       <- TTL healed it
# total DB reads: 3

# Python 3: stampede vs single-flight (20 threads all miss the same key)
import threading, time
db_calls = 0
db_lock = threading.Lock()
def slow_db_query():
    global db_calls
    with db_lock: db_calls += 1
    time.sleep(0.2)
    return "result"

def run(use_single_flight, n=20):
    global db_calls
    db_calls = 0
    cache, locks, guard = {}, {}, threading.Lock()
    def get(key):
        if key in cache: return cache[key]
        if not use_single_flight:
            cache[key] = slow_db_query()
            return cache[key]
        with guard:
            lock = locks.setdefault(key, threading.Lock())
        with lock:                       # only one thread rebuilds
            if key in cache:             # re-check after acquiring the lock
                return cache[key]
            cache[key] = slow_db_query()
            return cache[key]
    ts = [threading.Thread(target=get, args=("hot",)) for _ in range(n)]
    [t.start() for t in ts]; [t.join() for t in ts]
    return db_calls

print("20 concurrent misses, no protection    -> DB calls:", run(False))
print("20 concurrent misses, single-flight    -> DB calls:", run(True))
# Real output from python3 (unprotected count depends on thread timing; 20 is what
# a 0.2 s query with 20 near-simultaneous threads produced on this run):
# 20 concurrent misses, no protection    -> DB calls: 20
# 20 concurrent misses, single-flight    -> DB calls: 1

# Python 3: TTL jitter spreads expiry
import random
random.seed(7)
fixed  = [300 for _ in range(1000)]
jitter = [300 + random.randint(0, 60) for _ in range(1000)]
def buckets(ttls):
    b = {}
    for t in ttls: b[t] = b.get(t, 0) + 1
    return b
print("fixed TTL : distinct expiry seconds =", len(buckets(fixed)), " max keys expiring same second =", max(buckets(fixed).values()))
j = buckets(jitter)
print("jittered  : distinct expiry seconds =", len(j), " max keys expiring same second =", max(j.values()))
# Real output from python3:
# fixed TTL : distinct expiry seconds = 1  max keys expiring same second = 1000
# jittered  : distinct expiry seconds = 61  max keys expiring same second = 27

# redis-cli (not run here; needs a Redis server, so no output is shown)
# Basic cache entry with a 60 s TTL, then inspect the remaining time:
#   SET user:42:profile "{\"name\":\"Asha\"}" EX 60
#   GET user:42:profile
#   TTL user:42:profile          # seconds remaining; -2 = key gone, -1 = no expiry
# Invalidate on write:
#   DEL user:42:profile
# Cross-process stampede lock (only one caller gets OK; the rest get nil):
#   SET lock:user:42:profile some-unique-token NX PX 5000
# Inspect memory and eviction settings:
#   INFO memory
#   CONFIG GET maxmemory-policy

Interview Q&A

Q: Why use Redis?

A: Because it serves data from memory with very low latency and offers atomic operations on useful data structures (strings, hashes, sorted sets, streams), so it can absorb read load from a slower database, hold sessions, implement rate limiters, leaderboards, and queues, and coordinate distributed locks. The trade-offs are memory-bound capacity and cost, persistence and replication that are weaker or more configurable than a primary database, and the added consistency problem of keeping a cache in sync with its source. I would use it as a fast derived layer, not the sole copy of critical data.

Q: Cache-aside vs write-through?

A: In cache-aside the application loads data into the cache on a miss and invalidates on writes, so the cache is lazy and cheap to lose; the price is a miss penalty and a staleness window. In write-through every write updates the cache and database together, so reads of written keys are warm and fresh, but writes are slower, the cache fills with data that may never be read, and the two-step write can still partially fail. Cache-aside suits read-heavy data with tolerable staleness; write-through suits data that is read right after it is written and where you want fewer misses.

Q: What is cache invalidation?

A: It is deciding when a cached copy is no longer valid and removing or refreshing it, using explicit deletion on write, TTL expiry, versioned keys, or event-driven invalidation from database changes. It is hard because there is no atomic operation across the cache and the database, so concurrent readers and writers can interleave and leave stale data, as in the read-then-delayed-set race. The pragmatic design is delete on write plus a TTL safety net, with staleness that the product explicitly accepts.

Q: What is a cache stampede?

A: It is when many concurrent requests miss the same key, typically a hot key that just expired or was invalidated, and all fall through to the database to rebuild the same value at once. The database load spikes exactly when the cache was meant to protect it, which can cascade into timeouts and retries that make it worse. A related mass version occurs when many keys share the same TTL and expire together.

Q: How would you prevent a cache stampede?

A: Combine several tools: single-flight or a per-key lock so only one request rebuilds while others wait briefly or serve a stale value; random jitter on TTLs to stop synchronized expiry; early or probabilistic refresh before a hot key expires; and pre-warming for known hot keys. The trade-offs are complexity, a lock that can itself become a bottleneck or expire early (so the database must tolerate an occasional duplicate rebuild), and slightly stale data when serving stale during revalidation.

Q: What happens if Redis goes down?

A: With cache-aside the application can fall back to the database, so functionality survives but latency rises and the database suddenly takes the full read load, which can overload it. Mitigate with replication and automatic failover (Sentinel or Cluster), timeouts and a circuit breaker so requests do not hang on a dead cache, rate limiting or load shedding to protect the database, and a small in-process cache for the hottest keys. When Redis returns empty after a restart, expect a cold-cache stampede and consider warming it. If Redis held data that exists nowhere else, such as sessions or counters, that data may be lost or stale depending on persistence and replication settings.

Follow-up: Redis is single-threaded, so how does it handle so many clients?

A: Command execution is serial on one main thread, and it uses non-blocking I/O multiplexing to serve many connections; newer versions can use extra threads for network I/O, but commands still execute one at a time. That makes each command atomic and lock-free, and fast commands finish in microseconds. The catch is that one slow command such as KEYS * or reading a huge collection blocks everyone, so you avoid O(N) commands on big keys and scale out with replicas for reads and Redis Cluster for sharding.

Follow-up: How do you keep Redis and the database consistent for a critical value like inventory?

A: You generally do not cache the authoritative value for decisions like "can I sell this item"; the database transaction is the source of truth, and the cache serves a possibly stale display value. If Redis must be used for a counter, you use atomic commands (DECR, or a Lua script) and still reconcile with the database, accepting that a failover with asynchronous replication can lose recently acknowledged updates. Redis gives speed here, not the durability and constraint enforcement of a transactional database.

Follow-up: Which eviction policy would you pick, and what does it depend on?

A: For a pure cache, allkeys-lru (or an LFU variant) is a common choice because any key can be dropped and recently or frequently used keys survive. If some keys must never be evicted, such as locks or sessions, use volatile-* policies that only evict keys with a TTL, and understand that when only non-expiring keys remain, writes can fail. The right choice depends on the access distribution and on whether the instance mixes cache data with data that must persist, which is one reason to run separate instances for those roles.

System Design Tie-in

Almost every read-heavy system design (feeds, product pages, profiles, LLM-backed endpoints) puts a cache between the application tier and the database, and interviewers expect you to specify the whole policy, not just "add Redis": what is cached and at what key granularity, the TTL and its jitter, how writes invalidate it, how stampedes are prevented on hot keys, what the eviction policy is, and what happens on a cold or dead cache. For a viral feed, cache the assembled result per user or per segment, serve stale while revalidating in the background, and shed load if the database degrades. For AI systems, cache embeddings and deterministic LLM responses keyed by a hash of the normalized input, with a TTL and a model-version prefix so a model upgrade invalidates naturally. Redis Cluster or client-side sharding spreads keys across nodes, and the same hot-key problem reappears there as a hot partition, which local in-process caches or key replication can relieve. Finally, state plainly that the cache is a derived copy: the database remains the source of truth, and the design must stay correct, only slower, when the cache is empty.

Trade-offs

A cache trades consistency and simplicity for latency and database relief: you buy speed by accepting that some reads are stale and that a second system can fail, be cold, or be wrong. Cache-aside is the safe default but has a staleness window and miss penalty; write-through reduces both at the cost of write latency and wasted memory; write-behind gives the fastest writes but can lose data. Stampede protection adds locks, jitter, and stale-serving logic that must themselves be tested and can fail. Caching is the wrong choice when data changes as often as it is read, when reads must always reflect the latest committed state (balances, inventory decisions), when access is nearly uniformly random so hit rates stay low, or when the real problem is a missing index or an inefficient query that should be fixed at the source before hiding it behind a cache. Measure the hit rate and the database load with and without the cache before assuming it helps.