《互联网产品的服务端架构·产品、流量与基础能力》12.3 容量余量与弹性策略

从大促前自动扩容却因镜像拉取与预热耗时六分钟、流量提前到达的事故写起,讨论预留 30% 与留两倍容量的判断依据,把目标利用率、步长与冷却时间写成 Decide 纯函数,给出夹紧边界、死区与冷却期的测试和真实输出,并说明缩容为何要比扩容更保守。

扩容脚本跑了六分钟,流量三十秒就到了

「回声」的大促在晚上八点开始。七点五十分,值班同学确认弹性策略已开启:目标 CPU 利用率 60%,超过就每次加 4 个实例,冷却 60 秒。七点五十九分,监控显示流量开始爬升,八点整,扩容被触发。

八点零一分,新实例还在拉镜像。八点零二分,镜像拉完,应用启动,开始连数据库、加载配置、预热本地缓存。八点零五分,第一批新实例通过健康检查。八点零六分,它们才真正接入流量。

这六分钟里,入口 QPS 已经从 1800 涨到 4000。老实例的 CPU 打到 95%,请求在网关排队,p99 从 200 毫秒涨到 7 秒。弹性策略没有失效,它按预期扩了容;失效的是“扩容能及时救场”这个假设。

余量不是浪费,是故障时的缓冲

容量余量常被当成“可以砍掉的成本”。但余量的作用不是让平时更舒服,而是在三类时刻提供缓冲:流量突增、实例故障、下游变慢。

策略平时成本倍数能吸收的突增冷启动风险适用场景
无余量1.00几乎为零高内部工具、可暂停任务
20% 余量1.2525%中稳定增长的后台服务
30% 余量1.4343%中大多数在线业务
50% 余量2.00100%低有明确大促窗口的业务
两倍容量3.00200%低支付、登录等不可降级链路

成本倍数按 1 / (1 - 余量比例) 计算:留 30% 余量意味着只用到 70% 的容量,所以要多买 1.43 倍的机器。两倍容量是留 67% 余量,成本是三倍。

“预留 30% 还是两倍”没有统一答案,判断依据是三个问题:流量突增时,用户能不能被限流或排队?实例故障时,剩余容量能不能扛住?扩容从触发到接流量,需要多长时间?

如果产品可以降级、可以排队、可以容忍几分钟的慢,30% 余量加弹性通常够用。如果产品不能降级,比如支付确认和登录,扩容的几分钟就是不可接受的中断,必须用静态余量覆盖冷启动窗口。

弹性解决持续增长,不解决瞬时突增

弹性策略的价值在于跟随趋势,而不是抢在瞬时峰值之前。监控指标有采集窗口,扩容有触发延迟,新实例有启动和预热时间,这三段时间加起来,通常是一到十分钟。

一个常见的误区是把目标利用率设得很高,比如 85%,以为这样最省钱。实际上,85% 意味着任何 20% 的突增都会打满;而扩容需要几分钟,这几分钟里系统已经在排队。目标利用率越低,抗突增能力越强,但成本越高。

另一个误区是只看 CPU。CPU 是滞后指标:请求已经在排队时,CPU 可能还没打满;数据库连接池、线程池、下游限流可能先成为瓶颈。更可靠的做法是同时看入口 QPS、排队长度和 p99 延迟,任一指标越界都触发扩容。

缩容要比扩容保守。扩容慢了只是多花钱,缩容快了会在流量回升时反复抖动:缩掉实例、流量回来、再扩容、再缩容。常见做法是给缩容设更长的冷却时间,或者要求利用率低于目标值的一半才缩。

失败场景:大促前扩容,输在冷启动

回到「回声」。团队其实做了弹性配置,但只做了弹性配置。他们没有为大促预留静态容量,也没有提前拉镜像和预热缓存。

