《后端架构设计·高性能与高可用》3.3 穿透、击穿、雪崩与热 key 治理

在 arch-lab 的订单商品快照读路径上实现 singleflight、空值缓存、TTL 抖动和热 key 本地副本,用 200 个并发请求真实对比无保护与合并回源的回源次数,并说明每道保护的副作用、可观测指标、关闭条件与本机真实测量输出。

四种故障不是同一个问题

站内 缓存击穿/雪崩治理 已经给过热点探测与限流回源的通用方案,本节不重复清单,只在 arch-lab 的商品快照读路径上把保护做成可运行代码,并用真实并发测试回答“回源次数从多少降到多少”。四种故障的触发条件不同,保护手段也不能互换:

故障触发条件保护副作用
穿透查询不存在的 key空值缓存新数据可能被挡到 null TTL 到期
击穿单个热点 key 过期singleflight等待者共享 leader 的延迟与错误
雪崩大量 key 同时过期TTL 抖动只打散时间,不降低总回源量
热 key单 key 流量过度集中本地副本多实例之间出现短暂时差

singleflight 只合并同一 key 的并发未命中

Group.Do 用 map 记录在途调用:第一个调用执行 fn,其余调用等待同一个 done channel 并复用结果。调用完成后删除 map 条目,后续请求会重新竞争;它不做缓存,只把“同一瞬间的 N 次回源”压成一次。

// file: internal/platform/cacheguard/singleflight.go
// tier: L1
// verify: go test ./internal/platform/cacheguard/
package cacheguard

import (
	"sync"
	"sync/atomic"
)

type call[V any] struct {
	done chan struct{}
	val  V
	err  error
}

// Group 合并同一个 key 的并发未命中:第一个调用执行 fn,
// 其余调用等待并复用结果。
type Group[K comparable, V any] struct {
	mu     sync.Mutex
	m      map[K]*call[V]
	shared atomic.Int64
}

func (g *Group[K, V]) Do(key K, fn func() (V, error)) (V, error) {
	g.mu.Lock()
	if c, ok := g.m[key]; ok {
		g.shared.Add(1)
		g.mu.Unlock()
		<-c.done
		return c.val, c.err
	}
	c := &call[V]{done: make(chan struct{})}
	if g.m == nil {
		g.m = make(map[K]*call[V])
	}
	g.m[key] = c
	g.mu.Unlock()

	c.val, c.err = fn()
	close(c.done)
	g.mu.Lock()
	delete(g.m, key)
	g.mu.Unlock()
	return c.val, c.err
}

四道保护如何组合

Protector.Get 的顺序是热 key 副本 → 本地缓存 → singleflight → 后端。空值命中直接返回 ErrNotFound,不再回源;后端返回不存在时写入一个短 TTL 的空值条目;正常值写入时用 ttlFor 加抖动;被 MarkHot 标记的 key 额外写入 200ms 的本地副本。

// file: internal/platform/cacheguard/protector.go
// tier: L1
// verify: go test ./internal/platform/cacheguard/
package cacheguard

import (
	"context"
	"errors"
	"math/rand/v2"
	"sync"
	"time"
)

var ErrNotFound = errors.New("not found")

type Backend interface {
	Load(ctx context.Context, key string) ([]byte, error)
}

type entry struct {
	value    []byte
	expireAt time.Time
	null     bool
}

type Stats struct{ HotHits, NullHits, BackendCalls int64 }

// Protector 给回源加四道保护:singleflight 合并并发未命中、
// 空值缓存挡住穿透、TTL 抖动打散过期、热 key 本地副本减少跨实例读。
type Protector struct {
	backend  Backend
	sf       Group[string, []byte]
	mu       sync.Mutex
	data     map[string]entry
	hot      map[string]entry
	hotKeys  map[string]struct{}
	capacity int
	ttl      time.Duration
	hotTTL   time.Duration
	nullTTL  time.Duration
	jitter   time.Duration
	stats    Stats
}

func NewProtector(b Backend, capacity int) *Protector {
	return &Protector{
		backend: b, data: make(map[string]entry), hot: make(map[string]entry),
		hotKeys: make(map[string]struct{}), capacity: capacity,
		ttl: time.Minute, hotTTL: 200 * time.Millisecond, nullTTL: 30 * time.Second,
	}
}

func (p *Protector) MarkHot(keys ...string) {
	p.mu.Lock()
	defer p.mu.Unlock()
	for _, key := range keys {
		p.hotKeys[key] = struct{}{}
	}
}

