《互联网产品的服务端架构·产品、流量与基础能力》7.3 边缘缓存与回源保护

从「回声」热点文章缓存 TTL 同时到期、98% 命中率仍把源站打挂的事故写起,说明边缘缓存必须配合合并回源、并发上限和等待预算三条保护机制,并给出 singleflight 语义的 Guard 实现与并发测试,解释为什么缓存越生效越危险。

缓存命中率 98%,源站还是在零点被打挂

「回声」的热点文章在整点更新。CDN 的命中率长期在 98% 左右,团队一直认为源站很安全:只有 2% 的请求会回源。一次大促的零点,源站在 40 秒内被打挂,CPU 打满,连接池耗尽,随后所有未命中请求都超时。

按案例假设值算一笔账:边缘总入口 20 万 QPS,2% 回源就是 4000 QPS,而源站在这个接口上的安全容量只有 800 QPS。平时这 2% 分散在各个 key 上,源站还能承受;零点文章更新,所有边缘节点的同一个 key 同时过期,4000 QPS 在几秒内集中打到同一个接口。

更反直觉的是,缓存命中率越高,这次冲击越集中。命中率高意味着源站平时很闲,容量规划按“低均值”做;而 miss 不是均匀到来的,它被 TTL 同步成了一条尖峰。缓存把流量从“持续的中等压力”变成了“长时间接近零,然后瞬间极值”。

事故后团队加了重试,情况反而更糟:超时的回源请求没有取消,继续占着连接;客户端重试又带来第二波流量;源站恢复时缓存已经全部失效,新一波请求再次把它压垮。缓存越生效,失效瞬间越危险。

三条保护:合并回源、限制并发、等待预算

边缘缓存不能只配置 TTL,还要假设“过期瞬间所有请求都会同时到达”。三条保护分别对应三个不同的问题:

保护解决的问题参数超限后的行为
合并回源同一个 key 的并发 miss 重复打源站key只放行一个,其余等待共享结果
限制并发不同 key 同时回源耗尽连接池MaxInflight拒绝并快速返回,不排队
等待预算等待者无限堆积,拖垮内存和 goroutineBudget直接降级,返回 stale 或占位

合并回源(singleflight)解决的是“同一份内容被重复取”。热点 key 过期时,边缘节点可能收到几千个相同请求;如果没有合并,每个请求都发一次回源。合并之后,第一个请求负责取,其余请求等它的结果。它不减少不同 key 的并发,所以还需要第二层。

限制并发解决的是“不同 key 一起把源站打满”。MaxInflight 限制同时在途的回源 key 数量,超过就直接拒绝。这里的关键是“快速拒绝”而不是“排队”:排队会让请求在网关里堆积,最终以更长的超时和更多连接结束,源站恢复后还要处理积压。

等待预算解决的是“等待者本身也是资源”。即使每个 key 只回源一次,几千个等待者仍然占着 goroutine 和内存。Budget 限制等待者总数,超过预算的请求直接降级,由边缘返回 stale 内容、占位页或 503。降级不是失败,而是在源站受保护时保住大部分用户的可用性。

三条保护缺一不可:只有合并,不同 key 仍能打满源站;只有并发上限,同 key 的等待者仍会堆积;只有预算,重复回源仍会浪费容量。

失败场景:TTL 同时到期,源站被自己的缓存打挂

事故的时间线很典型。文章在 00:00 更新,CDN 的 TTL 是 60 秒。00:00:00 第一批请求回源并写入缓存,之后每个边缘节点都在 00:01:00 左右过期。由于所有节点几乎同时收到更新后的第一波流量,它们的过期时间高度同步。

00:01:00 开始,热点 key 在每个节点同时 miss。没有合并时,同一节点的一千个并发请求各自回源;没有并发上限时,这些请求占满连接池;没有等待预算时,排队的请求继续占内存。源站在几秒内收到平时几十倍的请求,CPU 和数据库连接同时打满。

源站变慢后,回源请求超时。超时的请求没有取消,仍然占用连接;客户端看到超时后重试,带来第二波流量;边缘节点因为回源失败没有写入新缓存,后续请求继续 miss。故障从“一个热点 key 过期”扩散到“整个源站不可用”。

修复要同时做四件事:热点 key 使用 soft TTL,过期后先返回 stale 并异步刷新;同一 key 合并回源;MaxInflight 按源站安全容量设置;等待预算超限时直接返回 stale,而不是继续排队。最后再加一层抖动,让不同边缘节点的 TTL 不要完全同步。

用 Guard 合并回源并限制等待

