Skip to content

Day 132 — Redis Cache-Aside

Month 5 · Week 3 · ⬅ Day 131 · Day 133 ➡ · Journal index

🎯 Learning Objective

Implement the cache-aside (lazy-loading) pattern against Redis: read-through on miss, invalidate on write, bound staleness with TTL, and handle the failure modes (stampede, stale, negative caching).

📚 Topics

  • Cache-aside read/write flow; the application owns the load logic
  • TTL to bound staleness; delete-on-write vs write-through
  • Failure modes: thundering herd / stampede, stale reads, negative caching
  • go-redis client API (Get/Set/Del, redis.Nil); singleflight for stampedes

📖 Reading / Sources

📝 Notes

  • Cache-aside read: look in the cache → hit returns it → miss loads from the store, populates the cache (with a TTL), and returns. The application, not the cache, owns the load — that's what "aside" means → [[cache-aside]].
  • Write path: update the store, then invalidate (DEL) the cache key. Deleting is safer than updating the cache, which can re-pin a stale value when a concurrent read writes back old data → [[invalidation]].
  • Always set a TTL. It bounds staleness and makes a missed invalidation self-heal — the worst case is "stale for TTL seconds", not "stale forever" → [[ttl]].
  • Thundering herd / cache stampede: when a hot key expires, many goroutines miss simultaneously and all hammer the store. Collapse them with singleflight (one in-flight load per key) or a short lock → [[thundering-herd]].
  • Negative caching: cache "not found" briefly to stop repeated misses for keys that don't exist (guarding against cache-penetration), but keep that TTL short → [[negative-cache]].
  • With go-redis, a miss is the sentinel redis.Nil, not a real error: if errors.Is(err, redis.Nil) → load from the store; treat other errors as failures → [[redis-nil]].
  • "Cache the right thing": cache read-mostly data with tolerable staleness. Don't cache data that must be strongly consistent, and don't cache failures long → [[consistency]].
  • Pass context.Context first to every Redis call so cache lookups respect the request deadline — a slow cache shouldn't outlive the request → [[context]].

💻 Code Examples

go-redis is third-party, so this is a snippet; the runnable model uses an in-memory TTL cache instead.

// Cache-aside Get with go-redis. ctx is first; redis.Nil signals a miss.
func (r *Repo) GetUser(ctx context.Context, id string) (User, error) {
    key := "user:" + id
    if s, err := r.rdb.Get(ctx, key).Result(); err == nil {
        return decode(s), nil // HIT
    } else if !errors.Is(err, redis.Nil) {
        return User{}, err // a real cache error, not a miss
    }
    u, err := r.db.Load(ctx, id) // MISS -> slow path
    if err != nil {
        return User{}, err
    }
    // populate with a TTL; ignore the Set error (cache is best-effort).
    _ = r.rdb.Set(ctx, key, encode(u), 5*time.Minute).Err()
    return u, nil
}

func (r *Repo) UpdateUser(ctx context.Context, u User) error {
    if err := r.db.Save(ctx, u); err != nil {
        return err
    }
    return r.rdb.Del(ctx, "user:"+u.ID).Err() // invalidate, don't rewrite
}

Full runnable model (stdlib only — in-memory TTL cache, hit/miss counters, invalidation): examples/month-05/cacheaside/main.go · Run: go run ./examples/month-05/cacheaside

🏋️ Exercises / Practice

Exercise Status Link
Cache-aside cache: read-through, TTL expiry, invalidate, hit/miss stats exercises/month-05/week-3/cacheaside

🐛 Mistakes Made

  • Treated redis.Nil as a fatal error and propagated it, turning every cache miss into a 500. It's the miss sentinel — branch on it with errors.Is and fall through to the store.
  • On update I rewrote the cache instead of deleting it; a concurrent reader re-cached the old value moments later. Switched to delete-on-write.
  • Cached a load error with the normal TTL, so a transient DB blip stuck around for minutes. Don't cache failures (or use a tiny negative TTL).

❓ Open Questions

  • Best stampede defense in practice: singleflight, probabilistic early expiration, or a short lock key in Redis?

🧠 Active Recall (answer without looking)

  1. Q: Describe the cache-aside read path and who owns the load on a miss.
ACheck the cache; on a hit return it; on a miss the *application* loads from the store, writes the value into the cache with a TTL, and returns it. The cache doesn't fetch on its own — the app code orchestrates it ("aside").
  1. Q: On an update, why delete the cache key instead of overwriting it, and why set a TTL at all?
ADelete-on-write avoids races where a concurrent read re-caches a stale value right after your write; the next read simply reloads fresh. A TTL bounds staleness so even a *missed* invalidation self-heals after the TTL instead of serving bad data forever.

🪶 Feynman Reflection

Cache-aside is "check your pocket before walking to the vault." Reads first look in the fast pocket (Redis); if it's empty they walk to the slow vault (the DB), grab the value, and stuff a copy in the pocket with an expiry tag so they don't keep walking. Writes update the vault and then throw away the pocket copy, so the next reader is forced to fetch the fresh value. The expiry tag is insurance: even if someone forgets to empty the pocket, it goes stale and gets refilled soon anyway.

🕳️ Knowledge Gaps

  • Serialization choice for cached values (JSON vs msgpack vs proto) and its effect on hit cost.
  • Coordinating invalidation across multiple app instances / multi-region caches.

✅ Summary

I can implement cache-aside (read-through on miss, populate with TTL, delete-on-write), branch on redis.Nil, and reason about stampede, stale, and negative-caching failure modes.

⏭️ Next Steps / Prep for Tomorrow

  • Day 133: week review + active recall across the whole architecture week.

Time spent Difficulty Confidence
90 min 🟦🟦⬜⬜⬜ 🟦🟦🟦⬜⬜

Suggested commit: feat(examples): cache-aside TTL cache with hit/miss stats (day 132)