容量问题的输入不是“QPS”
“这个系统能扛多少 QPS”通常没法直接回答,因为 QPS 不是业务指标,而是业务量经过时间、峰值和调用链放大之后的派生值。产品说日订单五十万,运维说峰值系数三点五,链路追踪说每单四次请求,这三个数分别来自不同的人、不同的口径,任何一个含糊,最后的容量结论都会偏一个数量级。
站内 SLA、SLO 与容量规划 已经讲过目标与预算的关系,本节不重复 SLO 方法论,只做一件更窄的事:把 arch-lab 的订单业务量换算成峰值 QPS 与并发数,并把这套换算写成可测试的 Go 代码。结论必须先能复算,才配写进容量表。
arch-lab 的业务输入
下表是本节的输入。类型比数值更重要:目标值来自产品计划,示例值是为了演示公式而设定的,实测值必须附命令和样本窗口。把三类数混在一张表里,是容量估算最常见的伪精确来源。
| 指标 | 数值 | 类型 | 口径与来源 |
|---|---|---|---|
| 日订单量 | 500,000 单/日 | 目标值 | 产品年度计划,按自然日统计已支付订单 |
| 峰值系数 | 3.50 | 示例值 | 24 个小时桶中最大小时量 ÷ 全天均值 |
| 每单请求放大 | 4.0 | 示例值 | 下单接口 1 次 + 库存预占 1 次 + 支付创建 1 次 + 结果查询 1 次 |
| 创建订单 P99 | 320ms | 目标值 | SLO 目标,不是当前观测值 |
| 状态变更微基准 | 2.228ns/op | 实测值 | 本机单次运行,只覆盖进程内版本递增 |
日订单量是唯一可以直接从业务计划拿到的数,其余都要经过测量或假设。峰值系数不能拍脑袋取 3 或 5,它必须由真实小时分布算出来;请求放大倍数也不能把重试、轮询和异步补偿漏掉,否则峰值 QPS 会被系统性低估。
从日订单量到峰值 QPS
换算分三步。先把日订单量摊到 86400 秒,得到平均每秒订单数;再乘以每单请求放大倍数,得到平均请求 QPS;最后乘以峰值系数,得到峰值请求 QPS。
平均订单率 = 500000 / 86400 = 5.787 单/秒
平均 QPS = 5.787 × 4 = 23.148 请求/秒
峰值 QPS = 23.148 × 3.5 = 81.019 请求/秒
这里有一个 Go 特有的坑:如果测试里写 500000 * 4 / 86400,三个操作数都是无类型整数常量,结果会被截断成 23,而不是 23.148。它不会报错,只会让断言以“差 0.148”的方式失败。代码里必须至少有一个浮点字面量,或者先把日订单量声明为 float64。
峰值系数本身也要校验。声明 3.5 而实测小时分布只有 2.0,说明容量表按 3.5 预留了资源,实际峰值却可能出现在另一个时段;反过来,声明 2.0 而实测 3.5,则会在最忙的一小时直接打满。校验逻辑不该只比较两个数,而应该从 24 个小时桶重新计算峰值系数,再用相对误差判断声明值是否可信。
Little’s Law:并发不是线程数
Little’s Law 说稳态下系统中的平均请求数等于到达率乘以平均停留时间:L = λW。放到容量估算里,λ 是峰值 QPS,W 是一次请求在系统内的时间,L 就是需要的并发处理能力。它的前提是稳态,所以只能用于“持续峰值”这一段,不能拿单个尖峰瞬间去套。
用哪个 W 决定了 L 的含义。用 P99 得到的是尾延迟安全包络:25.93 个并发请求,意味着在最忙时段、按 SLO 允许的最慢请求计算,系统仍要能同时容纳约 26 个请求。用平均延迟 80ms 得到的是 6.48,这是连接池和 worker 数的下限,不是容量上限。两个数都不能单独使用:只看平均值会在长尾时排队,只看 P99 会把资源按最坏情况永久预留。
还要注意放大倍数的作用位置。λ 已经是放大后的总请求 QPS,如果再按“每单 4 次”乘一遍,就会把下游调用算两次。正确做法是先决定估算对象:如果算的是入口层并发,用入口 QPS 和入口延迟;如果算的是库存服务并发,用库存服务的调用 QPS 和它自己的延迟。每一层单独算,不能把整条链路的放大倍数重复叠加。
代码:换算、校验与微基准
下面的包把三个公式和峰值系数校验放在一起。Validate 拒绝小于 1 的峰值系数和放大倍数,因为那意味着输入口径已经错了,继续算只会得到更精确的错误答案。
// file: internal/capacity/capacity.go
// tier: L1
// verify: go test ./internal/capacity/ -count=1 -v
package capacity
import (
"errors"
"fmt"
"math"
"time"
)
var (
ErrDailyOrders = errors.New("daily orders must be positive")
ErrPeakFactor = errors.New("peak factor must be at least 1")
ErrAmplification = errors.New("request amplification must be at least 1")
ErrLatency = errors.New("p99 latency must be positive")
ErrNoTraffic = errors.New("hourly window has no traffic")
ErrNegative = errors.New("hourly counts must be non-negative")
)
// BusinessMetrics 的 DailyOrders 是业务目标,PeakFactor 与 Amplification
// 必须来自实测,P99 是 SLO 目标而不是当前观测值。
type BusinessMetrics struct {
DailyOrders float64
PeakFactor float64
Amplification float64
P99 time.Duration
}
func (m BusinessMetrics) Validate() error {
switch {
case m.DailyOrders <= 0:
return ErrDailyOrders
case m.PeakFactor < 1:
return ErrPeakFactor
case m.Amplification < 1:
return ErrAmplification
case m.P99 <= 0:
return ErrLatency
}
return nil
}
func (m BusinessMetrics) AverageQPS() float64 {
return m.DailyOrders * m.Amplification / 86400
}
func (m BusinessMetrics) PeakQPS() float64 {
return m.AverageQPS() * m.PeakFactor
}
// Concurrency 是 Little's Law 的 L = λW;用 P99 得到尾延迟安全包络。
func (m BusinessMetrics) Concurrency() float64 {
return m.PeakQPS() * m.P99.Seconds()
}
// MeanConcurrency 用平均延迟估计稳态并发,用于连接池和线程数下限。
func (m BusinessMetrics) MeanConcurrency(mean time.Duration) float64 {
return m.PeakQPS() * mean.Seconds()
}
// HourlyCounts 是 24 个小时桶的原始计数,索引 0 表示 00:00-00:59。
type HourlyCounts [24]float64
// PeakFactorFromHourly 用实测小时分布计算:最忙一小时 ÷ 全天平均每小时。
func PeakFactorFromHourly(h HourlyCounts) (float64, error) {
total, peak := 0.0, 0.0
for _, v := range h {
if v < 0 {
return 0, ErrNegative
}
total += v
if v > peak {
peak = v
}
}
if total == 0 {
return 0, ErrNoTraffic
}
return peak / (total / 24), nil
}
// CheckPeakFactor 用相对误差校验声明值是否被实测分布支持。
func CheckPeakFactor(declared float64, h HourlyCounts, tolerance float64) error {
if declared < 1 {
return ErrPeakFactor
}
measured, err := PeakFactorFromHourly(h)
if err != nil {
return err
}
if diff := math.Abs(declared-measured) / measured; diff > tolerance {
return fmt.Errorf("declared %.2f vs measured %.2f: %.1f%% off", declared, measured, diff*100)
}
return nil
}
// Order 与 Apply 只用于微基准,覆盖状态变更的进程内开销。
type Order struct {
ID string
Status string
Version int
}
var ErrInvalidState = errors.New("invalid state transition")
func Apply(order *Order, next string) error {
if order.Status == next {
return ErrInvalidState
}
order.Version++
order.Status = next
return nil
}
Apply 不是完整的状态机,它只把“版本递增 + 状态写入”这两步留在进程内,用来区分 CPU 段开销和数据库、网络段开销。微基准的纳秒数不能当成端到端延迟,也不能反推 QPS;它的作用只是提醒读者,320ms 的 P99 里几乎没有多少时间花在这段代码上。
表驱动测试固定口径
测试覆盖三类断言:公式结果、非法输入、峰值系数校验。表驱动的好处不是少写几行,而是每个用例都显式写出期望值,避免实现和断言一起算错。
// file: internal/capacity/capacity_test.go
// tier: L1
// verify: go test ./internal/capacity/ -count=1 -v
package capacity
import (
"errors"
"math"
"testing"
"time"
)
func TestBusinessMetrics(t *testing.T) {
tests := []struct {
name string
metrics BusinessMetrics
avg, peak float64
concurrency float64
}{
{
name: "500k orders per day",
metrics: BusinessMetrics{DailyOrders: 500000, PeakFactor: 3.5, Amplification: 4, P99: 320 * time.Millisecond},
avg: 500000.0 * 4 / 86400,
peak: 500000.0 * 4 / 86400 * 3.5,
concurrency: 500000.0 * 4 / 86400 * 3.5 * 0.32,
},
{
name: "flat day",
metrics: BusinessMetrics{DailyOrders: 86400, PeakFactor: 1, Amplification: 1, P99: time.Second},
avg: 1, peak: 1, concurrency: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.metrics.Validate(); err != nil {
t.Fatal(err)
}
if got := tt.metrics.AverageQPS(); math.Abs(got-tt.avg) > 1e-9 {
t.Fatalf("AverageQPS=%f want %f", got, tt.avg)
}
if got := tt.metrics.PeakQPS(); math.Abs(got-tt.peak) > 1e-9 {
t.Fatalf("PeakQPS=%f want %f", got, tt.peak)
}
if got := tt.metrics.Concurrency(); math.Abs(got-tt.concurrency) > 1e-9 {
t.Fatalf("Concurrency=%f want %f", got, tt.concurrency)
}
})
}
}
func TestValidate(t *testing.T) {
valid := BusinessMetrics{DailyOrders: 1, PeakFactor: 1, Amplification: 1, P99: time.Millisecond}
tests := []struct {
name string
mutate func(*BusinessMetrics)
want error
}{
{"zero orders", func(m *BusinessMetrics) { m.DailyOrders = 0 }, ErrDailyOrders},
{"peak below one", func(m *BusinessMetrics) { m.PeakFactor = 0.9 }, ErrPeakFactor},
{"amplification below one", func(m *BusinessMetrics) { m.Amplification = 0 }, ErrAmplification},
{"zero latency", func(m *BusinessMetrics) { m.P99 = 0 }, ErrLatency},
{"valid", func(m *BusinessMetrics) {}, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := valid
tt.mutate(&m)
if err := m.Validate(); !errors.Is(err, tt.want) {
t.Fatalf("Validate()=%v want %v", err, tt.want)
}
})
}
}
func TestPeakFactor(t *testing.T) {
var h HourlyCounts
for i := range h {
h[i] = 178
}
h[20] = 700
factor, err := PeakFactorFromHourly(h)
if err != nil || math.Abs(factor-3.5) > 0.01 {
t.Fatalf("factor=%f err=%v want 3.50", factor, err)
}
if err := CheckPeakFactor(3.5, h, 0.05); err != nil {
t.Fatalf("declared 3.5 should pass: %v", err)
}
if err := CheckPeakFactor(2.0, h, 0.05); err == nil {
t.Fatal("declared 2.0 should fail")
}
var empty HourlyCounts
if _, err := PeakFactorFromHourly(empty); !errors.Is(err, ErrNoTraffic) {
t.Fatalf("empty=%v", err)
}
empty[0] = -1
if _, err := PeakFactorFromHourly(empty); !errors.Is(err, ErrNegative) {
t.Fatalf("negative=%v", err)
}
}
func BenchmarkApply(b *testing.B) {
order := &Order{ID: "o-1", Status: "CREATED"}
for i := 0; i < b.N; i++ {
order.Status = "CREATED"
if err := Apply(order, "INVENTORY_RESERVED"); err != nil {
b.Fatal(err)
}
}
}
func TestExampleReport(t *testing.T) {
m := BusinessMetrics{DailyOrders: 500000, PeakFactor: 3.5, Amplification: 4, P99: 320 * time.Millisecond}
t.Logf("daily_orders=%.0f type=target peak_factor=%.2f type=example amplification=%.1f type=example p99=%s type=target",
m.DailyOrders, m.PeakFactor, m.Amplification, m.P99)
t.Logf("average_qps=%.3f peak_qps=%.3f concurrency_p99=%.2f concurrency_mean_80ms=%.2f",
m.AverageQPS(), m.PeakQPS(), m.Concurrency(), m.MeanConcurrency(80*time.Millisecond))
}
真实运行结果
以下输出来自本机 macOS(darwin/arm64,Go 1.26.0),代码抽取到独立 module 后执行,原始输出保存在 .local/reports/be-arch/be-arch-perf-02-1-business-metrics-to-qps-run.txt:
$ go test ./... -count=1 -v
--- PASS: TestBusinessMetrics (0.00s)
--- PASS: TestValidate (0.00s)
--- PASS: TestPeakFactor (0.00s)
--- PASS: TestExampleReport (0.00s)
capacity_test.go:110: daily_orders=500000 type=target peak_factor=3.50 type=example amplification=4.0 type=example p99=320ms type=target
capacity_test.go:112: average_qps=23.148 peak_qps=81.019 concurrency_p99=25.93 concurrency_mean_80ms=6.48
PASS
ok archlab/internal/capacity 0.238s
$ go test ./internal/capacity -run '^$' -bench BenchmarkApply -benchtime=1s -count=1
BenchmarkApply-10 519307324 2.228 ns/op
PASS
ok archlab/internal/capacity 1.642s
gofmt -l . 没有输出,go vet ./... 没有告警。微基准是 BenchmarkApply-10(GOMAXPROCS=10)的单次运行,只能写“本机单次运行”,不能包装成稳定吞吐;它也不是端到端压测,不含数据库写入、库存 RPC 和支付 RPC。容量表里真正要用的,是下面这组派生值,而不是纳秒数。
容量结论:哪些数字能写进容量表
按上面的输入,arch-lab 的容量结论是:平均请求 QPS 约 23.15,峰值约 81.02,按 P99 320ms 计算的安全并发约 25.93,按平均 80ms 计算的稳态并发约 6.48。前三个是目标值与示例值推导出来的规划量,不是实测吞吐;最后一个同样依赖假设的平均延迟。
| 结论 | 数值 | 证据等级 | 使用方式 |
|---|---|---|---|
| 平均请求 QPS | 23.148 | 目标值推导 | 容量表的基线,不用于扩容 |
| 峰值请求 QPS | 81.019 | 目标值 + 示例峰值系数 | 预留实例和连接池的输入 |
| P99 安全并发 | 25.93 | 目标值推导 | 线程池、队列和限流上限的安全包络 |
| 平均延迟稳态并发 | 6.48 | 依赖假设的平均延迟 | 连接池下限,不能当容量上限 |
| 状态变更 CPU 开销 | 2.228ns/op | 本机实测 | 只说明 CPU 段占比极小 |
这张表最重要的列是“证据等级”。如果峰值系数改成实测值、放大倍数改成链路追踪统计值,前两行仍然是“目标值 + 实测系数”的推导量;只有压测给出的吞吐和 P99,才能进入“实测”一栏。2.2 会补上这部分证据。
常见错误与边界
第一,用整数常量做除法。Go 的 500000 * 4 / 86400 得到 23,不是 23.148;所有容量公式里至少保留一个浮点操作数,并在测试里写死期望值。
第二,拿日订单量直接当 QPS。日订单量除以 86400 只是平均订单率,还要乘请求放大和峰值系数;漏掉任何一个,峰值都会被低估数倍。
第三,把 P99 并发当成平均并发。Little’s Law 的 L 依赖 W 的口径,P99 得到的是安全包络,均值得到的是稳态下限;两个数回答的是不同问题。
第四,从单日最大值取峰值系数。单日最大值可能只出现一次,且受促销影响;应该用多个自然日的 24 小时桶,取稳定可复现的高峰窗口,并记录样本天数。
小结
容量估算的起点不是 QPS,而是三个可验证输入:日订单量、实测峰值系数、实测请求放大倍数。把它们写成 BusinessMetrics,用表驱动测试固定公式和非法输入,再用 PeakFactorFromHourly 校验声明值,才能让 81.019 这个数字有出处。P99 并发 25.93 是安全包络,平均延迟并发 6.48 是稳态下限;两者都不能替代 2.2 的真实压测。
阅读导航:上一节:1.3 性能预算:把端到端延迟拆到每个调用 · 下一节:2.2 压测模型、数据与流量回放 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。