《互联网产品的服务端架构·规模化与架构演进》11.1 容量规划与压测

「回声」大促前一周,业务说峰值是日常的五倍,运维说机器还能再加,压测报告却和线上表现对不上。本节把容量测算拆成业务峰值、安全系数与单实例能力三步,给出可测试的 Plan 函数,并复盘一次因压测请求体、缓存命中率和数据量与线上不同口径,导致开场十分钟即排队的故障。

开场前一周,容量数字还在打架

「回声」是一档限时抢购产品,大促开场时间是周五晚上八点。活动前一周,三个团队给出了三个不同的容量结论:业务说峰值是日常的五倍,运维说机器还能再加二十台,压测报告说单实例可以扛八百 QPS。三个数字都能在各自的上下文里成立,放在一起却推不出“到底要准备多少台”。

问题不在于谁算错了,而在于每个人都只回答了自己那一半。业务给的是订单峰值,不是接口 QPS;运维给的是机器数量,不是单机有效容量;压测报告给的是特定请求下的吞吐,不是线上真实请求。容量规划真正要做的,是把这三段接成一条可复核的链路,并明确每一段的口径。

这一节先讲怎么从业务峰值推到接口 QPS,再讲压测为什么必须和线上同口径,最后用一个可测试的 Go 函数把测算过程固定下来,并复盘一次“压测通过、开场排队”的故障。

从业务峰值推 QPS,而不是从机器推

容量测算的起点应该是业务事件,而不是机器指标。一次大促的峰值通常由三个量决定:预计参与人数、活动开始后的集中访问比例、以及每人会触发多少次接口调用。案例假设「回声」日常下单接口基线是 1200 QPS,大促峰值系数取 5,安全系数取 1.3,那么目标容量就是 1200 × 5 × 1.3 = 7800 QPS。

这个公式看起来简单,难点全在口径。业务说的“五倍”可能是订单量五倍,但下单接口的 QPS 还受购物车合并、重试、页面轮询影响;安全系数也不是越大越好,1.3 意味着多准备 30% 的机器,1.8 可能让成本翻倍却只换来很少的额外保障。数字必须写清楚来源和假设,否则事后无法判断是预测错了还是执行错了。

口径常见说法实际含义容易漏掉的部分
业务峰值订单量是日常五倍成交订单数的倍数未支付订单、重复提交、页面轮询
接口峰值下单接口 6000 QPS进入服务端的请求速率网关重试、客户端轮询、压测流量
单机容量单实例 800 QPS特定请求与数据下的吞吐请求体大小、缓存命中率、下游延迟
目标容量7800 QPS峰值乘安全系数安全系数的依据与成本约束

表里的数字都是案例假设值,不是行业基准。真正要带走的是最后一列:每一个口径都有它默认忽略的东西,容量规划就是把“漏掉的部分”显式补回来。

先算接口,再算实例

业务峰值不能直接除以单机容量。中间至少要经过两步换算:先把业务事件折算成每个接口的 QPS,再按接口的耗时和资源占比折算成实例数。一个下单请求可能触发库存扣减、优惠券核销、支付单创建三个下游调用,任何一个下游变慢,都会让下单接口的并发占用上升,单机可承载的 QPS 随之下降。

因此单机容量不能只测一个接口。至少要分别测“纯读”“纯写”“读写混合”三类请求,再按线上流量比例加权。案例假设「回声」大促流量中 70% 是商品详情读、20% 是下单写、10% 是支付回调,那么单机容量应该用这个比例下的混合吞吐,而不是拿商品详情的峰值去代表所有接口。

安全系数买的是反应时间

安全系数常被当成“留余量”的同义词,但它真正买的是反应时间。峰值预测偏差 20% 时,1.3 的系数还能撑住;扩缩容需要十分钟时,系数要覆盖这十分钟的流量爬升;下游出现抖动时,系数要覆盖重试带来的额外请求。把安全系数写成 1.3 却不写它对应多长的反应窗口,等于把风险留给值班同学临场判断。

反过来,安全系数也不能替代限流。它只能吸收预测误差,不能吸收无限流量。大促当天如果入口流量是预测的三倍,再多机器也会被打满,这时候需要的是 11.2 要讲的限流与降级,而不是继续加机器。

压测要同口径,否则数字只是安慰

压测报告最常见的错误,是只写“单实例 800 QPS”,不写请求长什么样。请求体从 200 字节变成 4 KB,网络和序列化成本会明显上升;缓存命中率从 99% 掉到 70%,数据库读会被放大几十倍;压测库是空表、线上库有十亿行,索引深度和锁竞争完全不是一回事。