第一层问题是镜像。应用镜像 1.8GB,节点上没缓存,拉取花了 90 秒。第二层是启动:应用要连三个下游、加载 20 万条配置、预热本地缓存,又花了三分钟。第三层是接入:健康检查通过后,负载均衡还要逐步放量,避免新实例被瞬间打满。

三层加起来六分钟,而流量从 1800 涨到 4000 只用了 90 秒。弹性策略在这 90 秒里确实加了实例,但这些实例在接下来的六分钟里不能服务。系统实际可用容量没有变,排队却已经发生。

事后他们做了三件事:大促前把镜像预拉到节点,提前扩容并预热,给入口加限流和排队页。第二年大促,同样的流量曲线,p99 保持在 400 毫秒以内。容量没有变多,变的是“容量什么时候可用”。

用 Decide 把弹性策略写成纯函数

把策略判断从控制器里拆出来,输入当前实例数、利用率和时间,输出下一次实例数。纯函数不碰时钟、不发扩容请求,冷却时间也作为参数传入,测试可以直接构造“刚扩过”和“冷却已过”两种状态。

// file: internal/platform/capacity/elastic.go
// tier: L1
// verify: go test ./internal/platform/capacity/

package capacity

import "fmt"

// Policy 是弹性策略的全部可调参数。阈值、步长和冷却时间必须一起调,
// 单独把阈值调低只会换来更频繁的抖动。
type Policy struct {
	Min             int
	Max             int
	TargetUtil      float64 // 目标利用率,0.6 表示按 60% 负载运行
	Step            int     // 每次扩缩容的实例数
	CooldownSeconds int
}

// Scaler 保存上一次真正发生扩缩容的时间,供冷却判断使用。
type Scaler struct {
	LastScaleAt int64
}

// Decide 返回下一次应运行的实例数。它不执行扩缩容,只做纯决策,
// 便于在测试里把时间当参数注入,而不是靠 sleep。
func Decide(s *Scaler, now int64, current int, util float64, p Policy) (int, error) {
	switch {
	case s == nil:
		return 0, fmt.Errorf("capacity: scaler must not be nil")
	case now < 0:
		return 0, fmt.Errorf("capacity: now must not be negative")
	case p.Min < 1:
		return 0, fmt.Errorf("capacity: min must be at least 1")
	case p.Max < p.Min:
		return 0, fmt.Errorf("capacity: max must be >= min")
	case p.TargetUtil <= 0 || p.TargetUtil > 1:
		return 0, fmt.Errorf("capacity: target utilization must be in (0,1]")
	case p.Step < 1:
		return 0, fmt.Errorf("capacity: step must be at least 1")
	case p.CooldownSeconds < 0:
		return 0, fmt.Errorf("capacity: cooldown must not be negative")
	case current < 0:
		return 0, fmt.Errorf("capacity: current must not be negative")
	case util < 0:
		return 0, fmt.Errorf("capacity: utilization must not be negative")
	}

	// 从未扩缩容时 LastScaleAt 为 0,不应被误判为刚动作过。
	if s.LastScaleAt > 0 && now-s.LastScaleAt < int64(p.CooldownSeconds) {
		return current, nil
	}

	next := current
	switch {
	case util > p.TargetUtil:
		next = current + p.Step
	case util < p.TargetUtil/2:
		next = current - p.Step
	}

	if next < p.Min {
		next = p.Min
	}
	if next > p.Max {
		next = p.Max
	}
	if next != current {
		s.LastScaleAt = now
	}
	return next, nil
}

实现里有四个关键点。第一,利用率高于目标就加 Step,低于目标的一半才减 Step,中间是死区,避免在阈值附近来回抖。第二,结果夹在 Min 和 Max 之间,防止一次步长把实例数带出边界。第三,冷却期内直接返回当前值,不更新 LastScaleAt。第四,LastScaleAt 为 0 表示从未扩缩容,不能被误判为“刚动作过”。

测试:边界、死区与冷却期

