《后端架构设计·高性能与高可用》13.1 一次完整的容量规划

把日订单量、峰值系数、单实例容量、目标利用率、故障域冗余和扩容阈值串成一份可审计的容量计划:用 Go 实现保守、基准、激进三种场景的规划器与表驱动测试,为每个输入标注实测、目标或示例等级,并给出 arch-lab 的扩容决策、成本边界与不可发布条件。

容量计划不是一张 QPS 表

压测结果解读与容量余量 已经给出余量公式,本节不再推导它,而是把公式的六个输入串成一份可以审计、可以切换场景、可以回答“为什么是四台而不是三台”的计划。单看峰值 QPS 无法做扩容决策,因为同样的 QPS 在不同单实例容量、目标利用率和故障域冗余下会得到完全不同的部署数。站内 SLA、SLO 与容量规划 讲了目标与预算;本节只解决一个更窄的问题:每个输入从哪里来、证据等级是什么、换成保守或激进口径后结论怎么变、成本边界在哪里。

六个输入,三种证据等级

arch-lab 的输入分成三类:产品给的目标、为了演示公式而设的示例、以及必须跑压测才能得到的实测。下表的三列场景共用日订单量与请求放大倍数,只改峰值系数、单实例容量、目标利用率和故障域冗余。

输入保守基准激进等级
日订单量500,000 单/日同左同左目标
每单请求放大4.0同左同左示例
峰值系数3.83.53.2示例
单实例容量45 req/s50 req/s65 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.9634 / 668.8%88.9%¥720不扩容,成本最高
基准81.0193 / 448.1%90.0%¥480基准方案,保留 N+1
激进74.0742 / 231.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 订单系统必测的三个故障场景 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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