Skip to content
gohacks.dev
Databases

Redis Caching Patterns in Go and Their Failure Modes

Cache-aside with Redis in Go and how it fails: a stampede of 100 loads cut to 1 with singleflight, TTL jitter, negative caching and serving stale data

13 min read
Summarize in AI
Flat illustration of an app reading through a fast cache in front of a database, with the Redis logo.

A cache is the cheapest way to make a service faster and the easiest way to make it fail in a new way. The happy path is one GET. The problems arrive when many requests miss at once, when keys expire together, when the thing being cached does not exist, and when the source is down just as the cache runs out. This guide builds cache-aside over Redis on Go 1.25.6 with go-redis v9.17.0 and measures each failure and its fix, with a simulated database that costs 40 milliseconds per load and counts every call.

Tested with Go 1.25.6, go-redis v9.17.0, x/sync v0.19.0, Redis 7 (redis:7-alpine) in Docker, macOS on Apple Silicon (arm64)

It belongs to the database cornerstone, builds on the go-redis article and complements Postgres full text search and index tuning as ways to make reads cheaper at the source.

Cache-aside

The pattern most Go services use is cache-aside, also called lazy loading. The application owns the logic:

  1. Read the key from Redis. If it is there, return it.
  2. On a miss, load from the database.
  3. Store the value in Redis with a TTL and return it.
main.goGo
package main

