《后端架构设计·高性能与高可用》3.1 本地缓存、分布式缓存与多级缓存

在 arch-lab 的订单商品快照读路径上实现带 TTL 与 LRU 的 L1 本地缓存和 L2 端口,用可注入时钟测试淘汰、过期与并发读写,再用 20 万次 80/20 请求测出真实命中率、回源次数,并给出容量规划、失效边界和并发测试的真实输出。

从商品快照的读路径开始

订单详情页每次渲染都要读商品快照:名称、单价、币种、下单时的库存状态。这些字段在一个订单生命周期内基本不变,却被反复读取。站内 分布式缓存深度策略 已经讲过 Redis Cluster、一致性哈希与多级缓存的通用结构,本节不重复定义,只把它落到 arch-lab 的一条具体读路径上:进程内 L1、共享 L2、数据库回源,并且用可运行代码回答“命中率是多少、回源几次、失效边界在哪”。

三层各自负责什么

层介质容量TTL失效边界故障行为
L1进程内 LRU512–4096 条1–3 秒本进程立即生效,其他实例靠 TTL不影响请求,回退 L2
L2Redis全量热数据5–30 分钟写后删除,跨实例共享超时则绕过缓存回源
DBPostgreSQL全量不适用唯一事实来源请求失败

L1 解决的是“同一实例在毫秒级重复读同一个 key”,它不能跨实例失效,所以 TTL 必须短;L2 解决跨实例共享与容量,但每次读仍有一次网络往返。把两层串起来的目标不是“缓存越多越好”,而是让回源次数降到数据库能承受的量级。

命中率之前先保证正确性

本地缓存最容易出的问题不是命中率低,而是并发读写把 map 写坏、过期条目继续占容量、淘汰顺序错误。实现必须同时满足:所有操作持同一把锁;命中时把条目移到队首;读取时发现过期要删除并计入 Expired;容量超出时淘汰队尾。命中率只是这些计数之上的派生指标。

实现 TTL + LRU

TTL 用 container/list 维护 LRU 顺序,用 map 做 O(1) 定位。时钟通过构造函数注入,测试可以前进时间而不 sleep:

// file: internal/platform/cache/ttl.go
// tier: L1
// verify: go test ./internal/platform/cache/
package cache

import (
	"container/list"
	"sync"
	"time"
)

type Stats struct{ Hits, Misses, Evictions, Expired int64 }

type item[K comparable, V any] struct {
	key      K
	value    V
	expireAt time.Time
}

// TTL 是带容量上限和过期时间的进程内 LRU,所有方法可并发调用。
type TTL[K comparable, V any] struct {
	mu       sync.Mutex
	capacity int
	ttl      time.Duration
	now      func() time.Time
	order    *list.List
	items    map[K]*list.Element
	stats    Stats
}

func NewTTLWithClock[K comparable, V any](capacity int, ttl time.Duration, now func() time.Time) *TTL[K, V] {
	if capacity <= 0 {
		capacity = 1
	}
	return &TTL[K, V]{capacity: capacity, ttl: ttl, now: now,
		order: list.New(), items: make(map[K]*list.Element, capacity)}
}

func (c *TTL[K, V]) Get(key K) (V, bool) {
	var zero V
	c.mu.Lock()
	defer c.mu.Unlock()
	el, ok := c.items[key]
	if !ok {
		c.stats.Misses++
		return zero, false
	}
	it := el.Value.(*item[K, V])
	if !it.expireAt.IsZero() && !c.now().Before(it.expireAt) {
		c.remove(el)
		c.stats.Expired++
		c.stats.Misses++
		return zero, false
	}
	c.order.MoveToFront(el)
	c.stats.Hits++
	return it.value, true
}

