Step 17 of 24 · optional

Caching with Redis

Add a Redis cache-aside layer in Go with sensible TTLs, correct invalidation and protection against cache stampedes.

15 min 8 sections

A cache stores the result of expensive work so the next request can skip it. Done well, it turns a 40 ms database query into a 1 ms lookup and lets the same database serve ten times the traffic. Done badly, it serves stale prices, hides bugs and fails in ways that are hard to reproduce. This lesson builds a Redis cache in front of a Go store using the cache-aside pattern, then covers the parts that decide whether it's correct: expiry, invalidation and stampedes.

When a cache is worth it

Caching trades freshness for speed. Before adding one, check three things:

  • The data is read far more often than it changes. A product page viewed 10,000 times per edit is ideal. A bank balance that changes every few seconds is not.
  • Some staleness is acceptable. If a price can be wrong for 60 seconds, you can cache it for 60 seconds. If it must never be wrong, cache something else.
  • You've measured. Often a missing index or an N+1 query is the real problem. Fix that first; a cache in front of a slow query just hides it until the cache is empty.

Where the cache lives matters too:

In-process (a map or LRU in your Go program)Redis
LatencyNanosecondsAbout a millisecond over the network
Shared across replicasNo: each instance has its own copyYes
InvalidationOnly the local instance hears about itOne DEL reaches every reader
Survives app restartsNoYes (until evicted, expired or Redis itself restarts)

With several replicas, an in-process cache means each one can hold a different version of the same data. Redis gives every instance one shared view, which is why it's the usual choice for API servers.

Redis from Go

Redis is an in-memory key-value server. The standard Go client is go-redis:

bash
go get github.com/redis/go-redis/v9
docker run --rm -p 6379:6379 redis:8
go
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
defer rdb.Close()

if err := rdb.Ping(ctx).Err(); err != nil {
	return fmt.Errorf("redis: %w", err)
}

// SET with a time-to-live: Redis deletes the key after 5 minutes.
err := rdb.Set(ctx, "product:v1:42", `{"id":42}`, 5*time.Minute).Err()

// GET returns redis.Nil when the key doesn't exist.
val, err := rdb.Get(ctx, "product:v1:42").Result()
if errors.Is(err, redis.Nil) {
	// cache miss
}

Every command returns a typed result (*StringCmd, *StatusCmd, ...) with .Err(), .Result() and conversions such as .Bytes(). Like pgx, the client is a connection pool, safe for concurrent use: create one at startup and share it.

Name keys type:version:id, such as product:v1:42. The type keeps keys readable in redis-cli; the version lets you change the cached format later by bumping v1 to v2, which makes every old entry unreachable at once.

Cache-aside, step by step

In cache-aside (also called lazy loading), application code manages the cache explicitly. On a read: check the cache; on a miss, load from the database and store the result. On a write: update the database, then delete the cached copy. The database stays the source of truth, and the cache only ever holds copies.

Wrap your existing store so handlers don't know the cache exists:

go
type ProductStore interface {
	Get(ctx context.Context, id int64) (Product, error)
	Update(ctx context.Context, p Product) error
}

type CachedProducts struct {
	store ProductStore
	rdb   *redis.Client
	ttl   time.Duration
	group singleflight.Group
}

func productKey(id int64) string { return "product:v1:" + strconv.FormatInt(id, 10) }

func (c *CachedProducts) Get(ctx context.Context, id int64) (Product, error) {
	key := productKey(id)

	// 1. Try the cache.
	b, err := c.rdb.Get(ctx, key).Bytes()
	switch {
	case err == nil:
		var p Product
		if err := json.Unmarshal(b, &p); err == nil {
			return p, nil
		}
		// A corrupt entry is treated as a miss.
	case !errors.Is(err, redis.Nil):
		// Redis is down or slow: log it and fall back to the database.
		slog.WarnContext(ctx, "cache get failed", "key", key, "err", err)
	}

	// 2. Miss: load from the source of truth, once per key per process.
	v, err, _ := c.group.Do(key, func() (any, error) {
		p, err := c.store.Get(ctx, id)
		if err != nil {
			return nil, err
		}
		// 3. Populate the cache for next time.
		if b, err := json.Marshal(p); err == nil {
			if err := c.rdb.Set(ctx, key, b, jitter(c.ttl)).Err(); err != nil {
				slog.WarnContext(ctx, "cache set failed", "key", key, "err", err)
			}
		}
		return p, nil
	})
	if err != nil {
		return Product{}, err
	}
	return v.(Product), nil
}

func (c *CachedProducts) Update(ctx context.Context, p Product) error {
	if err := c.store.Update(ctx, p); err != nil {
		return err
	}
	// Delete, don't overwrite: the next read repopulates from the database.
	if err := c.rdb.Del(ctx, productKey(p.ID)).Err(); err != nil {
		slog.WarnContext(ctx, "cache invalidate failed", "id", p.ID, "err", err)
	}
	return nil
}

Notice that a Redis error never fails the request. The cache is an optimisation, so when Redis is unavailable the service gets slower, not broken. Also give the Redis client tight timeouts (ReadTimeout, WriteTimeout in redis.Options) so a struggling cache can't stall every request. The group field and jitter function are explained below.

Choosing TTLs

