Invalidation

Three ways to stop serving a stale value: let a TTL expire it, write through on update, or delete the key after the commit.

Syntaxdelete(c.m, key) // in process; rdb.Del(ctx, key).Err() in Redis, after the commit

Invalidation means making a cached copy stop being used once the real value has changed. It is the hard half of caching. A cache that is never invalidated serves answers that used to be true.

Three ways, and where each fits

Time based. Every entry carries a TTL, a time to live. When it expires the next read misses and refills. You write no invalidation code at all. In exchange, a value can be wrong for up to one whole TTL. For a list of countries an hour is fine. For an account balance no TTL is short enough.

Write through. The code that updates the database also writes the new value into the cache, so the cache stays warm and the next reader pays nothing. The risk is ordering: two writers can land in one order in the database and the other order in the cache, leaving the cache holding the older name.

Explicit busting. The code that updates the database deletes the key instead. The next read refills it from the database. A delete cannot store the wrong value, which is why it is the default choice.

Naming decides all of it

You can only delete a key you can name. Build keys in one function, from entity, version and identity: user:v1:42. One key per row means one write knows exactly which key it just made wrong. A key holding something derived from many rows cannot be invalidated precisely, so give that one a short TTL instead.

Set a TTL even when you delete explicitly. The delete can fail and the process can die between the two steps. The TTL is then the only thing that will ever repair the entry.

Example

Example · go
package main

import (
	"fmt"
	"sync"
)

// userKey names one row and nothing else, with a version in the name.
func userKey(id int) string { return fmt.Sprintf("user:v1:%d", id) }

type store struct {
	mu    sync.Mutex
	rows  map[int]string    // the source of truth
	cache map[string]string // a copy, which may be absent
}

// get reports the value and whether it came from the cache.
func (s *store) get(id int) (string, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if v, ok := s.cache[userKey(id)]; ok {
		return v, true
	}
	v := s.rows[id]
	s.cache[userKey(id)] = v
	return v, false
}

// put writes the row, then deletes the one key it has just made wrong.
func (s *store) put(id int, name string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.rows[id] = name
	delete(s.cache, userKey(id))
}

func main() {
	s := &store{rows: map[int]string{7: "ada"}, cache: map[string]string{}}
	show := func() {
		v, cached := s.get(7)
		fmt.Println(v, "fromCache:", cached)
	}
	show()          // fills the cache
	show()          // served from the cache
	s.put(7, "grace") // the write busts the key
	show()          // misses, so it reads the new row
}

// Output:
// ada fromCache: false
// ada fromCache: true
// grace fromCache: false

When to use it

  • Make a profile edit visible on the next page load instead of a TTL later.
  • Retire every cached value at once after changing the struct you store.
  • Choose a TTL for a dashboard figure built from several tables that no single write can invalidate.
  • Work out which keys a bulk import has to bust when it rewrites ten thousand rows.

More examples

Write through and bust, side by side

Same job, different failure. If two renames run at once, write through can leave the cache holding the value whose Set happened to arrive last, which need not be the one the database kept. The bust version cannot do that, because it stores nothing; whatever the database ended up with is what the next reader sees. Choose write through only for a key so hot that you cannot afford the one miss after each write.

Example · go
// Write through: the update puts the new value in the cache as well.
func (s *store) renameWriteThrough(ctx context.Context, id int, name string) error {
	if err := s.updateRow(ctx, id, name); err != nil {
		return err
	}
	return s.rdb.Set(ctx, userKey(id), name, 5*time.Minute).Err()
}

// Bust: the update removes the key and the next read refills it.
func (s *store) renameInvalidate(ctx context.Context, id int, name string) error {
	if err := s.updateRow(ctx, id, name); err != nil {
		return err
	}
	return s.rdb.Del(ctx, userKey(id)).Err()
}

A version in the key, for mass invalidation

Reach for this on the deploy where a cached struct gains or loses a field. Without a version, the new binary decodes values written by the old one and the missing field silently becomes a zero value. With one, the change is a single constant, the old keys are never read again, and their TTLs clean them up unattended. The second function shows the other half of the rule: anything that changes the answer belongs in the key name.

Example · go
// The version lives in the key, not in the value. Bumping it retires every
// old key at once: new code simply never asks for the old names again.
const userKeyVersion = "v2"

func userKey(id int) string {
	return fmt.Sprintf("user:%s:%d", userKeyVersion, id)
}

// A key whose value depends on more than one row needs every input in its
// name, or two different results end up sharing one key.
func userOrdersKey(id, page, perPage int) string {
	return fmt.Sprintf("user:%s:%d:orders:page=%d:per=%d",
		userKeyVersion, id, page, perPage)
}

Delete after the commit

The ordering is the entire lesson, and it is the bug most often filed as "the cache is broken". The delete also has to survive a rolled-back transaction, which this shape gets for free: no commit means no delete, and the cached value was right all along. The delete error is logged rather than returned, because the write did happen and the TTL will finish the job.

Example · go
func (s *store) rename(ctx context.Context, id int, name string) error {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback() // does nothing once Commit has succeeded

	if _, err := tx.ExecContext(ctx,
		"UPDATE users SET name = $1 WHERE id = $2", name, id); err != nil {
		return err
	}
	if err := tx.Commit(); err != nil {
		return err
	}

	// Only now. Delete before the commit and a concurrent read refills the
	// key with the pre-commit row, which then stays wrong for a full TTL.
	if err := s.rdb.Del(ctx, userKey(id)).Err(); err != nil {
		log.Printf("cache delete failed for %s: %v", userKey(id), err)
	}
	return nil
}

Discussion

  • Be the first to comment on this lesson.