func (c *TTL[K, V]) Set(key K, value V) {
	c.mu.Lock()
	defer c.mu.Unlock()
	var expireAt time.Time
	if c.ttl > 0 {
		expireAt = c.now().Add(c.ttl)
	}
	if el, ok := c.items[key]; ok {
		it := el.Value.(*item[K, V])
		it.value, it.expireAt = value, expireAt
		c.order.MoveToFront(el)
		return
	}
	c.items[key] = c.order.PushFront(&item[K, V]{key: key, value: value, expireAt: expireAt})
	for c.order.Len() > c.capacity {
		c.remove(c.order.Back())
		c.stats.Evictions++
	}
}

func (c *TTL[K, V]) Len() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.order.Len()
}

func (c *TTL[K, V]) Stats() Stats {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.stats
}

func (c *TTL[K, V]) remove(el *list.Element) {
	it := el.Value.(*item[K, V])
	c.order.Remove(el)
	delete(c.items, it.key)
}

Get 的命中率分母只包含 Hits + Misses,不包含 Set。这不是细节:如果把写入也算进分母,命中率会被写请求稀释,无法反映读路径的真实覆盖率。

多级读取与失败打开

TwoLevel 的路径固定为 L1 → L2 → Loader。L2 返回错误时不把错误抛给调用方,而是记一次 L2Errors 后继续回源;缓存故障应该降级成“慢一点”,而不是“读不到”。

// file: internal/platform/cache/multilevel.go
// tier: L1
// verify: go test ./internal/platform/cache/
package cache

import (
	"context"
	"sync"
	"time"
)

// L2 是分布式缓存的最小读端口;失效能力留到 3.2。
type L2 interface {
	Get(ctx context.Context, key string) ([]byte, bool, error)
	Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
}

type Loader func(ctx context.Context, key string) ([]byte, error)

type LevelStats struct{ L1Hits, L2Hits, Loads, L2Errors int64 }

// TwoLevel 的顺序是 L1 -> L2 -> Loader;回源后同时回填两层。
type TwoLevel struct {
	L1   *TTL[string, []byte]
	L2   L2
	Load Loader
	TTL  time.Duration

	mu    sync.Mutex
	stats LevelStats
}

func (m *TwoLevel) Get(ctx context.Context, key string) ([]byte, error) {
	if v, ok := m.L1.Get(key); ok {
		m.add(func(s *LevelStats) { s.L1Hits++ })
		return v, nil
	}
	if m.L2 != nil {
		v, ok, err := m.L2.Get(ctx, key)
		if err != nil {
			m.add(func(s *LevelStats) { s.L2Errors++ })
		} else if ok {
			m.L1.Set(key, v)
			m.add(func(s *LevelStats) { s.L2Hits++ })
			return v, nil
		}
	}
	v, err := m.Load(ctx, key)
	if err != nil {
		return nil, err
	}
	m.add(func(s *LevelStats) { s.Loads++ })
	m.L1.Set(key, v)
	if m.L2 != nil {
		if err := m.L2.Set(ctx, key, v, m.TTL); err != nil {
			m.add(func(s *LevelStats) { s.L2Errors++ })
		}
	}
	return v, nil
}

func (m *TwoLevel) Stats() LevelStats {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.stats
}

func (m *TwoLevel) add(fn func(*LevelStats)) {
	m.mu.Lock()
	fn(&m.stats)
	m.mu.Unlock()
}

这里没有 singleflight:多个请求同时未命中同一个 key 时,会各自回源。这个缺陷在 3.3 用合并回源解决;本节先把未受保护的基线测出来。

测试:淘汰、过期、并发与命中率

测试覆盖三件事:容量为 2 时按 LRU 淘汰、假时钟前进后过期条目不再占容量、8 个 goroutine 并发读写不越界。最后一个用例用固定种子的 80/20 分布读 20 万次,L2 用内存替身代替 Redis。

// file: internal/platform/cache/cache_test.go
// tier: L1
// verify: go test ./internal/platform/cache/ -count=1 -v
package cache

import (
	"context"
	"fmt"
	"sync"
	"sync/atomic"
	"testing"
	"time"
)

