DEV Community

Naveen Karasu
Naveen Karasu

Posted on

Day 25/60: Caching with Redis -- Cache-Aside, Stampede, and Stale Reads

Day 25/60: Caching with Redis -- Cache-Aside, Stampede, and Stale Reads

60 Day Go Backend Engineering Challenge

Our GET /products/{id} endpoint read Postgres on every hit. During a promo the traffic concentrated on a few hundred hot SKUs, so the same SELECT ran thousands of times a second for identical rows. p99 was 180ms, the database sat near 80% CPU, and the shared connection pool was throttling everything else. A Redis cache-aside layer cut p99 to 9ms and dropped read QPS more than 95%. Then it shipped two classic bugs. This post is both.

Cache-aside in Go

The application owns the cache. Read Redis first; on a hit, return; on a miss, read the database, populate with a TTL, return. With go-redis, a missing key is the sentinel error redis.Nil -- a normal miss, not a failure. Any other error is Redis itself misbehaving, and you fall through to the database rather than failing the request.

type Cache struct {
    rdb *redis.Client
    db  *pgxpool.Pool
}

func (c *Cache) Product(ctx context.Context, id string) (Product, error) {
    key := "product:" + id

    switch val, err := c.rdb.Get(ctx, key).Result(); {
    case err == nil:
        var p Product
        if json.Unmarshal([]byte(val), &p) == nil {
            return p, nil // hit
        }
    case err != redis.Nil:
        log.Printf("cache degraded, serving from db: %v", err)
    }

    p, err := c.fetch(ctx, id) // miss -> source of truth
    if err != nil {
        return Product{}, err
    }
    if b, e := json.Marshal(p); e == nil {
        c.rdb.Set(ctx, key, b, c.ttl())
    }
    return p, nil
}
Enter fullscreen mode Exit fullscreen mode

Choosing a TTL

The TTL is your tolerance for being wrong. It is the longest a value may be stale even if every other safety net fails, and it bounds memory by letting cold keys evict themselves. A product blurb tolerates minutes; a price or stock count wants seconds. Pick the longest staleness the feature survives, not a round number. And jitter it, so keys cached together do not all expire on the same tick:

func (c *Cache) ttl() time.Duration {
    // 10 min base + up to 60s jitter to de-synchronize expiries
    return 10*time.Minute + time.Duration(rand.Intn(60))*time.Second
}
Enter fullscreen mode Exit fullscreen mode

The stampede

The first night, every hot-key expiry spiked p99 back to ~150ms. A hot key takes thousands of reads a second; the instant it expires, every concurrent request misses at once and runs the same query. The cache absorbed the load, then released it all in one synchronized burst -- a stampede, the thundering herd. Caching made it worse by aligning the misses perfectly.

Collapse concurrent misses into one DB call with singleflight. N callers for the same key share one execution; N-1 never touch Postgres.

import "golang.org/x/sync/singleflight"

func (c *Cache) load(ctx context.Context, id string) (Product, error) {
    key := "product:" + id
    v, err, _ := c.sf.Do(key, func() (any, error) {
        p, err := c.fetch(ctx, id)
        if err != nil {
            return Product{}, err
        }
        if b, e := json.Marshal(p); e == nil {
            c.rdb.Set(ctx, key, b, c.ttl())
        }
        return p, nil
    })
    if err != nil {
        return Product{}, err
    }
    return v.(Product), nil
}
Enter fullscreen mode Exit fullscreen mode

Jitter spreads expiries apart; singleflight handles whatever still lands together. Use both.

Invalidation on write

The quieter bug: a price update committed to Postgres but the page served the old value for ten minutes, because cache-aside never invalidates itself. Use delete-on-write -- change the row, then delete the key, in that order:

func (c *Cache) Update(ctx context.Context, p Product) error {
    if err := c.save(ctx, p); err != nil {
        return err // source of truth first
    }
    // Invalidate AFTER the commit. Delete, don't overwrite:
    // concurrent writers can race an overwrite into a wrong value.
    if err := c.rdb.Del(ctx, "product:"+p.ID).Err(); err != nil {
        log.Printf("invalidate product:%s failed: %v", p.ID, err)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Delete first and a failed write evicts a good value; worse, a concurrent read repopulates the stale row in the gap before your write lands. Commit, then invalidate.

Where it stops

Delete-on-write covers writes your service owns. It does not cover a manual SQL update or a second writer on the same table, and the delete is not transactional with the commit, so a crash in between leaves a stale key until the TTL saves you -- which is why the TTL stays mandatory. Stronger guarantees mean change-data-capture or write-through. For a read-heavy endpoint with one clear owner, cache-aside plus singleflight plus delete-on-write plus a sane TTL is the right amount of machinery.

A cache is a copy, and every copy can stampede and go stale. Plan for both on day one.

Top comments (0)