《互联网产品的服务端架构·规模化与架构演进》3.2 弹性扩容与预热

从一场提前排期却仍然打垮系统的直播带货写起,说明弹性扩容的瓶颈不是决策而是冷启动,预热必须按分钟计算;给出容量测算与每分钟预热比例的 Go 实现、参数校验测试和真实运行输出,并讨论扩容速度跟不上时提前决策的边界,以及预留实例的成本取舍和依赖初始化风暴。

提前一周知道要爆,系统还是没扛住

「拾光」准备做一场直播带货。活动时间提前一周就定好了,运营把商品、库存和优惠券都配置完毕,技术团队也确认了自动扩容策略:CPU 超过 60% 就加实例,每 30 秒检查一次,单次最多加 20%。

直播在晚上八点开始。八点零三分,主播喊出“上链接”,商品详情接口的 QPS 在 50 秒内从 2000 涨到 15000。自动扩容在第三分钟才第一次真正加上实例,而新实例从拉起、拉取镜像、初始化连接池到通过健康检查,又花了将近四分钟。等它们开始接流量,用户看到的已经是转圈和“活动太火爆,请稍后再试”。

事后复盘,扩容策略本身没有写错,错在把“扩容”当成了一个瞬时动作。从决策到实例真正可用,中间有一整段冷启动时间;这段时间里,系统的容量不会因为控制面已经决定扩容而立刻增加。活动明明可以预测,却仍然用“出事再扩”的方式应对,等于主动放弃了唯一的提前量。

扩容速度的三个约束

讨论弹性扩容时,最容易只看“多久触发一次扩容”,而忽略“新实例多久能接流量”。后者才是热点场景的瓶颈。一次完整的扩容至少包含决策、调度、启动、就绪四个阶段,每个阶段都有独立的时间下限。

扩容方式从决策到可用的典型时间每分钟可补充的容量适用场景主要限制
进程内预热秒级受单机 CPU 与内存限制已加载的热点数据、连接池只能预热已有实例
镜像预热加快速启动30 秒到 2 分钟取决于调度并发与镜像大小无状态服务、可提前拉镜像依赖节点资源与镜像仓库
常规弹性扩容2 到 5 分钟受限于启动脚本与健康检查缓慢爬升的日常高峰冷启动期间容量为零
预留实例接近零固定上限可预测的大促、直播成本高,闲置也计费

这张表说明一个事实:扩容速度不是一个标量,而是“每分钟能补多少容量”的斜率。热点流量也有自己的斜率。如果流量的增长速度是每分钟增加 3000 QPS,而扩容只能每分钟补充 1500 QPS,那么无论提前多久触发自动扩容,缺口都会越拉越大。此时唯一有效的动作不是把阈值调低,而是提前把容量准备好。

预热就是提前准备的具体形式。它可以是提前拉起实例并灌入缓存,可以是提前建立数据库连接池,也可以是提前把静态资源推到边缘节点。关键是把“预热”从一句口号变成可计算的指标:在给定的预热窗口内,每分钟需要让多大比例的最终容量进入就绪状态。

失败场景:扩容追不上,越扩越慢

案例假设值下,活动基线是 1000 QPS,峰值系数 4,预留 25% 余量,最终需要 5000 QPS 的容量。团队有 10 分钟预热窗口,但没有人把这个窗口换算成每分钟的动作,只在活动开始前五分钟手动把实例数翻了一倍。

直播开始后,流量在 50 秒内达到峰值。剩下的容量缺口只能靠自动扩容补,而自动扩容每次只加 20%,新实例启动需要四分钟。更糟的是,新实例启动时会同时向配置中心、数据库和缓存发起初始化请求,几十个实例一起启动,反而把配置中心和数据库连接池打满。控制面看到错误率上升,又触发更多扩容,形成正反馈。

