Caching Strategies in Distributed Systems: Cache-Aside, Write-Through, and Surviving the Thundering Herd

Almost every service that survives its own traffic eventually grows a cache. The idea is simple: the database is slow and far away, Redis is fast and close, so you put the fast thing in front of the slow thing. The practice is harder. The moment two copies of a piece of data exist, every write becomes a small distributed-systems problem, and every expiry becomes a scheduled load test against your database.

This post walks through the caching patterns you actually choose between in a Go service — cache-aside, write-through, write-behind, and refresh-ahead — and then the operational details: how TTL expiry differs from eviction, why a cluster of expiring keys can take your database down, and what to do about it. Examples use go-redis, the standard Go client for Redis.

By the end you’ll have a decision model for picking a strategy, plus one coherent Get implementation — singleflight, jittered TTLs, early refresh, negative caching — that you can ship on your hottest read path.

Cache-Aside, Write-Through, Write-Behind, Refresh-Ahead

Cache-aside (lazy loading) is the default choice and the one most Go services start with. Your code consults the cache first; on a miss it loads from the database, writes the value back to the cache, and returns. The database remains the single source of truth, and the cache is purely an optimization you can drop at any time. The cost: the first read of every key is slow, every read path carries caching logic, and there’s a stale window after each write unless you invalidate explicitly.

Write-through flips the responsibility. Writes go to the cache, and the cache synchronously writes through to the database before acknowledging. Reads of recently written data are always warm, and consistency between cache and database is tight. The cost: every write pays a cache round trip plus a database round trip, and write-heavy keys nobody reads churn cache memory for no benefit.

Write-behind (write-back) acknowledges the write from the cache and flushes to the database asynchronously, usually on a timer or when a batch fills up. Writes become very fast and database load becomes smooth and batchable. The cost is durability: if the cache process dies before flushing, acknowledged writes are lost, and keeping ordering and retry semantics correct is genuinely hard. Reach for it only when you can quantify the acceptable loss window.

Refresh-ahead targets the hot-key tail. The cache monitors entries that are being read frequently and proactively reloads them before their TTL expires, so popular keys never incur a miss. It composes well with cache-aside but adds a scheduler and prediction heuristics, and it only helps keys you can identify as hot.

Quick trade-off summary:

  • Cache-aside: simplest, resilient to cache loss, stale window after writes; best default for read-heavy services.
  • Write-through: always-warm reads, tight consistency; slower writes, cache churn on write-heavy keys.
  • Write-behind: fastest writes, batchable database load; risk of data loss and complex failure handling.
  • Refresh-ahead: no miss spikes on hot keys; extra machinery, only pays off for identifiable hot sets.

Here is cache-aside in its raw form, with go-redis v9 — note the careful distinction between a miss (redis.Nil) and Redis itself being down:

package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

var errNotFound = errors.New("record not found")

// loadUserFromDB stands in for your real database lookup.
func loadUserFromDB(ctx context.Context, id string) (string, error) {
	return fmt.Sprintf(`{"id":%q,"name":"Ada Lovelace"}`, id), nil
}

func GetUser(ctx context.Context, client *redis.Client, id string) (string, error) {
	key := "user:" + id

	val, err := client.Get(ctx, key).Result()
	if err == nil {
		return val, nil // cache hit
	}
	if !errors.Is(err, redis.Nil) {
		return "", err // Redis is down: degrade, don't misread it as a miss
	}

	user, err := loadUserFromDB(ctx, id)
	if err != nil {
		return "", err
	}

	// Best effort: a failed fill costs performance, not correctness.
	_ = client.Set(ctx, key, user, 10*time.Minute).Err()
	return user, nil
}

Expiry Is Not Eviction

Two completely different mechanisms remove keys from Redis, and conflating them causes real incidents. Expiry is logical: you set a TTL, and after that deadline the key is officially gone. Redis enforces it partly lazily (expired keys are detected when accessed) and partly actively (a background task samples the keyspace about ten times per second), so a key may physically vanish slightly after its deadline — but it stops being returned either way.

Eviction is physical: the server hit maxmemory and had to throw something out under its eviction policy — allkeys-lru, allkeys-lfu, volatile-lru, volatile-ttl, noeviction, and friends. Two consequences follow. First, a key with hours of TTL left can be evicted right now if memory pressure says so, so your cache layer must treat “key absent” as a normal event even for keys you just wrote. Second, under volatile-* policies, keys with no TTL are never evicted — they are immortal until deleted, which is how a cache instance slowly fills with junk. Put a TTL on everything you can rebuild, size maxmemory against your working set, and watch evicted_keys and hit ratio rather than finding out from pager alerts.

The Thundering Herd, and How to Survive It