同口径至少包含五项:请求体大小与字段分布、读写比例、缓存命中率、数据库数据量与索引状态、下游依赖的延迟与错误率。压测环境不可能完全复制线上,但必须把差异写进报告,并在容量数字上打折扣。案例假设压测在缓存预热、空库、小请求体下得到单机 800 QPS,线上冷缓存、大请求体、热点行锁下只有 300 QPS,这个 2.6 倍的差距如果不在报告里,扩容就会少买一半机器。

压测流量要打标

压测流量必须能和生产流量区分开。常见做法是加特殊 Header、独立入口域名或独立集群。没有打标的压测流量会污染监控、触发真实优惠、写脏订单,甚至把压测机的瓶颈误判成服务端瓶颈。压测机本身也要检查:当压测客户端先跑满 CPU 或连接数时,报告里的“服务端上限”其实是“压测机上限”。

压测结果要看分位数,不只看平均值

平均值会掩盖长尾。一个接口平均 50 ms,但 p99 是 2 秒,大促时用户感受到的是那 1% 的卡顿,而不是平均值。容量规划应该以 p99 或 p999 达标为前提,再看该延迟下的吞吐。如果只按平均延迟扩容,峰值时排队会迅速放大,因为慢请求占住连接,后续请求连进来的机会都变少。

一次“压测通过、开场排队”的故障

事故发生在一次被标记为“压测通过”的大促。压测报告显示,下单接口单实例 800 QPS,准备 12 台机器,按 1.3 安全系数计算可以扛 9600 QPS,而预测峰值只有 7800 QPS。结论是容量充足,甚至还有余量。

开场十分钟后,下单接口开始排队,p99 从 80 ms 涨到 4 秒,部分用户看到“系统繁忙”。监控显示 12 台机器的 CPU 都只有 45%,数据库连接池却先满了。值班同学第一反应是加机器,但新实例启动后仍然排队,因为瓶颈不在应用 CPU。

根因有三处口径差异。第一,压测请求体是 200 字节的合成 JSON,线上真实下单请求平均 4 KB,还带商品快照和地址信息,序列化与网络开销更高。第二,压测时缓存预热到 99%,线上开场瞬间缓存是冷的,大量请求穿透到数据库。第三,压测库只有十万行数据,线上订单表有三亿行,库存扣减的热点行锁等待被严重低估。三个差异叠加,单机有效容量从 800 QPS 掉到约 300 QPS,12 台机器只相当于压测口径下的 4.5 台。

更值得反思的是报告结构。报告只有“单实例 QPS”和“建议机器数”,没有请求体大小、缓存命中率、数据库数据量,也没有写压测环境与生产环境的差异。评审时没有人能发现问题,因为缺失的信息根本不在纸上。修复不是简单加机器,而是把压测口径写成报告的第一页,并规定口径不一致时容量数字必须打折。

把容量测算写成可测试的函数

配套代码 internal/promo/capacity.go 只做三件事:校验输入、按峰值与安全系数算实例数、在余量不足时返回 warning。它不连接监控、不读配置中心,也不决定要不要扩容,因为这些都会让“这个数字怎么来的”难以复核。

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

package promo

import (
	"errors"
	"fmt"
	"math"
)

// ErrInvalidCapacity 表示容量输入本身不成立。
var ErrInvalidCapacity = errors.New("promo: invalid capacity input")

// InstanceQPS 是单实例在目标延迟下可承载的 QPS(案例假设值)。
// 生产环境应替换为压测得到的实测值,而不是照抄这个常量。
const InstanceQPS = 400.0

// HeadroomFloor 是推荐的最低余量比例。
const HeadroomFloor = 0.2

// Input 是一次大促容量测算的输入。
// BaselineQPS 是日常基线,PeakFactor 是峰值相对基线的倍数,
// SafeRate 是安全系数(1.3 表示在峰值上再留 30% 余量)。
type Input struct {
	BaselineQPS float64
	PeakFactor  float64
	SafeRate    float64
}

// Capacity 是测算结果。
// PeakQPS 是未经安全系数的峰值,Instances 是按 SafeRate 扩容后的实例数,
// Headroom 是安全系数折算出的余量比例。
type Capacity struct {
	PeakQPS   float64
	Instances float64
	Headroom  float64
}

func (i Input) validate() error {
	switch {
	case i.BaselineQPS <= 0:
		return fmt.Errorf("%w: baseline QPS must be positive, got %v", ErrInvalidCapacity, i.BaselineQPS)
	case i.PeakFactor < 1:
		return fmt.Errorf("%w: peak factor must be at least 1, got %v", ErrInvalidCapacity, i.PeakFactor)
	case i.SafeRate < 1:
		return fmt.Errorf("%w: safe rate must be at least 1, got %v", ErrInvalidCapacity, i.SafeRate)
	}
	return nil
}