测试覆盖五类行为:高于目标扩容、低于半目标缩容、死区内保持、上下界夹紧、冷却期内不动作且冷却结束后恢复动作。最后一组测试同时断言 LastScaleAt 的变化,确保“没动作”不会污染冷却状态。

// file: internal/platform/capacity/elastic_test.go
// tier: L1
// verify: go test ./internal/platform/capacity/

package capacity

import "testing"

func TestDecideScalesOutAboveTarget(t *testing.T) {
	s := &Scaler{}
	p := Policy{Min: 2, Max: 20, TargetUtil: 0.6, Step: 2, CooldownSeconds: 60}
	got, err := Decide(s, 1_000, 4, 0.9, p)
	if err != nil {
		t.Fatal(err)
	}
	if got != 6 {
		t.Fatalf("got %d, want 6", got)
	}
	if s.LastScaleAt != 1_000 {
		t.Fatalf("last scale = %d, want 1000", s.LastScaleAt)
	}
}

func TestDecideScalesInBelowHalfTarget(t *testing.T) {
	s := &Scaler{}
	p := Policy{Min: 2, Max: 20, TargetUtil: 0.6, Step: 2, CooldownSeconds: 60}
	got, err := Decide(s, 2_000, 10, 0.2, p)
	if err != nil {
		t.Fatal(err)
	}
	if got != 8 {
		t.Fatalf("got %d, want 8", got)
	}
}

func TestDecideHoldsInsideDeadBand(t *testing.T) {
	s := &Scaler{LastScaleAt: 500}
	p := Policy{Min: 2, Max: 20, TargetUtil: 0.6, Step: 2, CooldownSeconds: 60}
	got, err := Decide(s, 4_000, 10, 0.5, p)
	if err != nil {
		t.Fatal(err)
	}
	if got != 10 {
		t.Fatalf("got %d, want 10", got)
	}
	if s.LastScaleAt != 500 {
		t.Fatalf("dead band must not touch last scale, got %d", s.LastScaleAt)
	}
}

func TestDecideClampsToBounds(t *testing.T) {
	p := Policy{Min: 2, Max: 10, TargetUtil: 0.6, Step: 4, CooldownSeconds: 0}
	up, err := Decide(&Scaler{}, 100, 9, 1.0, p)
	if err != nil {
		t.Fatal(err)
	}
	if up != 10 {
		t.Fatalf("scale up clamp = %d, want 10", up)
	}
	down, err := Decide(&Scaler{}, 100, 3, 0.1, p)
	if err != nil {
		t.Fatal(err)
	}
	if down != 2 {
		t.Fatalf("scale down clamp = %d, want 2", down)
	}
}

func TestDecideRespectsCooldown(t *testing.T) {
	p := Policy{Min: 2, Max: 20, TargetUtil: 0.6, Step: 2, CooldownSeconds: 60}
	s := &Scaler{LastScaleAt: 1_000}

	during, err := Decide(s, 1_030, 4, 0.95, p)
	if err != nil {
		t.Fatal(err)
	}
	if during != 4 {
		t.Fatalf("cooldown = %d, want 4", during)
	}
	if s.LastScaleAt != 1_000 {
		t.Fatalf("cooldown must not move last scale, got %d", s.LastScaleAt)
	}

	after, err := Decide(s, 1_060, 4, 0.95, p)
	if err != nil {
		t.Fatal(err)
	}
	if after != 6 {
		t.Fatalf("after cooldown = %d, want 6", after)
	}
	if s.LastScaleAt != 1_060 {
		t.Fatalf("last scale = %d, want 1060", s.LastScaleAt)
	}
}