type fakeClock struct{ t time.Time }

func (c *fakeClock) Now() time.Time { return c.t }

func TestEvictionAndExpiry(t *testing.T) {
	clk := &fakeClock{t: time.Unix(0, 0)}
	c := NewTTLWithClock[string, int](2, 30*time.Second, clk.Now)
	c.Set("a", 1)
	c.Set("b", 2)
	if _, ok := c.Get("a"); !ok {
		t.Fatal("a should be present")
	}
	c.Set("c", 3)
	if _, ok := c.Get("b"); ok {
		t.Fatal("b should be evicted as least recently used")
	}
	clk.t = clk.t.Add(31 * time.Second)
	if _, ok := c.Get("a"); ok {
		t.Fatal("a should have expired")
	}
	if c.Len() != 1 {
		t.Fatalf("expired entry still occupies capacity: len=%d", c.Len())
	}
	if s := c.Stats(); s.Evictions != 1 || s.Expired != 1 || s.Hits != 1 {
		t.Fatalf("stats = %+v", s)
	}
}

func TestConcurrentAccess(t *testing.T) {
	c := NewTTLWithClock[string, int](64, time.Minute, time.Now)
	var wg sync.WaitGroup
	for g := 0; g < 8; g++ {
		wg.Add(1)
		go func(g int) {
			defer wg.Done()
			for i := 0; i < 2000; i++ {
				key := fmt.Sprintf("k-%d", (g*31+i)%128)
				c.Set(key, i)
				c.Get(key)
			}
		}(g)
	}
	wg.Wait()
	if n := c.Len(); n > 64 {
		t.Fatalf("capacity violated: len=%d", n)
	}
	if reads := c.Stats().Hits + c.Stats().Misses; reads != 16000 {
		t.Fatalf("reads = %d", reads)
	}
}

type memL2 struct {
	mu   sync.Mutex
	data map[string][]byte
}

func (m *memL2) Get(_ context.Context, key string) ([]byte, bool, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	v, ok := m.data[key]
	return v, ok, nil
}

func (m *memL2) Set(_ context.Context, key string, value []byte, _ time.Duration) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.data == nil {
		m.data = make(map[string][]byte)
	}
	m.data[key] = value
	return nil
}

// TestTwoLevelWorkload 用确定性的 80/20 分布读 20 万次;L2 是内存替身。
func TestTwoLevelWorkload(t *testing.T) {
	const keys, hotKeys, requests, l1Cap = 10000, 2000, 200000, 512
	var loads atomic.Int64
	ml := &TwoLevel{
		L1:  NewTTLWithClock[string, []byte](l1Cap, 5*time.Minute, time.Now),
		L2:  &memL2{},
		TTL: 5 * time.Minute,
		Load: func(ctx context.Context, key string) ([]byte, error) {
			loads.Add(1)
			return []byte("snapshot:" + key), nil
		},
	}
	state := uint64(20261016)
	next := func() uint64 {
		state = state*6364136223846793005 + 1442695040888963407
		return state >> 33
	}
	for i := 0; i < requests; i++ {
		n := int(next() % hotKeys)
		if int(next()%100) >= 80 {
			n = hotKeys + int(next()%(keys-hotKeys))
		}
		if _, err := ml.Get(context.Background(), fmt.Sprintf("sku-%05d", n)); err != nil {
			t.Fatal(err)
		}
	}
	s := ml.Stats()
	if s.Loads != loads.Load() || s.L1Hits+s.L2Hits+s.Loads != requests {
		t.Fatalf("accounting: stats=%+v loader=%d", s, loads.Load())
	}
	t.Logf("requests=%d l1_hits=%d l2_hits=%d loads=%d hit_rate=%.4f l1_hit_rate=%.4f",
		requests, s.L1Hits, s.L2Hits, s.Loads,
		float64(s.L1Hits+s.L2Hits)/requests, float64(s.L1Hits)/requests)
}