// Plan 从业务峰值推出需要承载的 QPS 与实例数。
// 第二个返回值是 warning:余量低于 HeadroomFloor 时提示风险,
// 但仍然返回测算结果,方便调用方在预案里显式记录“已知余量不足”。
func Plan(i Input) (Capacity, string, error) {
	if err := i.validate(); err != nil {
		return Capacity{}, "", err
	}
	peak := i.BaselineQPS * i.PeakFactor
	instances := math.Ceil(peak * i.SafeRate / InstanceQPS)
	headroom := i.SafeRate - 1
	c := Capacity{PeakQPS: peak, Instances: instances, Headroom: headroom}
	if headroom < HeadroomFloor-1e-9 {
		return c, fmt.Sprintf("promo: headroom %.0f%% is below the %.0f%% floor", headroom*100, HeadroomFloor*100), nil
	}
	return c, "", nil
}

几个设计点值得说明。

Plan 返回三个值:容量、warning、error。warning 和 error 分开,是因为“余量不足”不是计算失败,而是一个需要被记录的风险。调用方可以继续用这个容量数字做预案,但必须把 warning 写进评审材料。如果把余量不足做成 error,调用方要么忽略错误继续跑,要么被迫中断,两种行为都不理想。

InstanceQPS 写成常量,是为了让示例可运行。生产代码里它应该来自配置或压测结果,并且按接口、按机型分别配置。把单实例容量写死成全局常量,会在机型混布或接口差异很大时给出错误的实例数。

实例数用 math.Ceil 向上取整。容量规划不能在实例数上四舍五入:14.3 台只能买 15 台,少买一台就意味着峰值时所有实例都跑在超过设计容量的状态。

余量比较用了 HeadroomFloor-1e-9 而不是直接比较。浮点数里 1.2 - 1 并不精确等于 0.2,直接比较会把“刚好达标”误判成“低于下限”,让边界用例产生假告警。

代码没有判断“预测峰值是否可信”。这不是函数能回答的问题,应该由压测报告和业务假设评审来回答。函数只保证:给定输入,算出的数字可重复、可测试、边界明确。

测试:边界、余量告警和坏输入

容量函数的测试重点不是算得快,而是边界行为稳定。下面的测试固定了正常放大、向上取整、余量告警、刚好达标和非法输入五类情况。

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

package promo

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

func TestPlanScalesPeakAndInstances(t *testing.T) {
	c, warn, err := Plan(Input{BaselineQPS: 1000, PeakFactor: 4, SafeRate: 1.3})
	if err != nil {
		t.Fatalf("Plan returned error: %v", err)
	}
	if warn != "" {
		t.Fatalf("unexpected warning: %q", warn)
	}
	if math.Abs(c.PeakQPS-4000) > 1e-9 {
		t.Fatalf("PeakQPS = %v, want 4000", c.PeakQPS)
	}
	// 4000 * 1.3 / 400 = 13,刚好整除。
	if math.Abs(c.Instances-13) > 1e-9 {
		t.Fatalf("Instances = %v, want 13", c.Instances)
	}
	if math.Abs(c.Headroom-0.3) > 1e-9 {
		t.Fatalf("Headroom = %v, want 0.3", c.Headroom)
	}
}

func TestPlanRoundsInstancesUp(t *testing.T) {
	c, warn, err := Plan(Input{BaselineQPS: 1100, PeakFactor: 4, SafeRate: 1.3})
	if err != nil {
		t.Fatalf("Plan returned error: %v", err)
	}
	if warn != "" {
		t.Fatalf("unexpected warning: %q", warn)
	}
	// 4400 * 1.3 / 400 = 14.3,向上取整为 15。
	if math.Abs(c.Instances-15) > 1e-9 {
		t.Fatalf("Instances = %v, want 15", c.Instances)
	}
}

func TestPlanWarnsWhenHeadroomBelowFloor(t *testing.T) {
	c, warn, err := Plan(Input{BaselineQPS: 1000, PeakFactor: 4, SafeRate: 1.1})
	if err != nil {
		t.Fatalf("Plan returned error: %v", err)
	}
	if warn == "" {
		t.Fatal("want warning for 10% headroom")
	}
	if !strings.Contains(warn, "below") {
		t.Fatalf("warning %q should explain the floor", warn)
	}
	if math.Abs(c.Headroom-0.1) > 1e-9 {
		t.Fatalf("Headroom = %v, want 0.1", c.Headroom)
	}
}

