容量计划不是一张 QPS 表
压测结果解读与容量余量 已经给出余量公式,本节不再推导它,而是把公式的六个输入串成一份可以审计、可以切换场景、可以回答“为什么是四台而不是三台”的计划。单看峰值 QPS 无法做扩容决策,因为同样的 QPS 在不同单实例容量、目标利用率和故障域冗余下会得到完全不同的部署数。站内 SLA、SLO 与容量规划 讲了目标与预算;本节只解决一个更窄的问题:每个输入从哪里来、证据等级是什么、换成保守或激进口径后结论怎么变、成本边界在哪里。
六个输入,三种证据等级
arch-lab 的输入分成三类:产品给的目标、为了演示公式而设的示例、以及必须跑压测才能得到的实测。下表的三列场景共用日订单量与请求放大倍数,只改峰值系数、单实例容量、目标利用率和故障域冗余。
| 输入 | 保守 | 基准 | 激进 | 等级 |
|---|---|---|---|---|
| 日订单量 | 500,000 单/日 | 同左 | 同左 | 目标 |
| 每单请求放大 | 4.0 | 同左 | 同左 | 示例 |
| 峰值系数 | 3.8 | 3.5 | 3.2 | 示例 |
| 单实例容量 | 45 req/s | 50 req/s | 65 req/s | 示例 |
| 目标利用率 | 55% | 60% | 75% | 目标 |
| 故障域冗余 | 2 台 | 1 台 | 0 台 | 目标 |
| 单台月成本 | 12,000 分 | 同左 | 同左 | 示例 |
七项输入里 measured 是 0:日订单量是产品计划,峰值系数没有 24 小时分布,单实例容量没有在目标 P99 下压测,成本只是示例报价。把示例值写成实测值,计划会更精确,但不会更可信。
公式链
峰值 QPS = 日订单量 / 86400 × 请求放大 × 峰值系数
单实例安全容量 = 单实例容量 × 目标利用率
所需实例 N = ceil(峰值 QPS / 单实例安全容量)
部署实例 D = N + 故障域冗余 F
容量余量 = D × 单实例安全容量 / 峰值 QPS - 1
故障后利用率 = 峰值 QPS / (N × 单实例安全容量)
月成本 = D × 单台月成本
链条的顺序不能交换:先有业务量才有峰值 QPS,先有目标利用率才有安全容量,先有故障域冗余才有部署数。任何一步用了不同口径的输入,后面的成本结论都不可比。
代码:带证据门的规划器
Input 的 Grade 只有 measured、target、example 三个合法值。measured 必须带来源和可复现命令,target 与 example 必须带 owner 和截止日期;任何缺口都会让 Build 返回 ErrInvalid,而不是带着假输入继续算。
// file: internal/capacityplan/plan.go
// tier: L1
// verify: go test ./internal/capacityplan/ -count=1 -v
package capacityplan
import (
"errors"
"fmt"
"math"
"time"
)
// Grade 标注输入的证据等级;只有 measured 才能支撑发布结论。
type Grade string
const (
Measured Grade = "measured"
Target Grade = "target"
Example Grade = "example"
)
const (
CodeBadGrade = "BAD_GRADE"
CodeMeasuredNoSource = "MEASURED_NO_SOURCE"
CodeMeasuredNoVerify = "MEASURED_NO_VERIFY"
CodePlanNoOwner = "PLAN_NO_OWNER"
CodeBadDue = "BAD_DUE"
CodeOutOfRange = "OUT_OF_RANGE"
CodeNotInteger = "NOT_INTEGER"
)
var ErrInvalid = errors.New("capacity plan has invalid inputs")
type Gap struct{ Input, Code, Detail string }
type Input struct {
Name string
Value float64
Unit string
Grade Grade
Source, Verify, Due string
}
func (in Input) gaps(allowZero bool) []Gap {
var out []Gap
add := func(code, detail string) { out = append(out, Gap{in.Name, code, detail}) }
switch in.Grade {
case Measured:
if in.Source == "" {
add(CodeMeasuredNoSource, "measured input needs a source")
}
if in.Verify == "" {
add(CodeMeasuredNoVerify, "measured input needs a verify command")
}
case Target, Example:
if in.Source == "" {
add(CodePlanNoOwner, "target/example input needs an owner")
}
if _, err := time.Parse("2006-01-02", in.Due); err != nil {
add(CodeBadDue, in.Due)
}
default:
add(CodeBadGrade, string(in.Grade))
return out
}
if math.IsNaN(in.Value) || math.IsInf(in.Value, 0) || in.Value < 0 || (!allowZero && in.Value == 0) {
add(CodeOutOfRange, fmt.Sprintf("value %g", in.Value))
}
return out
}
type Business struct{ DailyOrders, RequestAmplification Input }
type Scenario struct {
Name string
PeakFactor, InstanceCapacity Input
TargetUtilization, FailureRedundancy Input
CostPerInstanceCents Input
}
type Policy struct{ HeadroomFloor, MaxFailureUtilization float64 }
func (b Business) Gaps() []Gap {
return append(b.DailyOrders.gaps(false), b.RequestAmplification.gaps(false)...)
}
func (s Scenario) Gaps() []Gap {
out := append(s.PeakFactor.gaps(false), s.InstanceCapacity.gaps(false)...)
out = append(out, s.TargetUtilization.gaps(false)...)
out = append(out, s.FailureRedundancy.gaps(true)...)
out = append(out, s.CostPerInstanceCents.gaps(true)...)
if s.TargetUtilization.Value >= 1 {
out = append(out, Gap{s.TargetUtilization.Name, CodeOutOfRange, "utilization must be below 1"})
}
if math.Trunc(s.FailureRedundancy.Value) != s.FailureRedundancy.Value {
out = append(out, Gap{s.FailureRedundancy.Name, CodeNotInteger, "redundancy counts whole instances"})
}
return out
}
type Plan struct {
Scenario string
PeakQPS, SafePerInstance, Headroom float64
FailureUtilization float64
Required, Deployed int
MonthlyCostCents int64
ScaleOut bool
Reasons []string
Grades map[Grade]int
}
// Build 把业务量、峰值系数、单实例容量、目标利用率、故障域冗余
// 和成本串成一份可复算的容量计划。
func Build(policy Policy, b Business, sc Scenario) (Plan, error) {
gaps := append(b.Gaps(), sc.Gaps()...)
if len(gaps) > 0 {
return Plan{}, fmt.Errorf("%w: %d gap(s), first=%s/%s", ErrInvalid, len(gaps), gaps[0].Input, gaps[0].Code)
}
peak := b.DailyOrders.Value / 86400 * b.RequestAmplification.Value * sc.PeakFactor.Value
safe := sc.InstanceCapacity.Value * sc.TargetUtilization.Value
required := int(math.Ceil(peak / safe))
deployed := required + int(sc.FailureRedundancy.Value)
p := Plan{
Scenario: sc.Name, PeakQPS: peak, SafePerInstance: safe,
Required: required, Deployed: deployed,
Headroom: float64(deployed)*safe/peak - 1,
FailureUtilization: peak / (float64(required) * safe),
MonthlyCostCents: int64(math.Round(float64(deployed) * sc.CostPerInstanceCents.Value)),
Grades: map[Grade]int{},
}
if p.Headroom < policy.HeadroomFloor {
p.ScaleOut = true
p.Reasons = append(p.Reasons, fmt.Sprintf("headroom %.3f below floor %.3f", p.Headroom, policy.HeadroomFloor))
}
if p.FailureUtilization > policy.MaxFailureUtilization {
p.ScaleOut = true
p.Reasons = append(p.Reasons, fmt.Sprintf("failure utilization %.3f above ceiling %.3f", p.FailureUtilization, policy.MaxFailureUtilization))
}
if int(sc.FailureRedundancy.Value) == 0 {
p.ScaleOut = true
p.Reasons = append(p.Reasons, "no failure redundancy")
}
for _, in := range []Input{b.DailyOrders, b.RequestAmplification, sc.PeakFactor,
sc.InstanceCapacity, sc.TargetUtilization, sc.FailureRedundancy, sc.CostPerInstanceCents} {
p.Grades[in.Grade]++
}
return p, nil
}
// MeasuredShare 返回 measured 输入比例;0 表示计划尚不可发布。
func (p Plan) MeasuredShare() int {
total := 0
for _, n := range p.Grades {
total += n
}
if total == 0 {
return 0
}
return p.Grades[Measured] * 100 / total
}
场景输入
三个场景只改四个数值;保守多留一台冗余,激进把冗余降到零,这不是更乐观的预测,而是拿故障域换成本。
// file: internal/capacityplan/scenarios.go
// tier: L1
// verify: go test ./internal/capacityplan/ -count=1 -v
package capacityplan
func input(name string, v float64, unit string, g Grade, src string) Input {
return Input{Name: name, Value: v, Unit: unit, Grade: g, Source: src, Due: "2026-12-01"}
}
// ArchLabBusiness:日订单量是产品目标,请求放大倍数是示例值。
func ArchLabBusiness() Business {
return Business{
DailyOrders: input("daily_orders", 500000, "order/day", Target, "product plan 2026"),
RequestAmplification: input("request_amplification", 4, "req/order", Example, "arch-lab example"),
}
}
// ArchLabScenarios 的单实例容量全部是示例值,必须用 bench-order 替换成 measured。
func ArchLabScenarios() []Scenario {
return []Scenario{
{"conservative",
input("peak_factor", 3.8, "x", Example, "arch-lab example"),
input("instance_capacity", 45, "req/s", Example, "replace with bench-order"),
input("target_utilization", 0.55, "ratio", Target, "capacity policy"),
input("failure_redundancy", 2, "instances", Target, "N+1 policy"),
input("cost_per_instance", 12000, "cents/month", Example, "vendor quote example")},
{"baseline",
input("peak_factor", 3.5, "x", Example, "arch-lab example"),
input("instance_capacity", 50, "req/s", Example, "replace with bench-order"),
input("target_utilization", 0.60, "ratio", Target, "capacity policy"),
input("failure_redundancy", 1, "instances", Target, "N+1 policy"),
input("cost_per_instance", 12000, "cents/month", Example, "vendor quote example")},
{"aggressive",
input("peak_factor", 3.2, "x", Example, "arch-lab example"),
input("instance_capacity", 65, "req/s", Example, "replace with bench-order"),
input("target_utilization", 0.75, "ratio", Target, "capacity policy"),
input("failure_redundancy", 0, "instances", Target, "no redundancy"),
input("cost_per_instance", 12000, "cents/month", Example, "vendor quote example")},
}
}
var ArchLabPolicy = Policy{HeadroomFloor: 0.20, MaxFailureUtilization: 0.95}
表驱动测试
测试同时断言实例数、成本和扩容标记;TestGapRules 逐条破坏规则,确认非法等级、缺验证命令、缺 owner、缺截止日期、利用率等于 1、冗余非整数都会被拒绝。
// file: internal/capacityplan/plan_test.go
// tier: L1
// verify: go test ./internal/capacityplan/ -count=1 -v
package capacityplan
import (
"errors"
"math"
"testing"
)
func hasGap(gaps []Gap, code string) bool {
for _, g := range gaps {
if g.Code == code {
return true
}
}
return false
}
func TestScenariosAndCost(t *testing.T) {
want := map[string]struct {
peak, headroom, failureUtil float64
required, deployed int
cost int64
scaleOut bool
}{
"conservative": {87.962963, 0.688210, 0.888515, 4, 6, 72000, false},
"baseline": {81.018519, 0.481144, 0.900206, 3, 4, 48000, false},
"aggressive": {74.074074, 0.316250, 0.759734, 2, 2, 24000, true},
}
for _, sc := range ArchLabScenarios() {
p, err := Build(ArchLabPolicy, ArchLabBusiness(), sc)
if err != nil {
t.Fatalf("%s: %v", sc.Name, err)
}
w := want[sc.Name]
if math.Abs(p.PeakQPS-w.peak) > 1e-4 || p.Required != w.required || p.Deployed != w.deployed ||
math.Abs(p.Headroom-w.headroom) > 1e-4 || math.Abs(p.FailureUtilization-w.failureUtil) > 1e-4 ||
p.MonthlyCostCents != w.cost || p.ScaleOut != w.scaleOut || p.MeasuredShare() != 0 {
t.Fatalf("%s: %+v want %+v", sc.Name, p, w)
}
t.Logf("scenario=%s peak_qps=%.3f required=%d deployed=%d headroom=%.3f failure_util=%.3f cost_cents=%d scale_out=%v reasons=%v measured_share=%d%%",
p.Scenario, p.PeakQPS, p.Required, p.Deployed, p.Headroom, p.FailureUtilization,
p.MonthlyCostCents, p.ScaleOut, p.Reasons, p.MeasuredShare())
}
}
func TestGapRules(t *testing.T) {
tests := []struct {
name string
mutate func(*Business, *Scenario)
want string
}{
{"bad grade", func(b *Business, s *Scenario) { s.InstanceCapacity.Grade = "guess" }, CodeBadGrade},
{"measured without verify", func(b *Business, s *Scenario) {
s.InstanceCapacity.Grade, s.InstanceCapacity.Source = Measured, "run 3"
}, CodeMeasuredNoVerify},
{"target without owner", func(b *Business, s *Scenario) { s.PeakFactor.Source = "" }, CodePlanNoOwner},
{"example without due", func(b *Business, s *Scenario) { s.PeakFactor.Due = "2026/12/01" }, CodeBadDue},
{"utilization at one", func(b *Business, s *Scenario) { s.TargetUtilization.Value = 1 }, CodeOutOfRange},
{"fractional redundancy", func(b *Business, s *Scenario) { s.FailureRedundancy.Value = 1.5 }, CodeNotInteger},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b, s := ArchLabBusiness(), ArchLabScenarios()[1]
tt.mutate(&b, &s)
gaps := append(b.Gaps(), s.Gaps()...)
if !hasGap(gaps, tt.want) {
t.Fatalf("gaps=%v want %s", gaps, tt.want)
}
if _, err := Build(ArchLabPolicy, b, s); !errors.Is(err, ErrInvalid) {
t.Fatalf("Build err=%v", err)
}
t.Logf("case=%s first=%s/%s", tt.name, gaps[0].Input, gaps[0].Code)
})
}
}
真实运行结果
以下输出来自本机 macOS(darwin/arm64,Go 1.26.0),命令 go test ./internal/capacityplan/ -count=1 -v,单次运行;原始输出保存在 .local/reports/be-arch/be-arch-perf-13-1-capacity-planning-tests.txt。这是确定性算术,不是性能基准。
=== RUN TestScenariosAndCost
plan_test.go:40: scenario=conservative peak_qps=87.963 required=4 deployed=6 headroom=0.688 failure_util=0.889 cost_cents=72000 scale_out=false reasons=[] measured_share=0%
plan_test.go:40: scenario=baseline peak_qps=81.019 required=3 deployed=4 headroom=0.481 failure_util=0.900 cost_cents=48000 scale_out=false reasons=[] measured_share=0%
plan_test.go:40: scenario=aggressive peak_qps=74.074 required=2 deployed=2 headroom=0.316 failure_util=0.760 cost_cents=24000 scale_out=true reasons=[no failure redundancy] measured_share=0%
--- PASS: TestScenariosAndCost (0.00s)
=== RUN TestGapRules
--- PASS: TestGapRules (0.00s)
PASS
ok archlab/internal/capacityplan 0.560s
arch-lab 的扩容决策与成本边界
| 场景 | 峰值 QPS | 所需/部署 | 余量 | 故障后利用率 | 月成本 | 决策 |
|---|---|---|---|---|---|---|
| 保守 | 87.963 | 4 / 6 | 68.8% | 88.9% | ¥720 | 不扩容,成本最高 |
| 基准 | 81.019 | 3 / 4 | 48.1% | 90.0% | ¥480 | 基准方案,保留 N+1 |
| 激进 | 74.074 | 2 / 2 | 31.6% | 76.0% | ¥240 | 触发扩容:无故障冗余 |
基准场景是唯一没有触发扩容理由的方案:余量 48.1% 高于 20% 下限,故障后利用率 90.0% 低于 95% 上限,并且保留一台冗余。激进场景每月省 ¥240,但失去故障域冗余,ScaleOut=true 的理由正是 no failure redundancy。成本边界是每台每月 12,000 分:基准四台 48,000 分,从基准走到保守多两台、多 24,000 分,换来 60 req/s 安全容量;这个换算只有在单实例容量可靠时才有意义,而当前 50 req/s 是示例值。
什么时候不能发布
不能因为三个场景都算出了数字就宣布容量达标。MeasuredShare() 为 0、单实例容量没有在目标 P99 下开环压测、峰值系数没有 24 小时真实分布、成本没有合同或账单支撑,任一条成立,计划都只能作为模板,结论只能是“基线方案待定”,而不是“四台可以上线”。
常见错误与边界
第一,把示例值写成 measured。等级不是注释,它决定结论能否发布;Build 会拒绝缺来源的 measured,但拦不住有人把假来源填进去。
第二,只做基准场景,或把峰值 QPS 乘实例数当总容量。保守和激进暴露结论对单实例容量和冗余策略的敏感度;实例又共享数据库、缓存和下游配额,容量要按整条链路的最短板计算。第三,把月成本当成唯一目标:激进场景最便宜,但把故障域冗余降为零,成本边界必须和故障后利用率、恢复目标一起看。
小结
一份可审计的容量计划必须同时具备六段链条、三种场景和逐项证据等级。arch-lab 的基准算例是峰值 81.019 QPS、所需 3 台、部署 4 台、余量 48.1%、故障后利用率 90.0%、月成本 48,000 分;保守方案 6 台、余量 68.8%、月成本 72,000 分;激进方案 2 台、余量 31.6%、月成本 24,000 分,但因为没有故障冗余而触发扩容。七项输入的 measured 比例是 0,所以这是一份可复算的模板,不是可以上线的容量承诺。
阅读导航:上一节:12.3 改造前后同一口径的数字与失败注入结果 · 下一节:13.2 订单系统必测的三个故障场景 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。