真实运行结果

以下输出在撰写时于本机 macOS(darwin/arm64,Go 1.26.0)真实执行,代码抽取到 .local/checks/be-arch-ch03/s31/ 的独立模块;原始输出保存在 .local/reports/be-arch/be-arch-perf-03-1-local-distributed-multilevel-cache-test.txt:

$ go test ./... -count=1 -v
=== RUN   TestEvictionAndExpiry
--- PASS: TestEvictionAndExpiry (0.00s)
=== RUN   TestConcurrentAccess
--- PASS: TestConcurrentAccess (0.01s)
=== RUN   TestTwoLevelWorkload
    cache_test.go:116: requests=200000 l1_hits=32729 l2_hits=157313 loads=9958 hit_rate=0.9502 l1_hit_rate=0.1636
--- PASS: TestTwoLevelWorkload (0.07s)
PASS
ok  	archlab/internal/platform/cache	0.555s

go test -race ./... 同样通过,输出 ok archlab/internal/platform/cache 2.411s,见同目录 ...-race.txt。这是一次单机运行,不是稳定基准。

命中率数字怎么读

20 万次请求里,L1 命中 32,729 次(16.36%),L2 命中 157,313 次(78.66%),真正回源 9,958 次(4.98%)。L1 配了 512 条,但 80% 的流量分布在 2,000 个热 key 上,512 条装不下这个工作集,所以 L1 只能拦住最热的一小段;剩下的由 L2 接住。最终回源从 20 万降到 9,958,减少约 95.0%。
这个数字同时说明两件事。第一,L1 容量必须按工作集而不是按“感觉够用”来配;512 条在 2,000 个热 key 面前只有 16.36% 的覆盖率。第二,L2 不是“L1 的备份”,而是命中率的主要贡献者:78.66% 的请求在 L2 命中。若把 L2 去掉,回源会从 9,958 涨到 167,271。

快照场景的容量与失效边界

按每条快照约 2 KB 估算,512 条 L1 约 1 MB/实例,10,000 条 L2 约 20 MB(不含 Redis 自身开销)。容量不是免费的:L1 每增加一倍,进程内存同步增加;命中率是否值得要看回源成本。

失效边界必须写清楚:L1 只在本进程删除立即生效,跨实例只能等 TTL 过期;L2 的删除由写路径触发,但“先写库后删缓存”仍会失败,这部分在 3.2 缓存一致性、失效与更新策略 用版本号与删除重试处理。结论是 L1 的 TTL 要短(1–3 秒)且不能承担强一致,L2 的 TTL 可以长,但写后必须失效。

常见错误与边界

第一,把 L1 当成跨实例缓存。进程各自持有一份,删除只影响自己;跨实例一致性只能靠短 TTL 或版本校验。

第二,命中时忘记移动 LRU 位置。那样淘汰的就不是“最久未使用”,而是“最早写入”,热点 key 会被冷数据挤掉。

第三,过期条目只在 Get 时删除。长期不访问的 key 会占着容量直到被淘汰;容量紧张时应增加主动清理,而不是无限放大 TTL。

第四,L2 故障时把错误抛给调用方。缓存是加速层,不是事实来源;超时应该降级回源,同时把 L2Errors 打进指标,避免故障被静默吞掉。

小结

本节把订单商品快照的读路径落成 L1 + L2 + Loader:L1 用 TTL + LRU 保证并发安全与容量上限,L2 用端口隔离 Redis 故障并失败打开,测试覆盖淘汰、过期、并发读写。在 80/20 分布、20 万次请求的单机实验中,L1 命中 16.36%,L2 命中 78.66%,回源 9,958 次。下一节处理这份代码刻意留下的问题:写路径如何让两层缓存失效,以及删除失败后如何重试。

阅读导航:上一节:2.3 压测结果解读与容量余量 · 下一节:3.2 缓存一致性、失效与更新策略 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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