Day 58 — caching
Day 58 — caching
Stage VI · ~3h
Goal: Implement a cache-aside layer behind a small interface—in-memory with TTL, optional Redis—plus invalidation rules that won’t lie to users.
Why this day exists
Databases and upstream HTTP are expensive. Caching helps when:
- Reads dominate
- Slightly stale data is acceptable or invalidation is correct
- You measure hit rate later (Stage VII metrics)
Caching also hurts when:
- You cache without TTL or invalidation
- You cache user-specific data under global keys
- Stampede thunders on expiry
- You treat cache as source of truth
Today you build a correct-by-default cache-aside path you can disable with a Noop implementation.
Theory 1 — Cache-aside (lazy loading)
read(key):
if val, ok := cache.Get(key); ok { return val }
val = db.Load(key)
cache.Set(key, val, ttl)
return val
write(key, val):
db.Save(key, val)
cache.Delete(key) // or Set updated
Why delete on write? Avoid dual-write inconsistency when DB succeeds and cache set fails (or vice versa). Re-populate on next read. Write-through (update cache in the write path) is valid but harder to keep consistent under partial failures.
Theory 2 — Interface first
type Cache interface {
Get(ctx context.Context, key string) (string, bool, error)
Set(ctx context.Context, key, val string, ttl time.Duration) error
Delete(ctx context.Context, key string) error
}Implementations:
MemoryCache—map+sync.RWMutex+ expiry
RedisCache— optional; skip if no Redis
NoopCache— always miss (tests/feature flag)
type NoopCache struct{}
func (NoopCache) Get(context.Context, string) (string, bool, error) {
return "", false, nil
}
func (NoopCache) Set(context.Context, string, string, time.Duration) error { return nil }
func (NoopCache) Delete(context.Context, string) error { return nil }Theory 3 — Memory implementation sketch
type entry struct {
val string
exp time.Time
}
type MemoryCache struct {
mu sync.RWMutex
data map[string]entry
}
func NewMemoryCache() *MemoryCache {
return &MemoryCache{data: make(map[string]entry)}
}
func (c *MemoryCache) Get(_ context.Context, key string) (string, bool, error) {
c.mu.RLock()
e, ok := c.data[key]
c.mu.RUnlock()
if !ok || time.Now().After(e.exp) {
if ok {
_ = c.Delete(context.Background(), key)
}
return "", false, nil
}
return e.val, true, nil
}
func (c *MemoryCache) Set(_ context.Context, key, val string, ttl time.Duration) error {
c.mu.Lock()
c.data[key] = entry{val: val, exp: time.Now().Add(ttl)}
c.mu.Unlock()
return nil
}
func (c *MemoryCache) Delete(_ context.Context, key string) error {
c.mu.Lock()
delete(c.data, key)
c.mu.Unlock()
return nil
}Janitor
Optional goroutine ticking every minute to drop expired keys (or lazy delete only). Bound map size (max entries + eviction) for production; lab can document unbounded risk.
func (c *MemoryCache) Janitor(stop <-chan struct{}, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
now := time.Now()
c.mu.Lock()
for k, e := range c.data {
if now.After(e.exp) {
delete(c.data, k)
}
}
c.mu.Unlock()
}
}
}Theory 4 — Key design
item:v1:{id}
user:v1:{id}:items
Include version prefix so you can bust all keys by deploying a new version. Include tenant/user when data is private:
item:v1:{userID}:{id}
Never use raw user input without sanitizing separators. Prefer fixed prefixes + opaque ids.
Theory 5 — Stampede control (awareness)
When popular key expires, N requests hit DB. Mitigations:
- Singleflight (
golang.org/x/sync/singleflight)
- Probabilistic early expiration
- Lock per key
var g singleflight.Group
func (r *CachedItemRepo) Get(ctx context.Context, id string) (Item, error) {
key := "item:v1:" + id
if raw, ok, err := r.cache.Get(ctx, key); err != nil {
return Item{}, err
} else if ok {
var it Item
if err := json.Unmarshal([]byte(raw), &it); err == nil {
return it, nil
}
}
v, err, _ := g.Do(key, func() (any, error) {
it, err := r.inner.Get(ctx, id)
if err != nil {
return Item{}, err
}
b, _ := json.Marshal(it)
_ = r.cache.Set(ctx, key, string(b), r.ttl)
return it, nil
})
if err != nil {
return Item{}, err
}
return v.(Item), nil
}Lab stretch: prove concurrent Gets coalesce to one load.
Theory 6 — HTTP caching vs app cache
| Layer | Mechanism |
|---|---|
| Client/CDN | Cache-Control, ETag (Day 56 R) |
| App memory/Redis | Cache interface today |
| DB | Materialized views / query cache (DBA) |
Do not confuse Cache-Control: private personal data with public CDN caching. App caches are not a substitute for HTTP cache headers when browsers/CDNs are the consumers.
Negative caching
Caching “not found” can protect the DB from hammering missing keys—use a short TTL and never confuse it with a real item payload (store a sentinel or separate negative map).
Worked example — repo with cache-aside
type CachedItemRepo struct {
inner ItemReaderWriter // interface to real store
cache Cache
ttl time.Duration
log *slog.Logger
}
func (r *CachedItemRepo) Get(ctx context.Context, id string) (Item, error) {
key := "item:v1:" + id
if raw, ok, err := r.cache.Get(ctx, key); err != nil {
return Item{}, err
} else if ok {
var it Item
if err := json.Unmarshal([]byte(raw), &it); err == nil {
r.log.Debug("cache hit", "key", key)
return it, nil
}
}
it, err := r.inner.Get(ctx, id)
if err != nil {
return it, err
}
b, err := json.Marshal(it)
if err == nil {
_ = r.cache.Set(ctx, key, string(b), r.ttl)
}
return it, nil
}
func (r *CachedItemRepo) Update(ctx context.Context, it Item) error {
if err := r.inner.Update(ctx, it); err != nil {
return err
}
return r.cache.Delete(ctx, "item:v1:"+it.ID)
}Lab 1 — Setup
mkdir -p ~/lab/90daysofx/01-go/day58
cd ~/lab/90daysofx/01-go/day58
go mod init example.com/day58Package layout: cache/, store/, optional api/ wiring.
Lab 2 — MemoryCache tests
- Set/Get hit
- Expiry miss after TTL
- Delete removes key
- Concurrent Set/Get with
-race
Use short TTLs (20 * time.Millisecond) + time.Sleep carefully or inject a clock interface for purity.
go test ./cache/ -race -count=1Lab 3 — Wire into API
GET /v1/items/{id} goes through cached repo. Manually:
- Get (miss → DB)
- Get again (hit—log optional
cache_hit=true)
- Update/Delete invalidates
- Get loads fresh
Optional counter metrics later (Day 69); for now slog Debug is enough.
Lab 4 — Optional Redis
If Redis available:
rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
// GET / SET with expiration / DEL — implement CacheSkip with build tag or runtime detect—document “Redis not used” if skipped. Never fail unit tests because Redis is down.
Lab 5 — singleflight stretch
Prove with a slow DB fake that 20 concurrent Gets cause ~1 load:
var loads atomic.Int64
// inner.Get sleeps 50ms and increments loads
// 20 goroutines call Cached Get same id
// assert loads == 1 (or very close) with singleflightCommon gotchas
| Gotcha | Fix |
|---|---|
| Cache forever | TTL always |
| No invalidation on write | Delete/Set |
| Caching errors/404 forever | Don’t cache misses or short negative TTL deliberately |
| Global keys for private data | Namespace by user |
| Huge values | Bound size |
| Ignoring ctx in Redis | Use context variants |
| Race on map | Mutex |
| Caching mutable pointers shared with callers | Marshal copies / return new structs |
Checkpoint
Cacheinterface + Memory impl
- Cache-aside Get + invalidate on write
- Tests for TTL and race
- Key naming scheme documented
- Optional Redis or explicit skip note
- Know when not to cache
Commit
git add .
git commit -m "day58: cache-aside interface and memory TTL cache"Write three personal gotchas before continuing.
Tomorrow
Day 59 — WebSockets or SSE: pick one realtime pattern; push live updates from your service.