《后端架构设计·高性能与高可用》3.2 缓存一致性、失效与更新策略

在 arch-lab 的订单商品快照写路径上实现 cache-aside、版本底线与删除重试队列,用两个测试复现“更新库后删缓存失败”和“并发读写把旧值回填”的窗口,比较双删、版本号与逻辑过期的代价,并给出带可观测指标的退出条件与适用边界。

写路径先问:删缓存还是改缓存

3.1 的读路径只解决了“怎么命中”,没解决“数据库变了以后缓存怎么办”。站内 分布式缓存深度策略 已经给过缓存更新模式的完整清单,本节不重复定义,只在 arch-lab 的商品快照上回答三件事:更新数据库后删缓存失败会怎样、并发读写为什么能把旧值写回缓存、arch-lab 最终选哪种策略以及什么时候退出。

先固定边界:订单商品快照的写路径只改价格和名称,频率远低于读;数据库是唯一事实来源;缓存允许短暂陈旧,但不允许旧版本覆盖新版本。这个边界决定了下面的方案选择。

三个方案的代价

方案做法一致性窗口主要代价适用条件
双删更新前后各删一次取决于第二次删除延迟延迟难以定,仍可能漏写少、可接受短陈旧
版本号缓存值带版本,回填时比较旧版本无法覆盖新版本需要版本来源与删除底线写路径有单调版本
逻辑过期不真删,标记过期后异步刷新返回旧值直到刷新完成读路径复杂,需后台刷新可用性优先、可读旧值

双删的第二次删除是“猜一个延迟”,没有可验证的完成条件;逻辑过期把一致性责任推给异步刷新,读到的可能是旧值。版本号不承诺“读到的永远最新”,但承诺“缓存不会从新版本退回旧版本”,这正是 arch-lab 需要的最弱保证。

cache-aside 的竞态窗口

标准 cache-aside 是“读未命中→查库→回填;写→更新库→删缓存”。竞态发生在读写交错时:读请求未命中,从数据库读到版本 1;写请求把数据库更新到版本 2 并删除缓存;读请求随后把版本 1 回填。缓存里留下旧值,直到下一次写或 TTL 过期。

只靠“更新后删除”无法关闭这个窗口,因为读请求的回填发生在删除之后。版本号的做法是给删除留一条“版本底线”:删除时记录本次写入的版本,后续回填只有版本不低于底线才允许写入。这样旧读的回填会被拒绝,下一次读重新回源拿到版本 2。

版本底线与删除重试

UpdatePrice 先更新数据库,再调用带版本的 Delete。删除失败时把 (id, version) 放进重试队列;队列可以换成 outbox 表,让进程重启后继续重试。Get 回填时调用 PutIfNewer,由缓存实现比较版本:

// file: internal/cacheaside/store.go
// tier: L1
// verify: go test ./internal/cacheaside/
package cacheaside

import (
	"context"
	"errors"
)

var ErrNotFound = errors.New("product not found")
var ErrStaleVersion = errors.New("stale cache version")

type Product struct {
	ID, Name            string
	PriceCents, Version int64
}

type DB interface {
	Get(ctx context.Context, id string) (Product, error)
	UpdatePrice(ctx context.Context, id string, price int64) (Product, error)
}

// Cache 的 Delete 带版本号:删除后留下版本底线,
// 晚到的旧读回填会被 PutIfNewer 拒绝。
type Cache interface {
	Get(ctx context.Context, id string) (Product, bool, error)
	PutIfNewer(ctx context.Context, id string, p Product) error
	Delete(ctx context.Context, id string, version int64) error
}

type Service struct {
	DB    DB
	Cache Cache
	Retry *RetryQueue
}

func (s *Service) Get(ctx context.Context, id string) (Product, error) {
	if p, ok, err := s.Cache.Get(ctx, id); err == nil && ok {
		return p, nil
	}
	p, err := s.DB.Get(ctx, id)
	if err != nil {
		return Product{}, err
	}
	// 回填失败只影响下一次命中,不影响本次结果。
	_ = s.Cache.PutIfNewer(ctx, id, p)
	return p, nil
}

func (s *Service) UpdatePrice(ctx context.Context, id string, price int64) (Product, error) {
	p, err := s.DB.UpdatePrice(ctx, id, price)
	if err != nil {
		return Product{}, err
	}
	if err := s.Cache.Delete(ctx, id, p.Version); err != nil && s.Retry != nil {
		s.Retry.Enqueue(id, p.Version)
	}
	return p, nil
}

重试队列只做一件事:把删除失败记下来,按批次重放。它不保证一定成功,因此必须有最大尝试次数和丢弃计数;丢弃不是删除问题,而是升级为需要人工介入的告警。

// file: internal/cacheaside/retry.go
// tier: L1
// verify: go test ./internal/cacheaside/
package cacheaside

import (
	"context"
	"sync"
)

type invalidation struct {
	id      string
	version int64
}

// RetryQueue 保存删除失败的失效请求。生产实现应把队列放进
// outbox 表或消息队列,让进程重启后仍能继续重试。
type RetryQueue struct {
	mu          sync.Mutex
	cache       Cache
	pending     []invalidation
	maxAttempts int
	attempts    int
	dropped     int
}

