解读是测量的一部分
压测产生原始数字,容量结论却要从数字再走一步。这一步最容易出两类错:把多实例的分位数直接平均,得到一个不存在于任何实例的“平均 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) ÷ 2 | 505ms |
| 合并后再算 P99 | 2000 个观测中第 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.60 | 30 请求/秒 |
| 所需实例数 | ceil(81.019 ÷ 30) | 3 |
| 实际部署数 | 3 + 1 | 4 |
| 容量余量 | 4 × 30 ÷ 81.019 - 1 | 48.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 本地缓存、分布式缓存与多级缓存 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。