后果有三层。第一层是可用性:活动前十分钟的订单失败率超过 20%,而这些订单大多不会回来。第二层是成本:为了追赶流量,系统在短时间内拉起了远超实际需要的实例,活动结束后又集中释放,账单出现一个尖峰。第三层是信任:运营团队下次不再相信“自动扩容”这四个字,转而要求提前锁死大量预留实例,成本进一步上升。

修复的办法是把预热做成按分钟推进的计划。以上面的数字为例,最终需要 5000 QPS,基线 1000 QPS,新增 4000 QPS 要在 10 分钟内完成,平均每分钟要补 400 QPS,占最终容量的 8%。这个比例可以提前排进值班表:第 1 分钟把实例池拉到 8%,第 2 分钟到 16%,依此类推,而不是等到流量来了再看 CPU。

用 Required 与 Preheat 把预热变成分钟计划

下面的实现来自 webapp-lab 的 internal/burst 包。Plan 描述基线、峰值系数和余量,Required 算出峰值需要承载的 QPS,Preheat 把新增容量均摊到预热窗口,返回每分钟需要就绪的实例比例。

// file: internal/burst/capacity.go
// tier: L1
// verify: go test ./internal/burst/

package burst

import (
	"errors"
	"fmt"
)

var (
	// ErrInvalidPlan 表示容量计划本身不成立。
	ErrInvalidPlan = errors.New("burst: invalid capacity plan")
	// ErrInvalidPreheat 表示预热窗口不合法。
	ErrInvalidPreheat = errors.New("burst: preheat minutes must be positive")
)

// Plan 描述一次热点容量测算。
// BaselineQPS 是日常基线,PeakFactor 是峰值相对基线的倍数,
// Headroom 是额外留出的余量比例(0.2 表示再留 20%)。
type Plan struct {
	BaselineQPS float64
	PeakFactor  float64
	Headroom    float64
}

func (p Plan) validate() error {
	switch {
	case p.BaselineQPS <= 0:
		return fmt.Errorf("%w: baseline QPS must be positive", ErrInvalidPlan)
	case p.PeakFactor < 1:
		return fmt.Errorf("%w: peak factor must be at least 1", ErrInvalidPlan)
	case p.Headroom < 0:
		return fmt.Errorf("%w: headroom must not be negative", ErrInvalidPlan)
	}
	return nil
}

// Required 返回峰值期间需要承载的 QPS。
func Required(p Plan) (float64, error) {
	if err := p.validate(); err != nil {
		return 0, err
	}
	return p.BaselineQPS * p.PeakFactor * (1 + p.Headroom), nil
}

// Preheat 返回每分钟需要预热的实例比例。
// 计算方式是:先算出峰值需要的新增容量,均摊到预热窗口的每一分钟,
// 再除以最终实例池大小。0.08 表示每分钟要拉起初始终态 8% 的实例。
func Preheat(p Plan, minutes int) (float64, error) {
	if minutes <= 0 {
		return 0, fmt.Errorf("%w: got %d", ErrInvalidPreheat, minutes)
	}
	required, err := Required(p)
	if err != nil {
		return 0, err
	}
	extra := required - p.BaselineQPS
	if extra <= 0 {
		return 0, nil
	}
	return extra / required / float64(minutes), nil
}

Required 的公式是基线乘以峰值系数,再乘以一加余量。余量不是拍脑袋的保险,而是给扩容误差、实例抖动和流量模型偏差留的缓冲。Preheat 先调用 Required,保证容量计划本身合法,再算新增部分占最终容量的比例,最后除以分钟数。

这里返回的是“比例”而不是“实例数”,因为实例规格会变,比例不会。运维拿到 0.08,可以换算成“最终 100 个实例里每分钟拉起 8 个”,也可以换算成“最终 400 个实例里每分钟拉起 32 个”。把规格从函数里剥离出去,测试和跨团队沟通都更简单。