func TestDecideRejectsBadPolicy(t *testing.T) {
	ok := Policy{Min: 2, Max: 10, TargetUtil: 0.6, Step: 2, CooldownSeconds: 60}
	bad := []Policy{
		{Min: 0, Max: 10, TargetUtil: 0.6, Step: 2, CooldownSeconds: 60},
		{Min: 10, Max: 5, TargetUtil: 0.6, Step: 2, CooldownSeconds: 60},
		{Min: 2, Max: 10, TargetUtil: 0, Step: 2, CooldownSeconds: 60},
		{Min: 2, Max: 10, TargetUtil: 1.2, Step: 2, CooldownSeconds: 60},
		{Min: 2, Max: 10, TargetUtil: 0.6, Step: 0, CooldownSeconds: 60},
		{Min: 2, Max: 10, TargetUtil: 0.6, Step: 2, CooldownSeconds: -1},
	}
	for i, p := range bad {
		if _, err := Decide(&Scaler{}, 100, 4, 0.9, p); err == nil {
			t.Fatalf("case %d: want error", i)
		}
	}
	if _, err := Decide(&Scaler{}, 100, 4, 0.9, ok); err != nil {
		t.Fatalf("valid policy rejected: %v", err)
	}
	if _, err := Decide(nil, 100, 4, 0.9, ok); err == nil {
		t.Fatal("nil scaler: want error")
	}
}

这些测试都是确定性的:时间作为 int64 参数传入,不依赖 time.Sleep,也不会因为机器快慢而偶发失败。真正的控制器只需要在调用 Decide 后执行扩容,并把返回的实例数写回期望副本数。

运行命令与真实输出

cd .local/checks/webapp-lab
go test ./internal/platform/capacity/ -run 'TestDecide' -v -count=1
=== RUN   TestDecideScalesOutAboveTarget
--- PASS: TestDecideScalesOutAboveTarget (0.00s)
=== RUN   TestDecideScalesInBelowHalfTarget
--- PASS: TestDecideScalesInBelowHalfTarget (0.00s)
=== RUN   TestDecideHoldsInsideDeadBand
--- PASS: TestDecideHoldsInsideDeadBand (0.00s)
=== RUN   TestDecideClampsToBounds
--- PASS: TestDecideClampsToBounds (0.00s)
=== RUN   TestDecideRespectsCooldown
--- PASS: TestDecideRespectsCooldown (0.00s)
=== RUN   TestDecideRejectsBadPolicy
--- PASS: TestDecideRejectsBadPolicy (0.00s)
PASS
ok  	webapp/internal/platform/capacity	0.234s

0.234s 是一次本机运行的耗时,会随机器和负载变化。测试里没有真实扩容,也没有网络调用,所以这个数字只说明决策逻辑本身很轻。

小结

余量不是浪费,而是故障和突增时的缓冲。留 30% 还是留两倍,取决于产品能否降级、实例故障后剩余容量是否够用,以及扩容从触发到接流量需要多久。

弹性解决持续增长,不解决瞬时突增。镜像拉取、应用启动、缓存预热和负载均衡放量,每一步都要时间;把这些时间算进容量规划,弹性策略才不是一张写在纸上的保险。

下一章进入账号与资料,看一个看起来简单的用户中心,如何在 BFF、权限和隐私要求之间划清服务边界。

阅读导航:上一节:12.2 存储、带宽与机器成本 · 下一节:13.1 账号、资料与 BFF 的模块划分 。

常见错误与边界

  • 把余量当浪费,按 95% 利用率采购。任何突增都会直接排队。
  • 只配自动扩容,不留静态余量。冷启动的几分钟里,弹性策略救不了场。
  • 忽略镜像拉取和应用预热。新实例通过健康检查不等于能承接满负载。
  • 冷却时间短于监控采集窗口。指标还没稳定就再次动作,容易反复抖动。
  • 把目标利用率和扩容阈值当成同一个数。目标 60% 意味着 40% 的缓冲,不是 60% 的告警线。
  • 缩容和扩容用同一套冷却时间。缩容过快会在流量回升时反复扩缩。
  • 把 Min 设成 0 或 1。低谷期缩到零,下一次流量到来时无从承接。
  • 大促前不做扩容演练。配置存在不等于链路通,预热和放量都要提前验证。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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