《后端架构设计·高性能与高可用》2.3 压测结果解读与容量余量

说明压测结果为什么不能把各实例 P99 取平均:用桶计数合并的 Go 实现与测试证明平均 P99 会低估长尾;再给出基于目标利用率、N+1 故障域与扩容阈值的容量余量公式和 arch-lab 订单系统算例,并列出只能写「不可判定」的证据条件。

解读是测量的一部分

压测产生原始数字,容量结论却要从数字再走一步。这一步最容易出两类错:把多实例的分位数直接平均,得到一个不存在于任何实例的“平均 P99”;以及只看单实例吞吐,忘了故障域和扩容阈值,把“当前能扛”当成“故障时也能扛”。

站内 SLA、SLO 与容量规划 已经讲过目标与预算的关系,本节不重复 SLO 定义,只处理压测结果到容量结论之间的两件事:跨实例分位数如何正确合并,以及容量余量如何按目标利用率、N+1 故障域和扩容阈值计算。

跨实例分位数不能取平均

假设两个实例各处理 1000 个请求,桶上界为 10ms、100ms、1s。实例 A 有 999 个请求在 1ms 内、1 个请求落在 1s 桶,P99 排名是第 990 个,落在 10ms 桶;实例 B 有 1 个请求在 1ms 内、999 个落在 1s 桶,P99 同样排第 990 个,落在 1s 桶。

统计方式计算结果
实例 A P99第 990 个观测在 10ms 桶10ms
实例 B P99第 990 个观测在 1s 桶1s
各实例 P99 平均(10ms + 1s) ÷ 2505ms
合并后再算 P992000 个观测中第 1980 个在 1s 桶1s

平均 P99 505ms 比真实合并 P99 1s 低了一半。原因不是算术错误,而是分位数不是可加量:P99 描述的是“排名第 99% 的那个观测”,两个实例的排名不能相加后再平均。正确做法是把桶计数逐桶相加,得到合并后的分布,再在合并分布上取分位数。

如果实例桶边界不同,连逐桶相加都不成立。10ms、100ms、1s 的桶与 10ms、500ms、2s 的桶不能直接合并;必须先统一桶边界重新聚合,或者回到原始观测。线上通常由 Prometheus 的 histogram_quantile 在服务端做合并,前提是所有实例暴露同一组 le 标签。

代码:桶计数合并

下面的实现把观测放进有序桶,合并时只加计数,分位数在合并后的累计计数上取最近秩。Overflow 单独计数:当排名落在最后一个桶之外时,返回 ok=false,而不是编造一个有限值。

// file: internal/capacity/histogram.go
// tier: L1
// verify: go test ./internal/capacity/ -count=1 -v
package capacity

import (
	"errors"
	"math"
	"sort"
	"time"
)

var (
	ErrNoBuckets      = errors.New("histogram needs at least one bucket")
	ErrBounds         = errors.New("bounds must be positive and strictly increasing")
	ErrBucketMismatch = errors.New("histograms have different bucket layouts")
	ErrEmpty          = errors.New("histogram has no observations")
	ErrQuantileRange  = errors.New("quantile must be within [0,1]")
	ErrOverflow       = errors.New("quantile falls in the overflow bucket")
)

// Histogram 是有序桶计数;Bounds 是闭区间上界,超出者进入 Overflow。
type Histogram struct {
	Bounds   []time.Duration
	Counts   []uint64
	Overflow uint64
}

func NewHistogram(bounds []time.Duration) (*Histogram, error) {
	if len(bounds) == 0 {
		return nil, ErrNoBuckets
	}
	for i, b := range bounds {
		if b <= 0 || (i > 0 && b <= bounds[i-1]) {
			return nil, ErrBounds
		}
	}
	return &Histogram{Bounds: append([]time.Duration(nil), bounds...), Counts: make([]uint64, len(bounds))}, nil
}

func (h *Histogram) Observe(d time.Duration) {
	idx := sort.Search(len(h.Bounds), func(i int) bool { return d <= h.Bounds[i] })
	if idx == len(h.Bounds) {
		h.Overflow++
		return
	}
	h.Counts[idx]++
}

func (h Histogram) Total() uint64 {
	total := h.Overflow
	for _, c := range h.Counts {
		total += c
	}
	return total
}

// Merge 先逐桶相加计数,再算分位数;不是把各实例分位数取平均。
func (h Histogram) Merge(others ...Histogram) (Histogram, error) {
	out := Histogram{Bounds: append([]time.Duration(nil), h.Bounds...),
		Counts: append([]uint64(nil), h.Counts...), Overflow: h.Overflow}
	for _, other := range others {
		if len(other.Bounds) != len(out.Bounds) {
			return Histogram{}, ErrBucketMismatch
		}
		for i := range out.Bounds {
			if other.Bounds[i] != out.Bounds[i] {
				return Histogram{}, ErrBucketMismatch
			}
		}
		for i, c := range other.Counts {
			out.Counts[i] += c
		}
		out.Overflow += other.Overflow
	}
	return out, nil
}