func (p *Protector) Get(ctx context.Context, key string) ([]byte, error) {
	p.mu.Lock()
	if e, ok := p.lookup(p.hot, key); ok {
		p.stats.HotHits++
		p.mu.Unlock()
		return e.value, nil
	}
	if e, ok := p.lookup(p.data, key); ok {
		if e.null {
			p.stats.NullHits++
		}
		p.mu.Unlock()
		if e.null {
			return nil, ErrNotFound
		}
		return e.value, nil
	}
	p.mu.Unlock()

	return p.sf.Do(key, func() ([]byte, error) {
		p.mu.Lock()
		p.stats.BackendCalls++
		p.mu.Unlock()

		v, err := p.backend.Load(ctx, key)
		if errors.Is(err, ErrNotFound) {
			p.store(key, entry{null: true}, p.nullTTL, false)
			return nil, ErrNotFound
		}
		if err != nil {
			return nil, err
		}
		p.store(key, entry{value: v}, p.ttl, true)
		return v, nil
	})
}

func (p *Protector) lookup(m map[string]entry, key string) (entry, bool) {
	e, ok := m[key]
	if !ok {
		return entry{}, false
	}
	if !e.expireAt.IsZero() && !time.Now().Before(e.expireAt) {
		delete(m, key)
		return entry{}, false
	}
	return e, true
}

func (p *Protector) store(key string, e entry, ttl time.Duration, mayHot bool) {
	p.mu.Lock()
	defer p.mu.Unlock()
	e.expireAt = time.Now().Add(p.ttlFor(ttl))
	if mayHot {
		if _, ok := p.hotKeys[key]; ok {
			p.hot[key] = entry{value: e.value, expireAt: time.Now().Add(p.hotTTL)}
		}
	}
	if p.capacity > 0 && len(p.data) >= p.capacity {
		return
	}
	p.data[key] = e
}

func (p *Protector) ttlFor(base time.Duration) time.Duration {
	if p.jitter <= 0 {
		return base
	}
	return base + time.Duration(rand.Int64N(int64(2*p.jitter))) - p.jitter
}

并发测试:无保护 vs 合并回源

第一组 200 个 goroutine 直接调用后端,第二组同样 200 个并发调用 Protector.Get。第二组用一个闸门后端把 leader 卡在回源中,等 199 个等待者全部进入 singleflight 后再放行,保证比较是确定性的。第二个测试验证空值缓存、热副本与 TTL 抖动。

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

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

type countingBackend struct {
	calls   atomic.Int64
	delay   time.Duration
	missing map[string]bool
}

func (b *countingBackend) Load(ctx context.Context, key string) ([]byte, error) {
	b.calls.Add(1)
	if b.delay > 0 {
		time.Sleep(b.delay)
	}
	if b.missing[key] {
		return nil, ErrNotFound
	}
	return []byte("value:" + key), nil
}

type gatedBackend struct {
	calls   atomic.Int64
	entered chan struct{}
	release chan struct{}
	once    sync.Once
}

func (b *gatedBackend) Load(ctx context.Context, key string) ([]byte, error) {
	b.calls.Add(1)
	b.once.Do(func() { close(b.entered) })
	<-b.release
	return []byte("value:" + key), nil
}

func TestNoProtectionVsSingleflight(t *testing.T) {
	ctx := context.Background()
	plain := &countingBackend{delay: 20 * time.Millisecond}
	var wg sync.WaitGroup
	start := time.Now()
	for i := 0; i < 200; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			_, _ = plain.Load(ctx, "sku-1")
		}()
	}
	wg.Wait()
	plainElapsed := time.Since(start)

	gate := &gatedBackend{entered: make(chan struct{}), release: make(chan struct{})}
	p := NewProtector(gate, 100)
	for i := 0; i < 200; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			_, _ = p.Get(ctx, "sku-1")
		}()
	}
	select {
	case <-gate.entered:
	case <-time.After(2 * time.Second):
		t.Fatal("backend was never called")
	}
	deadline := time.Now().Add(2 * time.Second)
	for p.sf.shared.Load() < 199 && time.Now().Before(deadline) {
		time.Sleep(time.Millisecond)
	}
	close(gate.release)
	wg.Wait()

	if plain.calls.Load() != 200 || gate.calls.Load() != 1 || p.sf.shared.Load() != 199 {
		t.Fatalf("plain=%d singleflight=%d shared=%d",
			plain.calls.Load(), gate.calls.Load(), p.sf.shared.Load())
	}
	t.Logf("no_protection_calls=%d no_protection_elapsed=%s singleflight_calls=%d shared=%d backend_stat=%d",
		plain.calls.Load(), plainElapsed.Round(time.Millisecond), gate.calls.Load(),
		p.sf.shared.Load(), p.stats.BackendCalls)
}