func NewRetryQueue(c Cache, maxAttempts int) *RetryQueue {
	if maxAttempts <= 0 {
		maxAttempts = 1
	}
	return &RetryQueue{cache: c, maxAttempts: maxAttempts}
}

func (q *RetryQueue) Enqueue(id string, version int64) {
	q.mu.Lock()
	q.pending = append(q.pending, invalidation{id, version})
	q.mu.Unlock()
}

// Drain 重试当前批次,返回成功删除的条数。
func (q *RetryQueue) Drain(ctx context.Context) int {
	q.mu.Lock()
	batch := q.pending
	q.pending = nil
	q.mu.Unlock()
	deleted := 0
	for _, it := range batch {
		if err := q.cache.Delete(ctx, it.id, it.version); err != nil {
			q.mu.Lock()
			q.attempts++
			if q.attempts >= q.maxAttempts {
				q.dropped++
			} else {
				q.pending = append(q.pending, it)
			}
			q.mu.Unlock()
			continue
		}
		deleted++
	}
	return deleted
}

func (q *RetryQueue) Pending() int {
	q.mu.Lock()
	defer q.mu.Unlock()
	return len(q.pending)
}

测试:删除失败与旧值回填

第一个测试让缓存第一次删除失败:更新后读到的仍是版本 1,队列里有一条待重试;Drain 成功后重新读,拿到版本 2。第二个测试用闸门让读请求停在“已从库读到版本 1、尚未回填”的位置,写请求完成更新与删除后再放行,验证回填被版本底线拒绝。

// file: internal/cacheaside/store_test.go
// tier: L1
// verify: go test ./internal/cacheaside/ -count=1 -v
package cacheaside

import (
	"context"
	"errors"
	"sync"
	"testing"
)

type memDB struct {
	mu   sync.Mutex
	data map[string]Product
}

func newMemDB() *memDB {
	return &memDB{data: map[string]Product{"p-1": {ID: "p-1", Name: "机械键盘", PriceCents: 1000, Version: 1}}}
}

func (d *memDB) Get(_ context.Context, id string) (Product, error) {
	d.mu.Lock()
	defer d.mu.Unlock()
	p, ok := d.data[id]
	if !ok {
		return Product{}, ErrNotFound
	}
	return p, nil
}

func (d *memDB) UpdatePrice(_ context.Context, id string, price int64) (Product, error) {
	d.mu.Lock()
	defer d.mu.Unlock()
	p, ok := d.data[id]
	if !ok {
		return Product{}, ErrNotFound
	}
	p.PriceCents = price
	p.Version++
	d.data[id] = p
	return p, nil
}

type memCache struct {
	mu          sync.Mutex
	data        map[string]Product
	floor       map[string]int64
	failDeletes int
	rejected    int
}

func newMemCache() *memCache { return &memCache{data: map[string]Product{}, floor: map[string]int64{}} }

func (c *memCache) Get(_ context.Context, id string) (Product, bool, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	p, ok := c.data[id]
	return p, ok, nil
}

func (c *memCache) PutIfNewer(_ context.Context, id string, p Product) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	if p.Version < c.floor[id] {
		c.rejected++
		return ErrStaleVersion
	}
	if old, ok := c.data[id]; ok && old.Version >= p.Version {
		c.rejected++
		return ErrStaleVersion
	}
	c.data[id] = p
	return nil
}

func (c *memCache) Delete(_ context.Context, id string, version int64) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.failDeletes > 0 {
		c.failDeletes--
		return errors.New("redis unavailable")
	}
	delete(c.data, id)
	if version > c.floor[id] {
		c.floor[id] = version
	}
	return nil
}

func TestDeleteFailureAndRetry(t *testing.T) {
	ctx := context.Background()
	cache := newMemCache()
	svc := &Service{DB: newMemDB(), Cache: cache, Retry: NewRetryQueue(cache, 3)}
	if _, err := svc.Get(ctx, "p-1"); err != nil {
		t.Fatal(err)
	}
	cache.failDeletes = 1
	if _, err := svc.UpdatePrice(ctx, "p-1", 1200); err != nil {
		t.Fatal(err)
	}
	stale, err := svc.Get(ctx, "p-1")
	if err != nil || stale.Version != 1 || stale.PriceCents != 1000 {
		t.Fatalf("before retry: %+v err=%v", stale, err)
	}
	if svc.Retry.Pending() != 1 {
		t.Fatalf("pending = %d, want 1", svc.Retry.Pending())
	}
	if n := svc.Retry.Drain(ctx); n != 1 {
		t.Fatalf("drained = %d, want 1", n)
	}
	fresh, err := svc.Get(ctx, "p-1")
	if err != nil || fresh.Version != 2 || fresh.PriceCents != 1200 {
		t.Fatalf("after retry: %+v err=%v", fresh, err)
	}
	t.Logf("stale_version=%d retried=1 final_version=%d final_price=%d", stale.Version, fresh.Version, fresh.PriceCents)
}

