开场前一周,容量数字还在打架
「回声」是一档限时抢购产品,大促开场时间是周五晚上八点。活动前一周,三个团队给出了三个不同的容量结论:业务说峰值是日常的五倍,运维说机器还能再加二十台,压测报告说单实例可以扛八百 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。长尾请求会占住连接,平均值达标不代表用户不卡。
- 压测流量不打标。污染监控、写脏数据、触发真实优惠,都会让压测结论失真。
- 报告只写结论不写假设。没有假设的容量数字无法复核,也无法在事后判断是预测错还是执行错。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。