import (
	"context"
	"errors"
	"fmt"
	"math/rand/v2"
	"sort"
	"sync"
	"sync/atomic"
	"time"

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

// slowDB stands in for the database: every load costs 40ms and is counted.
type slowDB struct {
	loads atomic.Int64
	fail  atomic.Bool
}

var errDown = errors.New("database unavailable")

func (d *slowDB) Load(id int) (string, error) {
	d.loads.Add(1)
	time.Sleep(40 * time.Millisecond)
	if d.fail.Load() {
		return "", errDown
	}
	if id < 0 {
		return "", errNotFound
	}
	return fmt.Sprintf("product-%d", id), nil
}

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

// Cache is cache-aside over Redis.
type Cache struct {
	rdb   *redis.Client
	db    *slowDB
	group singleflight.Group
}

func (c *Cache) key(id int) string { return fmt.Sprintf("p:%d", id) }

func (c *Cache) Get(ctx context.Context, id int, coalesce bool) (string, error) {
	if v, err := c.rdb.Get(ctx, c.key(id)).Result(); err == nil {
		return v, nil
	}
	load := func() (any, error) {
		v, err := c.db.Load(id)
		if err != nil {
			return "", err
		}
		return v, c.rdb.Set(ctx, c.key(id), v, 300*time.Millisecond).Err()
	}
	if !coalesce {
		v, err := load()
		s, _ := v.(string)
		return s, err
	}
	v, err, _ := c.group.Do(c.key(id), load)
	s, _ := v.(string)
	return s, err
}

func main() {
	ctx := context.Background()
	rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:56379"})
	defer rdb.Close()
	db := &slowDB{}
	c := &Cache{rdb: rdb, db: db}
	reset := func() { rdb.FlushDB(ctx); db.loads.Store(0) }

	// 1. Cache-aside over a skewed workload: 5,000 reads, 20 products.
	reset()
	var wg sync.WaitGroup
	for w := 0; w < 10; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			r := rand.New(rand.NewPCG(uint64(w), 1))
			for i := 0; i < 500; i++ {
				_, _ = c.Get(ctx, r.IntN(20), true)
			}
		}()
	}
	wg.Wait()
	fmt.Printf("1. 5000 reads of 20 products: %d database loads, hit ratio %.1f%%\n", db.loads.Load(), 100*(1-float64(db.loads.Load())/5000))

	// 2. A popular key expires and 100 requests ask for it at once.
	for _, coalesce := range []bool{false, true} {
		reset()
		start := time.Now()
		for i := 0; i < 100; i++ {
			wg.Add(1)
			go func() { defer wg.Done(); _, _ = c.Get(ctx, 7, coalesce) }()
		}
		wg.Wait()
		fmt.Printf("2. 100 concurrent misses on one key, singleflight=%-5v: %3d database loads, %v\n", coalesce, db.loads.Load(), time.Since(start).Round(10*time.Millisecond))
	}

	// 3. Same TTL for every key means the same expiry moment.
	spread := func(jitter time.Duration) (peak int, buckets int) {
		counts := map[int]int{}
		r := rand.New(rand.NewPCG(1, 2))
		for i := 0; i < 10000; i++ {
			ttl := 10 * time.Minute
			if jitter > 0 {
				ttl += time.Duration(r.Int64N(int64(jitter)))
			}
			counts[int(ttl/time.Second)]++
		}
		keys := make([]int, 0, len(counts))
		for k := range counts {
			keys = append(keys, k)
		}
		sort.Ints(keys)
		for _, k := range keys {
			if counts[k] > peak {
				peak = counts[k]
			}
		}
		return peak, len(counts)
	}
	p0, b0 := spread(0)
	p1, b1 := spread(60 * time.Second)
	fmt.Printf("3. 10,000 keys cached with a 10 minute TTL: no jitter expires %d in the worst second (%d distinct seconds); plus up to 60s jitter expires %d in the worst second (%d distinct seconds)\n", p0, b0, p1, b1)

	// 4. Missing ids reach the database every time unless the absence is cached too.
	reset()
	for i := 0; i < 50; i++ {
		_, _ = c.Get(ctx, -1, true)
	}
	fmt.Println("4. 50 lookups of an id that does not exist, no negative caching:", db.loads.Load(), "database loads")
	reset()
	negative := func(id int) {
		if _, err := rdb.Get(ctx, "absent:"+fmt.Sprint(id)).Result(); err == nil {
			return
		}
		if _, err := c.db.Load(id); errors.Is(err, errNotFound) {
			rdb.Set(ctx, "absent:"+fmt.Sprint(id), "1", 100*time.Millisecond)
		}
	}
	for i := 0; i < 50; i++ {
		negative(-1)
	}
	fmt.Println("   the same with a short lived absent marker:", db.loads.Load(), "database load")

	// 5. Serving stale data when the database is down.
	reset()
	stale := func(id int) (string, string) {
		v, err := rdb.Get(ctx, c.key(id)).Result()
		if err == nil {
			return v, "fresh"
		}
		nv, lerr := c.db.Load(id)
		if lerr != nil {
			if old, serr := rdb.Get(ctx, "stale:"+c.key(id)).Result(); serr == nil {
				return old, "stale (database error: " + lerr.Error() + ")"
			}
			return "", "error"
		}
		rdb.Set(ctx, c.key(id), nv, 80*time.Millisecond)
		rdb.Set(ctx, "stale:"+c.key(id), nv, time.Hour)
		return nv, "reloaded"
	}
	v1, s1 := stale(3)
	time.Sleep(120 * time.Millisecond)
	db.fail.Store(true)
	v2, s2 := stale(3)
	fmt.Printf("5. first read %q (%s); after expiry with the database down %q (%s)\n", v1, s1, v2, s2)
}
Text
1. 5000 reads of 20 products: 40 database loads, hit ratio 99.2%
2. 100 concurrent misses on one key, singleflight=false: 100 database loads, 60ms
2. 100 concurrent misses on one key, singleflight=true :   1 database loads, 40ms
3. 10,000 keys cached with a 10 minute TTL: no jitter expires 10000 in the worst second (1 distinct seconds); plus up to 60s jitter expires 199 in the worst second (60 distinct seconds)
4. 50 lookups of an id that does not exist, no negative caching: 50 database loads
   the same with a short lived absent marker: 1 database load
5. first read "product-3" (reloaded); after expiry with the database down "product-3" (stale (database error: database unavailable))

Cache.Get implements exactly that. The first experiment sends 5,000 reads from ten goroutines at 20 products and a TTL of 300 milliseconds. The result: 40 database loads, a hit ratio of 99.2%. Each product was loaded about twice, because every key expired once during the run and was reloaded. The database saw 40 queries instead of 5,000, which is the whole value of the pattern.

Alternatives exist. Write-through writes to the cache and the database together, so reads always hit, at the cost of caching data that may never be read. Write-behind acknowledges after the cache and writes to the database later, which is fast and risks losing data. Read-through puts the loading logic inside the cache layer. Cache-aside needs no special cache and tolerates a cold or empty cache, which is why it is the default. The rest of this article is about what it costs you.