func TestNullCacheHotKeyAndJitter(t *testing.T) {
	ctx := context.Background()
	b := &countingBackend{missing: map[string]bool{"missing": true}}
	p := NewProtector(b, 100)
	for i := 0; i < 500; i++ {
		if _, err := p.Get(ctx, "missing"); !errors.Is(err, ErrNotFound) {
			t.Fatalf("err = %v, want ErrNotFound", err)
		}
	}
	if b.calls.Load() != 1 {
		t.Fatalf("null cache backend calls = %d, want 1", b.calls.Load())
	}
	if s := p.stats; s.NullHits != 499 {
		t.Fatalf("null hits = %d, want 499", s.NullHits)
	}

	p.MarkHot("hot")
	for i := 0; i < 102; i++ {
		if _, err := p.Get(ctx, "hot"); err != nil {
			t.Fatal(err)
		}
	}
	if b.calls.Load() != 2 {
		t.Fatalf("hot replica missed: backend calls = %d, want 2", b.calls.Load())
	}
	s := p.stats
	if s.HotHits != 101 {
		t.Fatalf("hot hits = %d, want 101", s.HotHits)
	}

	p.jitter = 5 * time.Second
	seen := make(map[time.Duration]struct{})
	for i := 0; i < 100; i++ {
		seen[p.ttlFor(time.Minute)] = struct{}{}
	}
	if len(seen) < 90 {
		t.Fatalf("jitter not spread: %d distinct TTLs", len(seen))
	}
	t.Logf("backend_calls=%d null_hits=%d hot_hits=%d jitter_distinct=%d",
		b.calls.Load(), s.NullHits, s.HotHits, len(seen))
}

真实运行结果

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

$ go test ./... -count=1 -v
=== RUN   TestNoProtectionVsSingleflight
    protector_test.go:83: no_protection_calls=200 no_protection_elapsed=21ms singleflight_calls=1 shared=199 backend_stat=1
--- PASS: TestNoProtectionVsSingleflight (0.02s)
=== RUN   TestNullCacheHotKeyAndJitter
    protector_test.go:126: backend_calls=2 null_hits=499 hot_hits=101 jitter_distinct=100
--- PASS: TestNullCacheHotKeyAndJitter (0.00s)
PASS
ok  	archlab/internal/platform/cacheguard	0.461s

无保护时 200 个并发请求产生 200 次回源;singleflight 后只产生 1 次,199 个请求作为等待者复用结果;go test -race 同样通过(ok archlab/internal/platform/cacheguard 1.471s,见 ...-race.txt)。空值缓存让 500 次不存在查询只回源 1 次,499 次命中空值;热副本让 102 次热点读只回源 1 次,101 次命中本地副本;抖动在 100 次采样中产生 100 个不同 TTL。以上是单机单次运行,不是稳定基准。

副作用与关闭条件

singleflight 把 leader 的延迟和错误复制给所有等待者;如果 leader 超时,等待者一起失败。它只适合幂等的回源,且必须给回源设置超时,否则等待者会被无限拖住。关闭条件:后端自身已经做了请求合并,或单 key 并发很低、合并收益小于等待开销。
空值缓存会让“刚创建但尚未可见”的数据在 null TTL 内继续返回不存在。关闭条件:业务要求新建对象立即可见,或空值比例高到挤占正常缓存;arch-lab 的 null TTL 是 30 秒,超过这个窗口必须靠写路径主动删除空值标记。
TTL 抖动只打散过期时间,不降低总回源量;抖动范围过大还会让“刚写入就过期”的概率上升。关闭条件:缓存值必须严格按固定 TTL 失效,或过期时间由业务事件驱动而不是时间驱动。
热 key 本地副本的代价是实例间时差:200ms 内不同实例可能返回不同值。关闭条件:该 key 的价格、库存必须强一致,或写路径能主动推送失效;否则应改为每次读 L2,而不是本地副本。

常见错误与边界

第一,把 singleflight 套在缓存读之外,连命中请求也排队。它应该只包住“确认未命中之后”的回源段,否则会把并发的缓存命中串行化。
第二,用 nil 表示不存在。空值必须是一个显式的标记条目,否则无法区分“没查到”和“缓存里没有这个 key”。
第三,抖动范围拍脑袋。抖动应小于基础 TTL 的一半,并且要对“同时写入的一批 key”生效,而不是只给单个 key 加随机。

第四,热 key 列表无限增长。本地副本必须有容量上限和过期时间,否则热 key 治理会变成内存泄漏。

小结

本节把四种缓存故障落成同一套 Protector:singleflight 合并同一 key 的并发未命中,空值缓存挡住穿透,TTL 抖动打散过期,热 key 本地副本吸收单 key 流量。真实并发测试中,200 个请求的回源次数从 200 降到 1,199 个请求复用 leader 结果;空值缓存把 500 次不存在查询压到 1 次回源,热副本让 101 次热点读不再访问后端。每道保护都有明确的副作用和关闭条件,下一章讨论同步改异步的收益与代价。

阅读导航:上一节:3.2 缓存一致性、失效与更新策略 · 下一节:4.1 同步改异步的收益与代价 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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