type gatedDB struct {
	*memDB
	loaded  chan struct{}
	release chan struct{}
	once    sync.Once
}

func (g *gatedDB) Get(ctx context.Context, id string) (Product, error) {
	p, err := g.memDB.Get(ctx, id)
	g.once.Do(func() { close(g.loaded); <-g.release })
	return p, err
}

func TestConcurrentReadWriteNoStaleBackfill(t *testing.T) {
	ctx := context.Background()
	db := &gatedDB{memDB: newMemDB(), loaded: make(chan struct{}), release: make(chan struct{})}
	cache := newMemCache()
	svc := &Service{DB: db, Cache: cache}
	svc.Retry = NewRetryQueue(cache, 3)

	type result struct {
		p   Product
		err error
	}
	done := make(chan result, 1)
	go func() {
		p, err := svc.Get(ctx, "p-1")
		done <- result{p: p, err: err}
	}()
	<-db.loaded
	if _, err := svc.UpdatePrice(ctx, "p-1", 1200); err != nil {
		t.Fatal(err)
	}
	close(db.release)
	old := <-done
	if old.err != nil || old.p.Version != 1 {
		t.Fatalf("reader: %+v err=%v", old.p, old.err)
	}
	if _, ok, _ := cache.Get(ctx, "p-1"); ok {
		t.Fatal("stale value was backfilled into cache")
	}
	fresh, err := svc.Get(ctx, "p-1")
	if err != nil || fresh.Version != 2 || fresh.PriceCents != 1200 {
		t.Fatalf("fresh: %+v err=%v", fresh, err)
	}
	if cache.rejected != 1 {
		t.Fatalf("rejected writes = %d, want 1", cache.rejected)
	}
	t.Logf("reader_saw_version=%d rejected_writes=%d final_version=%d", old.p.Version, cache.rejected, fresh.Version)
}

真实运行结果

以下输出在撰写时于本机 macOS(darwin/arm64,Go 1.26.0)真实执行,代码抽取到 .local/checks/be-arch-ch03/s32/;原始输出在 .local/reports/be-arch/be-arch-perf-03-2-cache-consistency-invalidation-test.txt:

$ go test ./... -count=1 -v
=== RUN   TestDeleteFailureAndRetry
    store_test.go:113: stale_version=1 retried=1 final_version=2 final_price=1200
--- PASS: TestDeleteFailureAndRetry (0.00s)
=== RUN   TestConcurrentReadWriteNoStaleBackfill
    store_test.go:164: reader_saw_version=1 rejected_writes=1 final_version=2
--- PASS: TestConcurrentReadWriteNoStaleBackfill (0.00s)
PASS
ok  	archlab/internal/cacheaside	0.492s

go test -race ./... 同样通过(ok archlab/internal/cacheaside 1.471s,见 ...-race.txt)。第一条日志证明删除失败会留下一个可观测的陈旧窗口:版本 1 在重试前可见;第二条证明旧读回填被拒绝一次,最终版本是 2。

arch-lab 的选择与退出条件

arch-lab 选择“cache-aside + 版本底线 + 删除重试队列”,不选双删,也不选逻辑过期。理由是:订单快照有单调版本,版本比较是 O(1) 的本地判断;删除失败可以重试并计数;读路径不需要后台刷新线程。代价是缓存实现必须支持 PutIfNewer,Redis 适配器要用 Lua 把“比较版本 + 写入”做成原子操作;如果用普通 SET,版本比较会退化成非原子读改写。

退出条件也要提前写:如果写后删除的成功率连续一周高于 99.99%,且版本冲突计数接近 0,可以去掉重试队列,只保留版本底线;如果业务要求“读到的必须是提交后的最新值”,版本底线也不够,必须退出缓存或改成读主库;如果跨实例的删除延迟无法接受,应该缩短 L1 TTL 而不是继续加重试。每条退出条件都对应一个可观测指标,而不是“以后再看”。

常见错误与边界

第一,先删缓存再更新数据库。删除后、更新前的读会把旧值重新回填;顺序必须是先更新库、后删缓存。

第二,只实现删除,不实现版本比较。并发读的回填仍可能把旧版本写回,删除重试只解决“删失败”,不解决“删完又被旧值填回”。

第三,重试队列无限增长。没有最大尝试次数和丢弃计数,故障时内存会被撑爆;丢弃必须告警,不能静默。

第四,把版本底线当成强一致。它只阻止旧版本覆盖新版本,不保证读请求一定看到最新提交;强一致需要读主库或分布式锁,代价完全不同。

小结

本节把商品快照的写路径落成 cache-aside:更新数据库后带版本删除缓存,删除失败进入重试队列;回填用 PutIfNewer 拒绝低于底线的旧版本。两个测试分别复现了删除失败留下的陈旧窗口和并发读写的旧值回填,并在真实 go test 输出中确认最终版本为 2。下一节处理读路径的另外三种故障:穿透、击穿、雪崩与热 key。

阅读导航:上一节:3.1 本地缓存、分布式缓存与多级缓存 · 下一节:3.3 穿透、击穿、雪崩与热 key 治理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查