# Caching with Redis

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

Backend with Go roadmap · Data · 15 min read · https://levelcode.org/roadmaps/go-backend/caching

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`:

```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.

> [!WARNING]
> Make sure the database can survive the load without the cache. If Redis restarts, every key is a miss at once. A service that only works with a warm cache is one Redis failover away from an outage.

## 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.

> [!TIP]
> Set `maxmemory` and `maxmemory-policy allkeys-lru` on a Redis instance used purely as a cache. When memory fills, Redis then evicts the least recently used keys instead of rejecting writes.

## 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".

**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.

## Quiz

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

   A. Write the new product into the cache with SET
   B. Delete the product's cache key
   C. Nothing; the TTL will eventually expire the old value
   D. Flush the whole Redis database

   **Answer: B.** Deleting the key makes the next read load the current row from the database. Overwriting with SET can race with another writer and leave the cache holding an older value than the database until the TTL.

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?

   A. Return HTTP 503 to the client
   B. Retry the Redis call until it succeeds
   C. Panic so the orchestrator restarts the service
   D. Log the error and load the value from the database

   **Answer: D.** The cache is an optimisation and the database is the source of truth. Falling back keeps the service working, just slower. redis.Nil, by contrast, simply means a cache miss.

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?

   A. Only one database query runs; the other callers wait and share its result
   B. The key never expires again
   C. Redis queues the 300 GETs so they arrive one at a time
   D. Each request gets its own database connection so they run faster

   **Answer: A.** singleflight deduplicates concurrent calls with the same key inside one process. It doesn't change expiry or Redis behaviour; across N replicas you get at most N loads.

4. Why add random jitter to TTLs?

   A. Redis rejects identical TTLs on many keys
   B. It makes cached values harder to guess for attackers
   C. Keys written together then don't all expire at the same instant and hit the database in one burst
   D. Jitter reduces the memory each key uses

   **Answer: C.** Keys cached in the same moment with identical TTLs expire together, producing a synchronised wave of misses. Spreading expiry over a window smooths the reload.
