Topic 49

Cache-Aside With Redis

Caching

Cache-aside is the pattern where the code checks the cache, misses, reads the database, writes the cache, and returns. The cache sits beside the read path rather than in it: the database stays the only writer of truth, Redis holds a copy that the code put there, and the code is the only thing that knows both exist. It is the pattern nearly every service uses, and the seat map is the worked example, from the key to the bytes in it to the twelve lines of Python that do the whole thing.

Most of what goes wrong with a cache goes wrong in those twelve lines, and it goes wrong quietly: a key with no expiry, a value in a format the next deploy cannot read, a Redis error that became a 500, a write path that decided to be helpful and set the key itself. Each of them works in the test suite. This topic is the version that also works on the night of the on-sale, and the next topic is what happens in the gaps this one leaves open on purpose.

The Pattern

A read of the seat map for event 8812 starts with GET seatmap:v2:8812. On a hit, Redis returns the bytes, the code deserializes them and returns; 1 millisecond, no query. On a miss, Redis returns nothing, the code runs the 8-millisecond query on pg-primary, serializes the result, sends SET seatmap:v2:8812 with the bytes and EX 30, and returns the result it just built. Two paths, and the second one ends by making the first one true for the next reader.

The seat map read: get, miss, query, set with a TTL, return
SEATMAP_TTL = 30                                   # seconds; the floor of correctness (Topic 50)

async def get_seat_map(svc, event_id: int) -> SeatMap:
    key = f"seatmap:v2:{event_id}"
    raw = await svc.cache.get(key)                 # 100 ms timeout + breaker inside; None on any failure
    if raw is not None:
        svc.metrics.cache_hit("seatmap")
        return SeatMap.from_json(raw)
    svc.metrics.cache_miss("seatmap")
    async with svc.primary.connection() as conn:
        seat_map = await svc.seats.map_for_event(conn, event_id)   # 8 ms on the primary
    seat_map.cached_at = svc.clock.now()
    await svc.cache.set(key, seat_map.to_json(), ex=SEATMAP_TTL)  # one atomic SET ... EX 30
    return seat_map

The function asks Redis for the key and, if bytes come back, counts a hit and returns them parsed. If nothing comes back, it counts a miss, runs the query on the primary through the ordinary pool, stamps the result with the time it was built, stores it in Redis with a 30-second expiry in the same command, and returns it. The cache client the function calls is a thin wrapper that carries the 100-millisecond timeout from Topic 37 and the breaker from Topic 40, and turns any failure on either side into "nothing came back"; the function itself never sees a Redis error. The one thing the twelve lines do not handle is two readers missing at once: both query, both set, and at 2,600 reads a second that is the stampede of Topic 50, not a bug in this function.

Key Design

A key is a prefix for the family, a version for the shape, and the natural id: seatmap:v2:8812. The prefix is how a human at a Redis prompt finds the seat maps among the sessions and the counters, and how the hit-ratio metric knows which family a key belongs to. The id is the event's, the same one in the URL. The version is the part that is forgotten: when a deploy changes what the value contains, adding a section field to each seat, the new code must not read bytes the old code wrote. Bumping the version to v3 means the new code misses on every key once and rebuilds, and the old bytes expire untouched under their own TTL. Topic 52 has the incident where the version was not bumped.

Keys are listable by prefix for debugging and never for invalidation. KEYS seatmap:* walks the whole keyspace in one command and blocks Redis for the duration, which at a few hundred thousand keys is long enough for every request on both instances to time out at 100 milliseconds and the breaker to open. SCAN walks the same keyspace a page at a time with a cursor, and it is for a human with a question. Invalidation is by a key the code can compute from the write it just made, and if the code cannot compute which key a write affects, the key design is wrong, not the invalidation.

Serialization

The seat map is JSON: 2,000 seats at about 30 bytes each is 60 KB, readable at the Redis prompt, parseable by the worker, the next language and the person debugging at 2 a.m. The value carries its own cached_at so the read path can report the age of what it served, and the debugging question of Topic 52 has an answer in the value itself. When the 60 KB on every hit is the cost, and at 2,600 a second it is 156 MB a second across the wire from redis-01, zstd brings it to 8 KB, and the compression is cheaper than the bytes. MessagePack or Protobuf are the step after that, for a value that is hot, large and read by code that has a schema for it; the seat map has not needed them.

Never Python's pickle. A pickled value executes code when it is loaded, so anyone who can write to Redis can run code in every process that reads from it, and a value written by one version of a class is not readable by the next, or by the worker if it is a step behind, or by anything that is not Python. The format of a cached value is a contract between every process that touches the key, and JSON is the one every process can hold.

The two paths of cache-aside, and the write path that only deletes
GETseatmap:v2:8812
hitparse, return
miss: querythe primary
SET EX 30then return
writeUPDATE seats
COMMITpg-primary
DELseatmap:v2:8812
next readrebuilds

TTL Is the First Invalidation

Every key has an expiry, without exception, and the seat map's is 30 seconds. That number is not how stale the map is; the delete on the write path makes it fresh within milliseconds of a commit. It is how stale the map can be when everything else fails: the process that crashed between the commit and the delete, the Redis timeout that swallowed the DEL, the code path someone added that writes a seat and forgot the cache exists. With the TTL, each of those is a seat shown available for at most 30 seconds and then a 409. Without it, the first missed invalidation is a value that is wrong forever.

The EX is part of the SET, one command, so there is no moment when the key exists without an expiry. A SET followed by a separate EXPIRE has that moment, and a crash or a timeout between the two leaves a key that never expires. The wrapper that Stagedoor's code calls refuses a set with no expiry argument, which is a cheaper guard than the incident.

