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.
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 | |
|---|---|---|
| Latency | Nanoseconds | About a millisecond over the network |
| Shared across replicas | No: each instance has its own copy | Yes |
| Invalidation | Only the local instance hears about it | One DEL reaches every reader |
| Survives app restarts | No | Yes (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:
go get github.com/redis/go-redis/v9
docker run --rm -p 6379:6379 redis:8rdb := 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:
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:
// 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:
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 TTLDeleting 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.
loads after 100 concurrent gets: 1One 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:
- 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. - Prove it: call
Get(ctx, 404)ten times and print the store's load count (it should be 1). - Then create product 404 through
Update, callGetagain, 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.
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.
not found (printed 10 times)
loads: 1
{404 New 1} <nil> 2Key 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
SETwith a TTL; on writes, update the database and thenDELthe 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
KEYSfor bulk invalidation. - Use
singleflightto collapse concurrent misses for the same key into one load.
Check yourself
Quiz
0/4 correct · 4 left
1.In the cache-aside pattern, what should the write path do after successfully updating a product in the database?
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.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.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.