下面的实现来自 internal/edge 包。Guard 用一个 map 记录在途 key 和等待者数量,Acquire 返回 true 的调用方负责回源,返回 false 的调用方等待共享结果,Release 结束这次回源。

// file: internal/edge/origin.go
// tier: L1
// verify: go test ./internal/edge/

package edge

import (
	"errors"
	"fmt"
	"sync"
)

var (
	// ErrEmptyKey 表示缓存键为空,无法做合并回源。
	ErrEmptyKey = errors.New("edge: empty origin key")
	// ErrInflightExceeded 表示回源并发已经达到上限。
	ErrInflightExceeded = errors.New("edge: origin inflight limit reached")
	// ErrBudgetExceeded 表示等待同一份回源的请求数超过预算。
	ErrBudgetExceeded = errors.New("edge: origin waiter budget exceeded")
)

// Guard 保护源站不被缓存失效瞬间的流量打穿。同一个 key 只有一个
// 调用方拿到放行(true),其余调用方拿到 false 并等待共享结果;
// MaxInflight 限制同时在途的 key 数,Budget 限制等待者总预算。
// 零值可用:两个上限为 0 时都不限制。
type Guard struct {
	MaxInflight int
	Budget      int

	mu       sync.Mutex
	inflight map[string]int // key -> 等待者数量
	waiting  int            // 所有 key 的等待者总数
}

// Acquire 尝试取得某个 key 的回源权。返回 true 表示调用方负责回源,
// 完成后必须调用 Release;返回 false 表示已有在途回源,调用方应等待
// 共享结果,且不要调用 Release。
func (g *Guard) Acquire(key string) (bool, error) {
	if key == "" {
		return false, ErrEmptyKey
	}

	g.mu.Lock()
	defer g.mu.Unlock()

	if waiters, ok := g.inflight[key]; ok {
		if g.Budget > 0 && g.waiting >= g.Budget {
			return false, fmt.Errorf("%w: budget=%d", ErrBudgetExceeded, g.Budget)
		}
		g.inflight[key] = waiters + 1
		g.waiting++
		return false, nil
	}

	if g.MaxInflight > 0 && len(g.inflight) >= g.MaxInflight {
		return false, fmt.Errorf("%w: max=%d", ErrInflightExceeded, g.MaxInflight)
	}
	if g.inflight == nil {
		g.inflight = make(map[string]int)
	}
	g.inflight[key] = 0
	return true, nil
}

// Release 结束一次回源。只有取得放行权的调用方应该调用它。
// 未知 key 会被安全忽略,重复调用不会让计数变成负数。
func (g *Guard) Release(key string) {
	g.mu.Lock()
	defer g.mu.Unlock()

	waiters, ok := g.inflight[key]
	if !ok {
		return
	}
	g.waiting -= waiters
	delete(g.inflight, key)
}

// Snapshot 返回当前在途的 key 数量,用于监控回源并发。
func (g *Guard) Snapshot() int {
	g.mu.Lock()
	defer g.mu.Unlock()
	return len(g.inflight)
}

第一个设计点是 inflight 的 value 是等待者数量,而不是布尔值。len(inflight) 就是当前在途的不同 key 数量,正好对应 MaxInflight;每个 key 的等待者数量用于 Budget 统计和 Release 时一次性清理。一个 map 同时表达两层状态,省掉了第二个计数器的同步问题。

第二个设计点是 Release 只由取得放行权的调用方执行。等待者拿到 false 后不应该调用 Release,否则会把 leader 的在途记录删掉,让第三个请求再次回源,singleflight 语义被破坏。这个约定写在注释里,因为类型系统无法区分“leader”和“waiter”。

第三个设计点是 Budget 统计的是等待者总数,不是回源请求数。它保护的是边缘进程的内存和 goroutine,而不是源站容量;源站容量由 MaxInflight 保护。超过预算时返回 ErrBudgetExceeded,调用方可以据此返回 stale 内容,而不是把错误直接透传给用户。

Release 对未知 key 直接返回,重复调用也不会让计数变成负数。这在真实系统里很重要:回源函数可能 panic,defer 里的 Release 仍会执行;上游取消也可能触发重复清理。保护边界的代码必须对重复和乱序操作安全。

Guard 的零值可用,MaxInflight 和 Budget 为 0 时都不限制。这方便测试和渐进上线:可以先只开启合并回源,观察效果后再设置并发和预算。

测试:64 个并发调用只放行一个

并发组件的测试必须同时覆盖顺序语义和竞态。下面的测试先逐条验证合并、并发上限、等待预算和零值行为,再用 64 个 goroutine 验证同一 key 只有一个调用方被放行。

// file: internal/edge/origin_test.go
// tier: L1
// verify: go test ./internal/edge/