参数校验同样是接口的一部分。基线必须为正,峰值系数不能小于 1,余量不能为负,预热分钟数必须为正。这些条件看起来琐碎,但少一个校验,调用方就可能传入 0 分钟,得到无穷大的每分钟比例,再把一个不可能完成的计划排进值班表。

测试:峰值、余量与非法窗口

容量计算的测试重点不是复杂算法,而是公式和边界。下面的用例固定了乘法关系、非法计划、预热比例的均摊效果,以及窗口缩短时每分钟压力必须上升。

// file: internal/burst/capacity_test.go
// tier: L1
// verify: go test ./internal/burst/

package burst

import (
	"errors"
	"math"
	"testing"
)

func TestRequiredMultipliesPeakAndHeadroom(t *testing.T) {
	got, err := Required(Plan{BaselineQPS: 1000, PeakFactor: 4, Headroom: 0.25})
	if err != nil {
		t.Fatalf("Required returned error: %v", err)
	}
	if math.Abs(got-5000) > 1e-9 {
		t.Fatalf("Required = %v, want 5000", got)
	}
}

func TestRequiredRejectsInvalidPlan(t *testing.T) {
	cases := []Plan{
		{BaselineQPS: 0, PeakFactor: 2, Headroom: 0.1},
		{BaselineQPS: -1, PeakFactor: 2, Headroom: 0.1},
		{BaselineQPS: 100, PeakFactor: 0.5, Headroom: 0.1},
		{BaselineQPS: 100, PeakFactor: 2, Headroom: -0.1},
	}
	for _, p := range cases {
		if _, err := Required(p); !errors.Is(err, ErrInvalidPlan) {
			t.Fatalf("Required(%+v) error = %v, want ErrInvalidPlan", p, err)
		}
	}
}

func TestPreheatSpreadsGrowthPerMinute(t *testing.T) {
	got, err := Preheat(Plan{BaselineQPS: 1000, PeakFactor: 4, Headroom: 0.25}, 10)
	if err != nil {
		t.Fatalf("Preheat returned error: %v", err)
	}
	if math.Abs(got-0.08) > 1e-9 {
		t.Fatalf("Preheat = %v, want 0.08", got)
	}
}

func TestPreheatShorterWindowNeedsMorePerMinute(t *testing.T) {
	fast, err := Preheat(Plan{BaselineQPS: 1000, PeakFactor: 4, Headroom: 0.25}, 5)
	if err != nil {
		t.Fatalf("Preheat returned error: %v", err)
	}
	slow, err := Preheat(Plan{BaselineQPS: 1000, PeakFactor: 4, Headroom: 0.25}, 20)
	if err != nil {
		t.Fatalf("Preheat returned error: %v", err)
	}
	if !(fast > slow) {
		t.Fatalf("5-minute rate %v should exceed 20-minute rate %v", fast, slow)
	}
}

func TestPreheatNoGrowthIsZero(t *testing.T) {
	got, err := Preheat(Plan{BaselineQPS: 500, PeakFactor: 1, Headroom: 0}, 10)
	if err != nil {
		t.Fatalf("Preheat returned error: %v", err)
	}
	if got != 0 {
		t.Fatalf("Preheat = %v, want 0 when there is no growth", got)
	}
}

func TestPreheatRejectsNonPositiveMinutes(t *testing.T) {
	p := Plan{BaselineQPS: 1000, PeakFactor: 2, Headroom: 0.1}
	for _, minutes := range []int{0, -1} {
		if _, err := Preheat(p, minutes); !errors.Is(err, ErrInvalidPreheat) {
			t.Fatalf("Preheat(minutes=%d) error = %v, want ErrInvalidPreheat", minutes, err)
		}
	}
}

func TestPreheatPropagatesPlanError(t *testing.T) {
	if _, err := Preheat(Plan{BaselineQPS: 0, PeakFactor: 2}, 10); !errors.Is(err, ErrInvalidPlan) {
		t.Fatalf("error = %v, want ErrInvalidPlan", err)
	}
}