Write Path Unchanged

The hold, the sale and the organizer's seat-map edit write to Postgres exactly as Chapter 6 built them: the row lock, the version check, the unique constraint, the commit. After the commit, and only after, the write path sends DEL seatmap:v2:8812 and moves on. It does not build the new map and set it. Writing the cache from the write path means the serialization logic exists in two places, the read path's and the write path's, and they drift; and it means a race, because a read that missed a moment before the write is about to SET a map it built from the pre-commit rows, and whichever SET lands last wins. Topic 50 draws that race step by step. The delete has none of it: it is idempotent, it does not care what order it arrives in, and the next reader rebuilds from whatever is committed.

Redis Semantics That Matter Here

Four facts carry the pattern. SET with EX is atomic: the value and its expiry land together or not at all, and a second SET on the same key replaces both. GET on a key that does not exist returns nothing, which the client reports as None; it is a miss, not an error, and the function above treats the two identically. A Redis timeout at 100 milliseconds is also a miss, with a metric that says so, and never a 500: a buyer whose seat map came from the primary because Redis was slow has waited the 100-millisecond timeout and 8 milliseconds more, not had a failed request. And when the breaker of Topic 40 opens on Redis, every GET is a miss in under a millisecond and every read is on the primary, which is the fallback Chapter 7 wrote down and Topic 52 rate-limits.

The Redis client keeps a pool of connections, and it is sized the way Topic 31 sized the Postgres pool: a fixed maximum per instance, 20, with a checkout timeout, so that a slow Redis holds at most 20 tasks per instance and the rest fail fast into the miss path. An unbounded pool against a Redis that has stopped answering opens a connection per request until the file-descriptor limit, and the instance that was supposed to degrade to the primary instead falls over on its own.

Cache-Aside vs Read-Through vs Write-Through

Cache-aside puts the code in charge: it reads the cache, reads the database on a miss, and writes the cache itself. Explicit, and every decision about keys, format and TTL is visible in the function. It is the pattern in this book.

Read-through makes the cache load from the database on a miss, through a loader the code registers once. The read path shrinks to one call, and the smarts move into the cache or a library such as Spring's @Cacheable. Same semantics, less code, one more thing to configure.

Write-through sends writes to the cache, which forwards them to the database. The cache is now in the write path, must be as durable as the database, and is a second place a write can be lost. Stagedoor uses cache-aside because Postgres stays the only writer, and a Redis restart costs nothing but rebuilds.

Common Mistakes
  • No TTL on the key — one missed delete, and the seat map from last week's sold-out show is served until someone restarts Redis.
  • KEYS seatmap:* in production — Redis blocks for the length of the scan, every request on both instances times out at 100 milliseconds, and the breaker opens on a Redis that is merely busy.
  • pickle as the format — a deserialization exploit for anyone who can write to Redis, and a value the worker's next version cannot read after the class changed.
  • Writing the cache from the write path — two serializers that drift apart, and the race where the write's SET lands before a slower read's stale SET replaces it.
  • Redis errors as 500s — a dead cache turns every seat-map request into a failed request, when the primary was there to answer it 8 milliseconds slower.
  • SET and then a separate EXPIRE — a crash between the two leaves a key with no expiry, and the wrong-forever value the TTL was there to prevent.
Best Practices
  • Read: get, miss, query, set with the TTL in the same command, return. Write: update the database, commit, delete the key.
  • Name keys with a family prefix, a shape version and the natural id, and store JSON or a schema format with a cached_at in the value.
  • Put a TTL on every key as the floor of correctness, and make the cache wrapper refuse a set without one.
  • Treat every Redis failure as a miss with a metric, and let the breaker of Chapter 7 make a dead Redis a fast miss.
  • Bound the Redis connection pool per instance with a checkout timeout, the way the Postgres pool is bounded, so a slow Redis cannot take the instance with it.
Comparable toolsredis-py the client, with its asyncio flavour replacing the old aioredisDjango cache.get_or_set, Rails.cache.fetch the pattern as one callSpring @Cacheable, read-through by annotationCaffeine and Guava the in-process tier on the JVM

Knowledge Check

Two requests for event 8812 miss the cache within the same millisecond. What does the twelve-line cache-aside function do about it?

  • The second miss waits for the first one to set the key and then reads it back as a hit
  • Nothing: both query the primary and both set the key, which is Topic 50's problem
  • Redis refuses the second SET because the same key was written a moment earlier
  • The second rebuild is discarded because its version number matches the first one's

The write path deletes the key after the commit and explicitly does not set the new value. Why?

  • Because a SET of the 60 KB map is slower than a DEL and the write path has a tighter budget
  • Because Redis lets only the process that first created a key overwrite it, and that was the reader
  • Because a value set from the write path would reflect the uncommitted rows the write just changed
  • Because a set from the write path is a second serializer and can be overwritten by a slower read's stale SET

Redis answers a GET after 100 ms, the timeout. What should the seat-map read return to the buyer?

  • The seat map from the primary, with a miss counted against the cache
  • A 503 with a Retry-After, because a dependency of the read has failed
  • An empty seat map, so that the page renders and the next poll fills it in
  • The same GET retried one more time, since 100 ms is a very short deadline

Why does Stagedoor put a 30-second TTL on the seat map when the write path already deletes the key on every commit?

  • So the old value ages out after the delete instead of lingering in Redis's memory as garbage
  • So Redis's memory stays bounded across the 2,000 event keys that would otherwise accumulate
  • So a missed delete costs at most 30 seconds of staleness rather than a value that is wrong forever
  • So the map is guaranteed to reflect every hold within 30 seconds, which the delete alone cannot do

You got correct