package edge

import (
	"errors"
	"strconv"
	"sync"
	"sync/atomic"
	"testing"
)

func TestAcquireCoalescesSameKey(t *testing.T) {
	var g Guard
	ok, err := g.Acquire("home")
	if err != nil || !ok {
		t.Fatalf("first Acquire = (%v, %v), want (true, nil)", ok, err)
	}
	if ok, err := g.Acquire("home"); err != nil || ok {
		t.Fatalf("second Acquire = (%v, %v), want (false, nil)", ok, err)
	}
	if got := g.Snapshot(); got != 1 {
		t.Fatalf("Snapshot = %d, want 1", got)
	}
	g.Release("home")
	if ok, err := g.Acquire("home"); err != nil || !ok {
		t.Fatalf("Acquire after Release = (%v, %v), want (true, nil)", ok, err)
	}
}

func TestAcquireRejectsOverInflight(t *testing.T) {
	g := Guard{MaxInflight: 1}
	if ok, err := g.Acquire("a"); err != nil || !ok {
		t.Fatalf("Acquire(a) = (%v, %v)", ok, err)
	}
	if ok, err := g.Acquire("b"); ok || !errors.Is(err, ErrInflightExceeded) {
		t.Fatalf("Acquire(b) = (%v, %v), want inflight error", ok, err)
	}
	if got := g.Snapshot(); got != 1 {
		t.Fatalf("Snapshot = %d, want 1", got)
	}
	g.Release("a")
	if ok, err := g.Acquire("b"); err != nil || !ok {
		t.Fatalf("Acquire(b) after Release = (%v, %v)", ok, err)
	}
}

func TestAcquireEnforcesWaiterBudget(t *testing.T) {
	g := Guard{Budget: 2}
	if ok, err := g.Acquire("hot"); err != nil || !ok {
		t.Fatalf("leader Acquire = (%v, %v)", ok, err)
	}
	for i := 0; i < 2; i++ {
		if ok, err := g.Acquire("hot"); err != nil || ok {
			t.Fatalf("waiter %d = (%v, %v), want (false, nil)", i, ok, err)
		}
	}
	if ok, err := g.Acquire("hot"); ok || !errors.Is(err, ErrBudgetExceeded) {
		t.Fatalf("over-budget Acquire = (%v, %v), want budget error", ok, err)
	}
	g.Release("hot")
	if ok, err := g.Acquire("hot"); err != nil || !ok {
		t.Fatalf("Acquire after Release = (%v, %v)", ok, err)
	}
}

func TestBudgetZeroIsUnlimited(t *testing.T) {
	var g Guard
	if ok, err := g.Acquire("hot"); err != nil || !ok {
		t.Fatalf("leader Acquire = (%v, %v)", ok, err)
	}
	for i := 0; i < 100; i++ {
		if ok, err := g.Acquire("hot"); err != nil || ok {
			t.Fatalf("waiter %d = (%v, %v)", i, ok, err)
		}
	}
}

func TestMaxInflightZeroIsUnlimited(t *testing.T) {
	var g Guard
	for i := 0; i < 50; i++ {
		if ok, err := g.Acquire("key-" + strconv.Itoa(i)); err != nil || !ok {
			t.Fatalf("Acquire %d = (%v, %v)", i, ok, err)
		}
	}
	if got := g.Snapshot(); got != 50 {
		t.Fatalf("Snapshot = %d, want 50 distinct keys", got)
	}
}

func TestReleaseUnknownKeyDoesNotPanic(t *testing.T) {
	var g Guard
	g.Release("missing")
	if got := g.Snapshot(); got != 0 {
		t.Fatalf("Snapshot = %d, want 0", got)
	}

	if ok, err := g.Acquire("a"); err != nil || !ok {
		t.Fatalf("Acquire = (%v, %v)", ok, err)
	}
	g.Release("missing")
	if got := g.Snapshot(); got != 1 {
		t.Fatalf("unknown Release removed the flight: %d", got)
	}
	g.Release("a")
	g.Release("a")
	if got := g.Snapshot(); got != 0 {
		t.Fatalf("repeated Release = %d, want 0", got)
	}
}

func TestAcquireRejectsEmptyKey(t *testing.T) {
	var g Guard
	if ok, err := g.Acquire(""); ok || !errors.Is(err, ErrEmptyKey) {
		t.Fatalf("Acquire(\"\") = (%v, %v), want empty-key error", ok, err)
	}
}

