Caching in Redis

Put the cache in Redis so every replica reads the same value, store bytes with encoding/json, and treat redis.Nil as a miss.

Syntaxb, err := rdb.Get(ctx, key).Bytes() // then rdb.Set(ctx, key, b, ttl).Err()

An in-process cache lives inside one program, so ten replicas keep ten different copies and a restart loses all of them. Redis is a separate program that holds keys and values in memory and answers questions over the network. Every replica talks to the same Redis, so they agree with each other, and a deploy no longer empties the cache.

Cache aside

The usual shape is called cache aside, and it is three steps. Read the key; if it is there, return it. If it is not, load from the real source. Write it back with a TTL, the time to live after which Redis deletes it. The Go driver is github.com/redis/go-redis/v9, added with go get github.com/redis/go-redis/v9. Create one *redis.Client for the whole program: it is safe for use by many goroutines and keeps a pool of connections open.

A missing key arrives as an error, redis.Nil. That is a miss, not a fault. Check for it with errors.Is(err, redis.Nil) before treating an error as real.

Serialising safely

Redis stores bytes, so a struct has to be encoded. encoding/json is the common choice. Get(ctx, key).Bytes() is the short form of calling .Result() for a string and converting that string to bytes; both give the same bytes. Put a version in the key name, as in user:v1:42. When the struct changes shape, move to v2 so new code can never decode a value written by old code.

Two traps. Always pass a TTL: Set with 0 keeps the key forever. And a cache that is down must not take the service with it, so on a Redis error log it and read the real source.

Example

Example · go
package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"time"

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

type user struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

// loadUser stands in for the slow source of truth, such as a SQL query.
func loadUser(id int) user { return user{ID: id, Name: "ada"} }

func getUser(ctx context.Context, rdb *redis.Client, id int) (user, error) {
	key := fmt.Sprintf("user:v1:%d", id) // the version is part of the name
	var u user
	b, err := rdb.Get(ctx, key).Bytes()
	if err == nil {
		fmt.Println("hit", key)
		err = json.Unmarshal(b, &u)
		return u, err
	}
	if !errors.Is(err, redis.Nil) { // redis.Nil only means: no such key
		return u, err
	}
	fmt.Println("miss", key)
	u = loadUser(id)
	b, err = json.Marshal(u)
	if err != nil {
		return u, err
	}
	return u, rdb.Set(ctx, key, b, 5*time.Minute).Err()
}

func main() {
	ctx := context.Background()
	rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
	if err := rdb.Del(ctx, "user:v1:7").Err(); err != nil {
		panic(err) // start from a known empty key
	}
	for range 2 {
		u, err := getUser(ctx, rdb, 7)
		if err != nil {
			panic(err)
		}
		fmt.Println(u.Name)
	}
}

// Output:
// miss user:v1:7
// ada
// hit user:v1:7
// ada

When to use it

  • Share a session or token lookup across every replica behind a load balancer.
  • Keep an expensive report warm through a deploy, when every pod is replaced.
  • Cache a third-party API response for five minutes to stay inside its rate limit.
  • Hold a value too large to want one copy per replica, such as a rendered price list.

More examples

Typed get and set helpers

Two functions written once, so no handler ever has to remember the redis.Nil check or the encoding step. To try any of this locally, run `docker run --rm -p 6379:6379 redis:7` and point Options.Addr at localhost:6379. The three results, value plus found plus error, keep the three outcomes apart: a usable value, a clean miss, and a cache that is actually failing.

Example · go
// getJSON reads a key and decodes it into T. found is false for a miss.
func getJSON[T any](ctx context.Context, rdb *redis.Client, key string) (T, bool, error) {
	var v T
	b, err := rdb.Get(ctx, key).Bytes()
	if errors.Is(err, redis.Nil) {
		return v, false, nil // absent, not broken
	}
	if err != nil {
		return v, false, err
	}
	if err := json.Unmarshal(b, &v); err != nil {
		// A value written by an older build may no longer decode. Call it a
		// miss; the reload will overwrite it.
		return v, false, nil
	}
	return v, true, nil
}

func setJSON(ctx context.Context, rdb *redis.Client, key string, v any, ttl time.Duration) error {
	b, err := json.Marshal(v)
	if err != nil {
		return err
	}
	return rdb.Set(ctx, key, b, ttl).Err()
}

A cache that is allowed to fail

The first service to put Redis in front of its database usually gains one new way to be completely down. This is the fix: a tight deadline on the cache call, and a cache error treated as a miss rather than returned to the caller. Read it against the other direction, where a database error is returned, because that one really is the answer being unavailable.

Example · go
// cachedName gives the cache its own short deadline. Redis being slow must
// not make the request slower than not caching at all.
func (s *server) cachedName(ctx context.Context, id int) (string, error) {
	key := fmt.Sprintf("user:v1:%d", id)

	cctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
	defer cancel()

	name, found, err := getJSON[string](cctx, s.rdb, key)
	if err != nil {
		// Log and carry on: a broken cache is a slow request, not a failed one.
		log.Printf("cache read failed for %s: %v", key, err)
	} else if found {
		return name, nil
	}

	name, err = s.loadName(ctx, id)
	if err != nil {
		return "", err
	}
	if err := setJSON(ctx, s.rdb, key, name, 5*time.Minute); err != nil {
		log.Printf("cache write failed for %s: %v", key, err)
	}
	return name, nil
}

Many keys in one round trip

Fifty single Get calls cost fifty network round trips, and over a real network that is most of the time spent. MGet costs one. Use it whenever you are filling a page of rows: ask for every key, collect the hits, and load only the ids that came back nil.

Example · go
// names fetches one key per id in a single call. MGet returns one element per
// key, in the order asked, and nil where the key was absent.
func names(ctx context.Context, rdb *redis.Client, ids []int) (map[int]string, error) {
	keys := make([]string, len(ids))
	for i, id := range ids {
		keys[i] = fmt.Sprintf("user:v1:%d", id)
	}
	vals, err := rdb.MGet(ctx, keys...).Result()
	if err != nil {
		return nil, err
	}
	hits := make(map[int]string, len(ids))
	for i, v := range vals {
		s, ok := v.(string) // a nil element is a miss, so the assertion fails
		if !ok {
			continue
		}
		var name string
		if err := json.Unmarshal([]byte(s), &name); err != nil {
			continue
		}
		hits[ids[i]] = name
	}
	return hits, nil
}

Discussion

  • Be the first to comment on this lesson.