Every cached entry should have a time-to-live. The TTL is your upper bound on staleness if an invalidation is ever missed, and it stops the cache filling with keys nobody reads. Pick it from the business question "how wrong can this be, and for how long?": seconds for stock levels, minutes for product details, hours for a list of countries.

If you warm 10,000 keys at once with the same TTL, they also expire at once, and the database takes the whole reload in one burst. Add a little randomness:

go
// jitter returns d plus or minus up to 10%.
func jitter(d time.Duration) time.Duration {
	spread := int64(d) / 5
	return d - time.Duration(spread/2) + time.Duration(rand.Int64N(spread+1))
}

Missing records deserve attention too. If clients request IDs that don't exist, every request is a miss that reaches the database, which is an easy way to overload you. Negative caching stores "not found" for a short TTL. You'll build it in the practice task.

Invalidation: delete, don't update

When data changes, you must stop serving the old copy. It's tempting to write the new value into the cache, but under concurrency that can leave the cache permanently wrong:

text
Request A: UPDATE price = 10 in DB
Request B: UPDATE price = 12 in DB
Request B: SET cache = 12
Request A: SET cache = 10        <- cache now disagrees with the DB until the TTL

Deleting avoids this: both requests delete the key, and the next reader loads whatever the database holds now. Two more rules:

  • Invalidate after the database commit. If you delete first, a concurrent reader can repopulate the cache from the old row before your transaction commits.
  • Accept a small remaining race. A reader can still load the old row, stall, and write it back just after your delete. That window is tiny, and the TTL bounds it. If your data can't tolerate even that, it probably shouldn't be cached.

For invalidating many keys at once, such as every page of a product listing, don't search for keys. KEYS product:* blocks Redis while it scans the whole keyspace. Instead, put a version number in the key prefix (products:list:v7:page:3) and bump the version when anything changes; the old keys simply expire.

Stampedes and singleflight

A popular key expires, and in the next millisecond 500 requests all miss, all query the database for the same row, and all write the same value back. That's a cache stampede, and it can take down a database that normally handles the load easily.

The singleflight package (golang.org/x/sync/singleflight) fixes this within each process. group.Do(key, fn) runs fn once for concurrent callers with the same key; everyone else waits and receives the same result. With ten replicas, a stampede becomes at most ten database queries instead of 500.

text
loads after 100 concurrent gets: 1

One subtlety: the shared function runs with the first caller's context. If that client disconnects, every waiter receives a cancellation error. For hot keys, load with context.WithoutCancel(ctx) plus your own timeout.

Practice

Using CachedProducts and an in-memory ProductStore that sleeps 50 ms and counts how often Get is called:

  1. Add negative caching: when the store returns ErrNotFound, cache that fact for 30 seconds so repeated requests for a missing ID don't reach the store.
  2. Prove it: call Get(ctx, 404) ten times and print the store's load count (it should be 1).
  3. Then create product 404 through Update, call Get again, and confirm you now get the product, not a cached "not found".
Show solution

Store an empty string as the "does not exist" marker. A real product always encodes to non-empty JSON, so the two can't be confused.

go
const notFoundTTL = 30 * time.Second

func (c *CachedProducts) Get(ctx context.Context, id int64) (Product, error) {
	key := productKey(id)

	b, err := c.rdb.Get(ctx, key).Bytes()
	switch {
	case err == nil && len(b) == 0:
		return Product{}, ErrNotFound // cached "does not exist"
	case err == nil:
		var p Product
		if err := json.Unmarshal(b, &p); err == nil {
			return p, nil
		}
	case !errors.Is(err, redis.Nil):
		slog.WarnContext(ctx, "cache get failed", "key", key, "err", err)
	}

	v, err, _ := c.group.Do(key, func() (any, error) {
		p, err := c.store.Get(ctx, id)
		if errors.Is(err, ErrNotFound) {
			c.rdb.Set(ctx, key, "", notFoundTTL) // best effort
			return nil, err
		}
		if err != nil {
			return nil, err
		}
		if b, err := json.Marshal(p); err == nil {
			c.rdb.Set(ctx, key, b, jitter(c.ttl))
		}
		return p, nil
	})
	if err != nil {
		return Product{}, err
	}
	return v.(Product), nil
}

Step 3 works without changes: Update deletes the key, so the next Get misses and loads the new product.

text
not found   (printed 10 times)
loads: 1
{404 New 1} <nil> 2

Key takeaways

  • Cache data that is read much more than it changes and can tolerate bounded staleness; measure and fix slow queries first.
  • Cache-aside: read from cache, on a miss load from the database and SET with a TTL; on writes, update the database and then DEL the key.
  • Treat Redis as optional: log its errors, fall back to the database, and make sure the database survives a cold cache.
  • Every key gets a TTL, with jitter, and missing records get a short negative-cache TTL.
  • Invalidate by deleting after commit, never by overwriting; use versioned key prefixes instead of KEYS for bulk invalidation.
  • Use singleflight to collapse concurrent misses for the same key into one load.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.In the cache-aside pattern, what should the write path do after successfully updating a product in the database?

  2. 2.rdb.Get(ctx, key).Bytes() returns an error that is not redis.Nil because the Redis server is unreachable. What should a cache-aside Get do?

  3. 3.A hot key expires and 300 concurrent requests miss at once in a single Go instance. What does wrapping the database load in singleflight.Group.Do(key, ...) achieve?

  4. 4.Why add random jitter to TTLs?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.