func TestConcurrentAcquireAllowsExactlyOne(t *testing.T) {
	var g Guard
	const callers = 64

	var admitted atomic.Int32
	start := make(chan struct{})
	var wg sync.WaitGroup
	for i := 0; i < callers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			<-start
			ok, err := g.Acquire("same-key")
			if err != nil {
				t.Errorf("Acquire error: %v", err)
				return
			}
			if ok {
				admitted.Add(1)
			}
		}()
	}
	close(start)
	wg.Wait()

	if got := admitted.Load(); got != 1 {
		t.Fatalf("admitted = %d, want exactly 1", got)
	}
	if got := g.Snapshot(); got != 1 {
		t.Fatalf("Snapshot = %d, want 1", got)
	}
	g.Release("same-key")
	if got := g.Snapshot(); got != 0 {
		t.Fatalf("Snapshot after Release = %d, want 0", got)
	}
}

TestAcquireCoalescesSameKey 验证最基本的 singleflight 语义:第二次 Acquire 返回 false 且没有错误,Snapshot 仍然是 1;Release 之后可以重新取得放行权。

TestAcquireRejectsOverInflight 把 MaxInflight 设为 1,第二个不同 key 必须返回 ErrInflightExceeded,并且错误信息里带上限值。TestAcquireEnforcesWaiterBudget 则验证等待者数量达到预算后,新的等待请求收到 ErrBudgetExceeded;Release 会一次性清掉该 key 的所有等待者。

TestBudgetZeroIsUnlimited 和 TestMaxInflightZeroIsUnlimited 覆盖零值语义:100 个等待者和 50 个不同 key 都不会被拒绝。TestReleaseUnknownKeyDoesNotPanic 覆盖未知 key、重复 Release 和正常释放后的快照。

TestConcurrentAcquireAllowsExactlyOne 是最关键的一条。64 个 goroutine 在同一时刻调用 Acquire("same-key"),用原子计数器统计放行次数,结果必须恰好是 1,Snapshot 也必须是 1。这个测试在 -race 下同样有意义,因为所有状态都在同一把互斥锁内。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/edge/ -count=1 -v -run 'TestAcquireCoalescesSameKey|TestAcquireRejectsOverInflight|TestAcquireEnforcesWaiterBudget|TestConcurrentAcquireAllowsExactlyOne'

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestAcquireCoalescesSameKey
--- PASS: TestAcquireCoalescesSameKey (0.00s)
=== RUN   TestAcquireRejectsOverInflight
--- PASS: TestAcquireRejectsOverInflight (0.00s)
=== RUN   TestAcquireEnforcesWaiterBudget
--- PASS: TestAcquireEnforcesWaiterBudget (0.00s)
=== RUN   TestConcurrentAcquireAllowsExactlyOne
--- PASS: TestConcurrentAcquireAllowsExactlyOne (0.00s)
PASS
ok  	webapp/internal/edge	0.524s

输出里的 0.524s 是一次本机运行的耗时,会随机器和负载变化。四个测试分别覆盖合并、并发上限、等待预算和并发竞态;需要更严格验证时,可以加 -race 再跑一次。

小结

边缘缓存的三条保护解决三个不同的问题:合并回源避免同一个 key 重复打源站,MaxInflight 避免不同 key 一起耗尽连接,Budget 避免等待者堆积拖垮边缘进程。只做其中一条,另外两条仍会成为故障入口。

Guard 用一张 map 同时记录在途 key 和等待者数量,Acquire/Release 的契约是“leader 回源、waiter 等待、只有 leader 释放”。零值可用和未知 key 安全释放让它可以渐进上线,也方便在测试里复现并发边界。

缓存命中率越高,失效瞬间的尖峰越集中。真正的保护不是把 TTL 调长,而是假设“所有节点会同时过期”,并让过期后的第一波流量有序地回到源站。

阅读导航:上一节:7.2 网关的认证、路由与限流 · 下一节:8.1 读多写少的产品事实 。

常见错误与边界

  • 只配置 TTL,不做合并回源。热点 key 过期时,同一份内容被并发取上千次。
  • 把 MaxInflight 当成队列长度。排队会把超时和连接堆积得更严重,应该快速拒绝。
  • 等待者不设上限。goroutine 和内存被拖垮,源站恢复后还要处理积压。
  • 等待者调用 Release。在途记录被提前删除,第三个请求会再次回源。
  • 把 Budget 当成源站容量。它保护的是边缘进程,源站容量应由并发上限控制。
  • 回源超时后不取消。连接和 goroutine 继续占用,故障从缓存层扩散到整个进程。
  • 所有边缘节点使用相同 TTL。过期时间同步,miss 尖峰被放大。
  • 只提高命中率,不压测失效瞬间。均值好看不代表尖峰安全,缓存越生效越危险。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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