A cache stampede is what happens when a hot key expires and every request that arrives during the reload window independently decides to fetch from the database. Ten thousand requests per second against one expired key means ten thousand identical database queries, and a reload that would take five milliseconds takes seconds because the database is busy serving duplicates. The same thing happens when you pre-fill keys with identical TTLs — they expire as a synchronized wave.

The first fix is request deduplication. Go’s singleflight package collapses concurrent identical calls into one: the first caller executes the function, everyone else waits on the same in-flight result. It is per-process, so a fleet of forty instances still sends at most forty database queries instead of ten thousand — usually enough. When it is not, add a short lock key in Redis or an external refresher so requests never load at all.

package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
	"golang.org/x/sync/singleflight"
)

var (
	rdb   = redis.NewClient(&redis.Options{Addr: "localhost:6379"})
	loads singleflight.Group
)

func GetProfile(ctx context.Context, id string) (string, error) {
	key := "profile:" + id

	val, err := rdb.Get(ctx, key).Result()
	if err == nil {
		return val, nil
	}
	if !errors.Is(err, redis.Nil) {
		return "", err
	}

	// Concurrent misses collapse into one database call per process.
	v, err, shared := loads.Do("load:"+id, func() (any, error) {
		profile, dbErr := loadProfileFromDB(ctx, id)
		if dbErr != nil {
			return nil, dbErr
		}
		if setErr := rdb.Set(ctx, key, profile, 10*time.Minute).Err(); setErr != nil {
			fmt.Printf("cache fill failed: %v\n", setErr)
		}
		return profile, nil
	})
	if err != nil {
		return "", err
	}
	if shared {
		fmt.Printf("key %s: one load shared by many waiters\n", id)
	}
	return v.(string), nil
}

func loadProfileFromDB(ctx context.Context, id string) (string, error) {
	return fmt.Sprintf(`{"id":%q}`, id), nil
}

The second fix is spreading the expiry itself. Jittered TTLs break up synchronized waves: instead of every key living exactly ten minutes, each lives ten minutes plus or minus ten percent, so a batch written together expires scattered across a two-minute window. Probabilistic early expiration goes further: as a hot key approaches expiry, each read has an increasing chance of triggering a background refresh, so hot keys are usually replaced before the TTL lapses and the miss spike never materializes. Cold keys just expire quietly — no wasted refreshes for data nobody reads:

package main

import (
	"math/rand"
	"time"
)

// jitterTTL spreads expiry times so a batch of keys written together
// doesn't expire in one synchronized wave.
func jitterTTL(base time.Duration) time.Duration {
	spread := 0.1 * float64(base) // ±10%
	return base + time.Duration((2*rand.Float64()-1)*spread)
}

// earlyRefreshHit decides whether this request should trigger a
// background refresh. Inside the final quarter of the TTL, the
// probability climbs from 0 toward 1 as expiry approaches.
func earlyRefreshHit(remaining, base time.Duration) bool {
	window := base / 4
	if remaining <= 0 || remaining > window {
		return false
	}
	return rand.Float64() > float64(remaining)/float64(window)
}

Negative Caching: Cache the Misses Too

The pathologically bad case for cache-aside is a key that doesn’t exist — a deleted user, a malformed ID, a scraper probing URLs that will never resolve. Those requests miss the cache and hit the database every time, so your most expensive queries are often for nothing. The fix is to cache the absence: store a sentinel value with a short TTL and return “not found” from the cache until it lapses.

Two disciplines keep this safe. Keep the negative TTL short — if a record can be created, a stale “absent” answer is a correctness bug, so the window must be one your product can tolerate. And only cache a confirmed absence: a database timeout is not a miss, and caching it as one would mask an outage. Reserve the sentinel for “the source of truth answered no.”

Invalidation: The Actually Hard Problem

TTLs bound staleness; they don’t provide correctness. If a user updates their profile and immediately reloads the page, serving nine more minutes of stale data because the TTL said so is a bug report, not a cache policy. That makes invalidation the hard part: everything else in this post is performance tuning, but wrong invalidation is wrong answers.

The workable pattern is event-driven invalidation. On write, commit to the database first, then publish an invalidation event; every service instance subscribes and drops the affected key when the event arrives. A pub/sub channel in Redis is the simplest transport, and for local in-process caches Redis also offers server-assisted client-side caching, which pushes invalidation to clients for you. The caveats: pub/sub is fire-and-forget, so a subscriber that is down misses events — pair the event stream with a modest TTL as a safety net, or use a persisted channel if consistency requirements are strict.

package main

import (
	"context"
	"fmt"

	"github.com/redis/go-redis/v9"
)

// SaveUser commits to the database first, then broadcasts the
// invalidation — never the other way around.
func SaveUser(ctx context.Context, rdb *redis.Client, id, name string) error {
	if err := saveUserInDB(ctx, id, name); err != nil {
		return err
	}
	return rdb.Publish(ctx, "cache:invalidate", "user:"+id).Err()
}