TestRequiredMultipliesPeakAndHeadroom 用 1000、4、0.25 得到 5000,直接把公式钉死。TestRequiredRejectsInvalidPlan 覆盖基线为零、基线为负、峰值系数小于 1、余量为负四种情况,确认错误可以用 errors.Is(err, ErrInvalidPlan) 判断。TestPreheatSpreadsGrowthPerMinute 在 10 分钟窗口下得到 0.08,和正文里的手算一致。

TestPreheatShorterWindowNeedsMorePerMinute 比较 5 分钟和 20 分钟两个窗口,只断言前者大于后者,不绑定具体数值。这样即使以后调整公式里的归一化方式,测试仍然表达“窗口越短,每分钟压力越大”这个业务事实。TestPreheatNoGrowthIsZero 固定峰值系数为 1 时返回 0,避免没有增长却要求预热的荒谬计划。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/burst/ -count=1 -run 'TestRequired|TestPreheat' -v

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

=== RUN   TestRequiredMultipliesPeakAndHeadroom
--- PASS: TestRequiredMultipliesPeakAndHeadroom (0.00s)
=== RUN   TestRequiredRejectsInvalidPlan
--- PASS: TestRequiredRejectsInvalidPlan (0.00s)
=== RUN   TestPreheatSpreadsGrowthPerMinute
--- PASS: TestPreheatSpreadsGrowthPerMinute (0.00s)
=== RUN   TestPreheatShorterWindowNeedsMorePerMinute
--- PASS: TestPreheatShorterWindowNeedsMorePerMinute (0.00s)
=== RUN   TestPreheatNoGrowthIsZero
--- PASS: TestPreheatNoGrowthIsZero (0.00s)
=== RUN   TestPreheatRejectsNonPositiveMinutes
--- PASS: TestPreheatRejectsNonPositiveMinutes (0.00s)
=== RUN   TestPreheatPropagatesPlanError
--- PASS: TestPreheatPropagatesPlanError (0.00s)
PASS
ok  	webapp/internal/burst	0.226s

七个用例全部通过,包级结果是 ok webapp/internal/burst。输出里的耗时只覆盖内存计算;真实预热还要加上镜像拉取、配置加载、缓存灌入和健康检查的时间,这些才是决定“每分钟能补多少”的主要因素。

小结

弹性扩容的决策很快,真正慢的是冷启动。热点流量的斜率一旦超过扩容的斜率,缺口就会持续扩大,此时调低阈值只会让控制面更忙,不会让容量更早到位。

预热的价值在于把可预测性换成时间。Required 给出最终容量,Preheat 把新增容量摊到每一分钟,得到一个可以排班、可以验收的比例。这个比例是否可行,还要回到镜像大小、启动脚本、依赖服务和节点资源上逐项确认。公式只能保证计划自洽,不能保证系统真的能按计划启动。

阅读导航:上一节:3.1 热点事件的流量特征 · 下一节:3.3 降级与排队 。

常见错误与边界

  • 把扩容当成瞬时动作。从决策到实例可用有完整的冷启动时间,这段时间容量不会增加。
  • 只比较流量峰值和扩容后的总容量。还要比较流量斜率和扩容斜率,否则缺口会在过程中扩大。
  • 预热计划只写“提前扩容”。没有分钟级比例,执行时仍然会集中在活动前几分钟。
  • 让新实例同时初始化依赖。几十个实例一起打配置中心和数据库,会把扩容变成二次故障。
  • 峰值系数小于 1 仍然接受。那意味着“峰值”低于基线,计划本身自相矛盾。
  • 余量设为负数。负余量会算出比峰值更低的容量,调用方却以为已经留了缓冲。
  • 预热分钟数为零或负数。公式会得到无穷大或负比例,排班表无法执行。
  • 把预留实例当成万能解。它解决启动时间,不解决单机容量和依赖瓶颈,闲置成本也会持续累积。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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