// Quantile 返回最近秩所在桶的上界;ok=false 表示排名落在 Overflow。
func (h Histogram) Quantile(q float64) (time.Duration, bool, error) {
	if q < 0 || q > 1 {
		return 0, false, ErrQuantileRange
	}
	total := h.Total()
	if total == 0 {
		return 0, false, ErrEmpty
	}
	rank := uint64(math.Ceil(q * float64(total)))
	rank = max(1, rank)
	var cumulative uint64
	for i, c := range h.Counts {
		cumulative += c
		if cumulative >= rank {
			return h.Bounds[i], true, nil
		}
	}
	return 0, false, nil
}

// AverageQuantile 保留“各实例分位数取平均”的错误做法,仅用于对照测试。
func AverageQuantile(hs []Histogram, q float64) (time.Duration, error) {
	if len(hs) == 0 {
		return 0, ErrEmpty
	}
	var sum time.Duration
	for _, h := range hs {
		v, ok, err := h.Quantile(q)
		if err != nil {
			return 0, err
		}
		if !ok {
			return 0, ErrOverflow
		}
		sum += v
	}
	return sum / time.Duration(len(hs)), nil
}

分位数返回的是桶上界,不是插值后的精确值。这是有意的保守选择:桶上界只会高估、不会低估尾延迟;如果桶设计得过粗,比如只用 100ms、500ms、1s,P99 的误差也会很大,所以桶边界要按 SLO 的量级设计,至少覆盖 P50、P95、P99 和超时线。

测试:证明平均 P99 是错的

测试构造上面那组分布:A 是 999 个 1ms 加 1 个 1s,B 是 1 个 1ms 加 999 个 1s。断言平均 P99 为 505ms、合并 P99 为 1s,并且合并值严格大于平均值。第二个用例验证桶边界不同会拒绝合并。

// file: internal/capacity/histogram_test.go
// tier: L1
// verify: go test ./internal/capacity/ -count=1 -v
package capacity

import (
	"errors"
	"testing"
	"time"
)

func mustHist(t *testing.T, bounds ...time.Duration) *Histogram {
	t.Helper()
	h, err := NewHistogram(bounds)
	if err != nil {
		t.Fatal(err)
	}
	return h
}

func TestHistogramQuantile(t *testing.T) {
	h := mustHist(t, 10*time.Millisecond, 100*time.Millisecond, time.Second)
	for i := 0; i < 99; i++ {
		h.Observe(5 * time.Millisecond)
	}
	h.Observe(2 * time.Second)
	for _, tt := range []struct {
		q    float64
		want time.Duration
		ok   bool
	}{
		{0.50, 10 * time.Millisecond, true},
		{0.99, 10 * time.Millisecond, true},
		{1.00, 0, false},
	} {
		got, ok, err := h.Quantile(tt.q)
		if err != nil || got != tt.want || ok != tt.ok {
			t.Fatalf("P%.2f=(%s,%v,%v) want (%s,%v)", tt.q, got, ok, err, tt.want, tt.ok)
		}
	}
	if _, _, err := h.Quantile(1.1); !errors.Is(err, ErrQuantileRange) {
		t.Fatalf("range err=%v", err)
	}
}

func TestMergedP99IsNotAverageOfP99(t *testing.T) {
	a := mustHist(t, 10*time.Millisecond, 100*time.Millisecond, time.Second)
	b := mustHist(t, 10*time.Millisecond, 100*time.Millisecond, time.Second)
	for i := 0; i < 999; i++ {
		a.Observe(time.Millisecond)
	}
	a.Observe(time.Second)
	b.Observe(time.Millisecond)
	for i := 0; i < 999; i++ {
		b.Observe(time.Second)
	}
	avg, err := AverageQuantile([]Histogram{*a, *b}, 0.99)
	if err != nil {
		t.Fatal(err)
	}
	merged, err := a.Merge(*b)
	if err != nil {
		t.Fatal(err)
	}
	p99, ok, err := merged.Quantile(0.99)
	if err != nil || !ok {
		t.Fatalf("merged quantile ok=%v err=%v", ok, err)
	}
	if avg != 505*time.Millisecond || p99 != time.Second || p99 <= avg {
		t.Fatalf("avg=%s merged=%s", avg, p99)
	}
	t.Logf("instance_a_p99=10ms instance_b_p99=1s average_of_p99=%s merged_p99=%s total=%d",
		avg, p99, merged.Total())
}

func TestMergeRejectsDifferentBuckets(t *testing.T) {
	a := mustHist(t, time.Millisecond, time.Second)
	b := mustHist(t, time.Millisecond, 2*time.Second)
	if _, err := a.Merge(*b); !errors.Is(err, ErrBucketMismatch) {
		t.Fatalf("err=%v", err)
	}
}

真实运行结果

以下输出来自本机 macOS(darwin/arm64,Go 1.26.0),go test ./internal/capacity/ -count=1 -v 单次运行,原始输出保存在 .local/reports/be-arch/be-arch-perf-02-3-result-capacity-headroom-run.txt:

=== RUN   TestHistogramQuantile
--- PASS: TestHistogramQuantile (0.00s)
=== RUN   TestMergedP99IsNotAverageOfP99
    histogram_test.go:69: instance_a_p99=10ms instance_b_p99=1s average_of_p99=505ms merged_p99=1s total=2000
--- PASS: TestMergedP99IsNotAverageOfP99 (0.00s)
=== RUN   TestMergeRejectsDifferentBuckets
--- PASS: TestMergeRejectsDifferentBuckets (0.00s)
PASS
ok  	archlab/internal/capacity	0.508s

gofmt -l . 没有输出,go vet ./... 没有告警。这个结果是确定性的,不是性能基准:它证明的是分位数合并的算术,不依赖机器速度。真正随机器变化的,是下一节的单实例容量输入。

容量余量公式

容量余量不是“峰值 QPS 除以单实例 QPS”,而是先按目标利用率留出安全边界,再按故障域加冗余。记单实例实测容量为 C(在目标 P99 下测得),目标利用率为 U,峰值 QPS 为 Q,故障域冗余为 F(N+1 时 F=1):

单实例安全容量 C_safe = C × U
所需实例数 N = ceil(Q / C_safe)
实际部署数 D = N + F
容量余量 H = D × C_safe / Q - 1
故障后利用率 U_fail = Q / (N × C_safe)

扩容阈值不能只看当前利用率,还要看故障后的余量。一个可执行的规则是:连续 3 个 5 分钟窗口利用率 ≥60%,或未来 30 天预测峰值 > D × C_safe × 0.8,或失去一个实例后 U_fail > 90%,任一条件成立就扩容。阈值写进监控,而不是留在容量评审的 PPT 里。

arch-lab 的算例沿用 2.1 的峰值 QPS 81.019。单实例 50 请求/秒是示例值,必须在目标 P99 下用开环压测实测;目标利用率 60%,N+1 故障域:

步骤公式结果
单实例安全容量50 × 0.6030 请求/秒
所需实例数ceil(81.019 ÷ 30)3
实际部署数3 + 14
容量余量4 × 30 ÷ 81.019 - 148.1%
故障后利用率81.019 ÷ (3 × 30)90.0%

这个算例说明两件事。第一,48.1% 的余量不是浪费:失去一个实例后利用率升到 90%,已经接近上限,故障域冗余买的就是这段空间。第二,单实例容量 50 是示例值;如果实测只有 40,所需实例变成 ceil(81.019 ÷ 24)=4,部署 5 个,余量仍是 48.1%,但成本增加一台。容量结论对 C 极其敏感,所以 C 必须来自同口径压测,不能来自 CPU 核数或厂商参数。

什么时候只能写“不可判定”

压测结果不是每次都能支撑容量结论。下面任一条件成立,结论就只能写“不可判定”,并附上缺失的证据,而不是硬给一个数字。

条件判据只能怎么写
到达率不达标实际速率 < 目标速率 95%压测器未施加目标负载,不能判定容量
超时率过高超时请求 > 1%P99 被截断,只能写“下界”
桶边界不一致实例直方图 le 标签不同不能合并,分位数不可比
P99 落在溢出桶Quantile 返回 ok=false只能写“大于最后一个桶上界”
未进入稳态预热不足或窗口 <5 分钟延迟仍在变化,不能代表稳态
只有闭环数据模型为固定 worker存在协调遗漏,不能当容量证据
版本或配置混合实例间版本、池大小不同分布不可比,先统一再测

其中“超时率过高”最容易被忽略。超时请求在客户端被截断在超时值,真实排队可能更长;把它们删掉会让 P99 变好看,保留又会让 P99 停在超时线上。正确写法是同时给出超时率、超时阈值和“P99 为下界”的限定。

常见错误与边界

第一,把各实例 P99 平均。分位数不可加,平均后的数不对应任何一个真实分布;必须先合并桶计数再取分位数。

第二,用单实例峰值乘实例数当总容量。实例之间共享数据库、缓存和下游服务,单实例线性外推会忽略共同瓶颈;容量要按整条链路的最短板计算。

第三,只按当前利用率扩容。当前 50% 看起来安全,但失去一个实例后可能到 90%;扩容阈值必须包含故障后利用率。

第四,把示例单实例容量写进生产容量表。50 请求/秒只是算例输入,必须用 2.2 的开环压测在目标 P99 下实测,并注明机器、数据规模、运行次数和超时率。

小结

压测结果解读有两个硬约束:跨实例分位数必须先合并桶计数,再取分位数,平均 P99 会低估长尾;容量余量必须同时包含目标利用率、N+1 故障域和扩容阈值。arch-lab 的算例给出 3 台满足峰值、部署 4 台、余量 48.1%、故障后利用率 90.0%,但单实例 50 请求/秒仍是示例值。到达率不足、超时率过高、桶不一致、落在溢出桶、未进入稳态或只有闭环数据时,结论只能写“不可判定”。

阅读导航:上一节:2.2 压测模型、数据与流量回放 · 下一节:3.1 本地缓存、分布式缓存与多级缓存 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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