《后端架构设计·高性能与高可用》2.1 从业务指标到 QPS 与并发数

以 arch-lab 订单系统为例,把日订单量、峰值系数与下单事务的请求放大倍数换算成峰值 QPS 和并发数;实现 Little's Law 计算器与 24 小时峰值系数校验,附表驱动测试、本机微基准和一组区分目标值、示例值与实测值的容量结论,并说明 P99 并发为何是安全包络。

容量问题的输入不是“QPS”

“这个系统能扛多少 QPS”通常没法直接回答,因为 QPS 不是业务指标,而是业务量经过时间、峰值和调用链放大之后的派生值。产品说日订单五十万,运维说峰值系数三点五,链路追踪说每单四次请求,这三个数分别来自不同的人、不同的口径,任何一个含糊,最后的容量结论都会偏一个数量级。

站内 SLA、SLO 与容量规划 已经讲过目标与预算的关系,本节不重复 SLO 方法论,只做一件更窄的事:把 arch-lab 的订单业务量换算成峰值 QPS 与并发数,并把这套换算写成可测试的 Go 代码。结论必须先能复算,才配写进容量表。

arch-lab 的业务输入

下表是本节的输入。类型比数值更重要:目标值来自产品计划,示例值是为了演示公式而设定的,实测值必须附命令和样本窗口。把三类数混在一张表里,是容量估算最常见的伪精确来源。

指标数值类型口径与来源
日订单量500,000 单/日目标值产品年度计划,按自然日统计已支付订单
峰值系数3.50示例值24 个小时桶中最大小时量 ÷ 全天均值
每单请求放大4.0示例值下单接口 1 次 + 库存预占 1 次 + 支付创建 1 次 + 结果查询 1 次
创建订单 P99320ms目标值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。前三个是目标值与示例值推导出来的规划量,不是实测吞吐;最后一个同样依赖假设的平均延迟。

结论数值证据等级使用方式
平均请求 QPS23.148目标值推导容量表的基线,不用于扩容
峰值请求 QPS81.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 压测模型、数据与流量回放 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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