四种故障不是同一个问题
站内 缓存击穿/雪崩治理 已经给过热点探测与限流回源的通用方案,本节不重复清单,只在 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 同步改异步的收益与代价 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。