Invalidation: the part nobody gets right

When the data changes, the cache must not lie. Three honest options:

  • Let the TTL decide. Accept that readers may see data up to a TTL old. Cheapest and right for anything where staleness is tolerable, such as a product description or a public profile.
  • Delete on write. After updating the row, DEL the key. The next read reloads. Delete rather than overwrite: concurrent writers can store values in the wrong order, but a deleted key is always rebuilt from the database.
  • Version the key. Include a version or an updated_at in the key, so a change creates a new key and the old one ages out.

Delete on write has a race: a reader that missed before the write can store the old value after the delete. The window is small, the TTL bounds the damage, and for money or permissions, do not cache, or cache with a very short TTL.

Stampede

The second experiment is the classic outage. A popular key expires. In the same instant 100 requests ask for it, all miss, and all 100 load from the database: output 100 concurrent misses on one key, singleflight=false: 100 database loads, 60ms. On a real database with a slow query, that is a hundred copies of the expensive query running at once, exactly when the cache was supposed to be protecting it, and it can take the database down.

The fix is to collapse concurrent loads for the same key into one. Go has the tool in golang.org/x/sync/singleflight: the first caller runs the loader and the others wait and share its result. With it the same experiment shows 1 database load, in 40ms. In Get, the miss path wraps the load in group.Do(key, ...).

Two limits to know. singleflight works inside one process, so with 20 instances you get 20 loads, which is acceptable. For one load across all instances, take a short lock in Redis before loading, as in distributed locks, and have the losers wait and re-read the cache. And a failure is shared too: if the one loader fails, every waiting caller gets that error, so keep the loader's timeout short.

A related defence is early refresh. Store the expiry inside the value, or keep a second, shorter timer, and refresh in the background a little before the real TTL ends, so users keep getting the cached value while one request renews it.

Expiry that arrives together

Setting the same TTL on keys written at the same time makes them expire at the same time. The third experiment counts how many of 10,000 keys with a 10 minute TTL expire in each second. Without jitter, all 10,000 expire in one second. With up to 60 seconds of random jitter added to the TTL, the worst second sees 199, spread across 60 seconds. A warmed cache after a deploy, or a batch import that sets many keys at once, is the usual way to get the first outcome, and the second is one line of code. Add jitter of ten percent or so to every TTL you set in bulk.

Caching the answer "no"

The fourth experiment looks up an id that does not exist. A cache that stores only found values never stores anything, so every lookup goes to the database: 50 lookups, 50 loads. This is a common source of load, and an attack: a client asking for random ids bypasses the cache entirely. The fix is negative caching, storing a marker for "does not exist" with a short TTL. With a 100ms marker the same 50 lookups made 1 load. Keep the TTL short, seconds to a minute, because the item may be created soon, and delete the marker when it is. A Bloom filter in front of the cache is the next step when the key space is large, and it is outside this article's scope.

Serving stale when the source fails

The last experiment keeps two copies: the normal value with a short TTL, and a stale copy with a long one. The first read loaded the value (reloaded). After the short TTL passed and the database was made to fail, the second read tried to reload, got an error, and returned the old value, labelled stale (database error: database unavailable). Without the stale copy that request would have failed. This is "stale on error", and for many read paths an outage of the database becomes an outage of freshness. Combine it with a circuit breaker: after a few failures, stop calling the database for a while and serve stale directly, so you do not spend every request on a timeout. The same idea, applied to the normal path, is "stale while revalidate": serve the stale value immediately and refresh in the background.

It costs memory, and it needs a rule for how stale is acceptable. A price served from a copy that is an hour old might be wrong in a way that matters; a blog post is not.

A typed wrapper

Repeating the get, miss, load, set sequence in every handler invites mistakes, such as a missing TTL or a forgotten redis.Nil check. Wrap it once, with generics so callers get their own type back:

Go
func Cached[T any](ctx context.Context, c *Cache, key string, ttl time.Duration, load func(context.Context) (T, error)) (T, error) {
	var zero T
	if raw, err := c.rdb.Get(ctx, key).Bytes(); err == nil {
		var v T
		if json.Unmarshal(raw, &v) == nil {
			return v, nil
		}
	}
	v, err, _ := c.group.Do(key, func() (any, error) {
		out, err := load(ctx)
		if err != nil {
			return zero, err
		}
		raw, _ := json.Marshal(out)
		c.rdb.Set(ctx, key, raw, jitter(ttl))
		return out, nil
	})
	if err != nil {
		return zero, err
	}
	return v.(T), nil
}

Four decisions are in those lines. A decode failure is a miss, so a changed value format heals itself. A failed Set is ignored, because the value is already in hand and a cache write that fails must not fail the request. The loader runs under singleflight. And the TTL passes through a jitter helper, so no caller can forget it. The one thing the wrapper cannot decide is the key, which must include everything the value depends on: the tenant, the language, the version.

Two levels

A small in-process cache in front of Redis removes the network round trip for the hottest keys. A map with a mutex, or a library such as ristretto or otter, holding a few thousand entries for a few seconds, can absorb most of the traffic to a handful of keys and cut Redis load sharply. The price is that every instance has its own copy, so a change takes up to the in-process TTL to appear everywhere, and invalidation by DEL in Redis does not reach the local copies. Use a short local TTL, a few seconds, and treat it as an optimisation of the hit path and not as a place for data that must change quickly. Publish invalidations over Pub/Sub, as described in Redis Streams and Pub/Sub, if local copies must drop a key at once; accept that a missed message is covered by the TTL.

Warming and cold starts

A cache that starts empty sends the full load to the database until it fills. After a deploy or a Redis restart the first minute can be worse than steady state, and the stampede protection above only helps per key. Two practical measures. Warm the keys you know are hot before taking traffic, from a list of the top products or the most recent ones, in batches with a pause so the warming itself does not overload the source. And roll deploys gradually, so the local caches of a few instances are cold at a time, not all of them. If Redis restarts empty, a rate limit on cache misses at the loader (a semaphore with a small capacity) keeps the database alive while the cache refills, at the price of some requests waiting or failing fast.

Testing a cache

Test the behaviour, not the library. A test with a counting loader asserts that two reads make one load, that a hundred concurrent reads make one, that an expired key reloads, that a loader error is not cached (an important one: caching an error for the whole TTL turns one blip into minutes of failures), and that a Redis error becomes a miss and the request still succeeds. The last is easy to check by pointing the client at a closed port. Use a real Redis for the stampede and expiry tests, since they depend on timing, and a fake for the rest; see testing Redis code with miniredis.

Keys, values and size

  • Key naming. service:version:entity:id, with the version bumped when the value's format changes, so a deploy does not read values it cannot decode.
  • Value format. JSON is readable and bigger; a binary format is smaller and faster; both are fine. Cache the shape the caller needs, not the raw row.
  • Size. Redis handles large values and punishes them: a 5MB value blocks the server while it is copied. Keep values small, and break big objects up.
  • Always set an expiry. A cache key without a TTL is a leak. Set maxmemory and allkeys-lru so a full cache evicts instead of failing.

Measuring a cache

A cache you cannot measure is a cache you cannot trust. Count hits, misses, loads, load errors and load latency, per cache name, and watch the hit ratio, a falling one means the TTL is too short, the key space is too large or something changed the keys. Watch evictions: a rising count means Redis is too small for the working set. Watch the coalesced count from singleflight (the shared value it returns) to see how many stampedes you avoided. And measure with and without: sometimes a well indexed query at 2ms is not worth a cache with its invalidation bugs, and the honest answer to a slow endpoint is a better index, as in index basics and reading EXPLAIN.

When not to cache

Do not cache what is cheap to compute, what changes on nearly every read, what is different for every user (the hit ratio will be near zero), or what must be correct at the moment of reading: stock levels at checkout, balances, permissions. Do not cache to hide a slow query you have not examined. And remember that a cache is one more system that can be down, slow or wrong, so the code must survive its absence: every Redis error on the read path should become a miss, as covered in the go-redis guide.

The singleflight package is documented on pkg.go.dev, and the eviction policies and memory settings in the Redis page on key eviction.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.