// InvalidateLoop subscribes and drops keys as events arrive.
func InvalidateLoop(ctx context.Context, rdb *redis.Client) {
	sub := rdb.Subscribe(ctx, "cache:invalidate")
	defer sub.Close()

	for msg := range sub.Channel() {
		if err := rdb.Del(ctx, msg.Payload).Err(); err != nil {
			fmt.Printf("failed to invalidate %s: %v\n", msg.Payload, err)
		}
	}
}

func saveUserInDB(ctx context.Context, id, name string) error {
	return nil // your transaction lives here
}

Putting It Together: A Cache-Aside Get That Survives Its Own Popularity

Everything above composes into one Get. On a hit it checks the remaining TTL and may trigger a deduplicated background refresh inside the last quarter of the key’s lifetime. On a miss it collapses the herd with singleflight, loads from the source, and fills the cache with a jittered TTL — or caches a confirmed absence with a short negative TTL. If Redis itself is failing it degrades to the source instead of erroring. The client behind it all is github.com/redis/go-redis:

package main

import (
	"context"
	"errors"
	"fmt"
	"math/rand"
	"time"

	"github.com/redis/go-redis/v9"
	"golang.org/x/sync/singleflight"
)

var errNotFound = errors.New("record not found")

const (
	baseTTL   = 10 * time.Minute
	negTTL    = 30 * time.Second
	negMarker = "\x00absent" // sentinel: source of truth says the key has no value
)

type UserCache struct {
	rdb  *redis.Client
	sf   singleflight.Group
	Load func(ctx context.Context, id string) (string, error) // the source of truth
}

func jitter(d time.Duration) time.Duration {
	return d + time.Duration((2*rand.Float64()-1)*0.1*float64(d))
}

func (c *UserCache) Get(ctx context.Context, id string) (string, error) {
	key := "user:" + id

	val, err := c.rdb.Get(ctx, key).Result()
	switch {
	case errors.Is(err, redis.Nil): // miss: collapse the herd
		return c.getOrLoad(ctx, key, id)
	case err != nil: // Redis failing: degrade to the source, still deduplicated
		return c.getOrLoad(ctx, key, id)
	case val == negMarker: // cached absence
		return "", errNotFound
	}

	// Hit. Probabilistic early refresh in the final quarter of the TTL.
	remaining, ttlErr := c.rdb.TTL(ctx, key).Result()
	if ttlErr == nil && remaining > 0 && remaining < baseTTL/4 {
		if rand.Float64() > float64(remaining)/(baseTTL/4) {
			// Detach from the request lifecycle; singleflight dedups.
			go c.sf.Do(key, func() (any, error) {
				return c.loadAndFill(context.WithoutCancel(ctx), key, id)
			})
		}
	}
	return val, nil
}

// getOrLoad runs at most one load per key per process at a time.
func (c *UserCache) getOrLoad(ctx context.Context, key, id string) (string, error) {
	v, err, _ := c.sf.Do(key, func() (any, error) {
		return c.loadAndFill(ctx, key, id)
	})
	if err != nil {
		return "", err
	}
	return v.(string), nil
}

func (c *UserCache) loadAndFill(ctx context.Context, key, id string) (string, error) {
	user, err := c.Load(ctx, id)
	if err != nil {
		if errors.Is(err, errNotFound) {
			// Negative caching: remember a confirmed absence, briefly.
			_ = c.rdb.Set(ctx, key, negMarker, jitter(negTTL)).Err()
		}
		return "", err // never cache a backend failure as a miss
	}
	_ = c.rdb.Set(ctx, key, user, jitter(baseTTL)).Err()
	return user, nil
}

func main() {
	rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
	cache := &UserCache{
		rdb: rdb,
		Load: func(ctx context.Context, id string) (string, error) {
			// Pretend this hits Postgres; return errNotFound for absent IDs.
			return fmt.Sprintf(`{"id":%q,"name":"Ada Lovelace"}`, id), nil
		},
	}
	user, err := cache.Get(context.Background(), "42")
	fmt.Println(user, err)
}

Wrapping Up

The strategy choice is the easy part: cache-aside remains the right default for most read-heavy Go services, with write-through where read-your-writes matters, write-behind only where you can afford to lose a flush window, and refresh-ahead layered onto genuinely hot keys. Durability comes from the details: expiry and eviction are different failure modes, jitter TTLs so keys don’t expire as a herd, deduplicate reloads with singleflight, cache confirmed absences, and treat invalidation as a correctness problem that events plus TTLs only mostly solve.

None of this is exotic; it is a small set of compositional habits. Wire the final Get into your hottest read path, watch the hit ratio and evicted_keys for a week, and caching stops being scary.

Leave a Reply

Your email address will not be published. Required fields are marked *