提前一周知道要爆,系统还是没扛住
「拾光」准备做一场直播带货。活动时间提前一周就定好了,运营把商品、库存和优惠券都配置完毕,技术团队也确认了自动扩容策略: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 仍然接受。那意味着“峰值”低于基线,计划本身自相矛盾。
- 余量设为负数。负余量会算出比峰值更低的容量,调用方却以为已经留了缓冲。
- 预热分钟数为零或负数。公式会得到无穷大或负比例,排班表无法执行。
- 把预留实例当成万能解。它解决启动时间,不解决单机容量和依赖瓶颈,闲置成本也会持续累积。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。