func TestPlanAcceptsExactFloor(t *testing.T) {
	_, warn, err := Plan(Input{BaselineQPS: 1000, PeakFactor: 4, SafeRate: 1.2})
	if err != nil {
		t.Fatalf("Plan returned error: %v", err)
	}
	if warn != "" {
		t.Fatalf("headroom exactly at floor should not warn, got %q", warn)
	}
}

func TestPlanRejectsInvalidInput(t *testing.T) {
	cases := []Input{
		{BaselineQPS: 0, PeakFactor: 2, SafeRate: 1.2},
		{BaselineQPS: -1, PeakFactor: 2, SafeRate: 1.2},
		{BaselineQPS: 100, PeakFactor: 0.9, SafeRate: 1.2},
		{BaselineQPS: 100, PeakFactor: 2, SafeRate: 0.99},
	}
	for _, in := range cases {
		_, _, err := Plan(in)
		if !errors.Is(err, ErrInvalidCapacity) {
			t.Fatalf("Plan(%+v) error = %v, want ErrInvalidCapacity", in, err)
		}
	}
}

TestPlanRoundsInstancesUp 专门锁住向上取整。如果以后有人把 math.Ceil 改成 math.Round,14.3 会变成 14,测试会失败。容量规划里少一台机器不会被平均值发现,只会在峰值时变成排队。

TestPlanAcceptsExactFloor 锁住边界。安全系数 1.2 对应 20% 余量,正好等于下限,不应该告警。这个用例也是浮点数比较的回归测试:一旦有人把 -1e-9 的容差删掉,它会立刻失败。

TestPlanRejectsInvalidInput 覆盖四类坏输入:基线为零、基线为负、峰值系数小于 1、安全系数小于 1。它们都返回 ErrInvalidCapacity,调用方可以用 errors.Is 区分“输入不合法”和“计算过程出错”。

测试没有覆盖压测口径。口径差异不是纯函数能判断的,应该在压测报告模板和评审清单里检查。代码能锁住的是算术和边界,锁不住的是假设是否可信。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/promo/ -run 'TestPlan' -count=1 -v

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

=== RUN   TestPlanScalesPeakAndInstances
--- PASS: TestPlanScalesPeakAndInstances (0.00s)
=== RUN   TestPlanRoundsInstancesUp
--- PASS: TestPlanRoundsInstancesUp (0.00s)
=== RUN   TestPlanWarnsWhenHeadroomBelowFloor
--- PASS: TestPlanWarnsWhenHeadroomBelowFloor (0.00s)
=== RUN   TestPlanAcceptsExactFloor
--- PASS: TestPlanAcceptsExactFloor (0.00s)
=== RUN   TestPlanRejectsInvalidInput
--- PASS: TestPlanRejectsInvalidInput (0.00s)
PASS
ok  	webapp/internal/promo	0.304s

五个用例全部通过,退出码为 0。ok 后面的耗时是一次本机运行的值,换一台机器或换一次运行都会变化,不影响通过与否。

小结

容量规划不是“买多少台机器”,而是把业务峰值、接口 QPS、单机容量和安全系数接成一条可复核的链路。每一段都要写口径,每一段都要允许别人追问“这个数字是怎么来的”。

压测的价值不在于给出一个漂亮的吞吐数字,而在于暴露口径差异。请求体、缓存命中率、数据量、下游延迟,任何一项与线上不同,容量数字都要打折。把差异写进报告,比多买几台机器更能避免开场排队。

下一节讨论限流、降级与预案:即使容量算对了,也总会有超出预测的流量,系统需要知道超限之后该退到哪里。

阅读导航:上一节:目录与学习路径 · 下一节:11.2 限流、降级与预案 。

常见错误与边界

  • 用订单量倍数直接乘单机 QPS。订单量、接口请求量和机器容量是三种口径,中间缺少换算步骤。
  • 压测只报单实例峰值,不报请求体、缓存命中率和数据量。口径不同的数字不能直接用于扩容。
  • 把安全系数当成万能缓冲。它只能吸收预测误差,不能替代限流,也不能吸收无限流量。
  • 实例数四舍五入。容量规划只能向上取整,少一台都会在峰值时变成排队。
  • 只测平均值不看 p99。长尾请求会占住连接,平均值达标不代表用户不卡。
  • 压测流量不打标。污染监控、写脏数据、触发真实优惠,都会让压测结论失真。
  • 报告只写结论不写假设。没有假设的容量数字无法复核,也无法在事后判断是预测错还是执行错。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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