吞吐和延迟回答的不是同一个问题
吞吐是单位时间内完成的请求数,延迟是单个请求从进入到返回的时间。两者会互相挤压:把并发从 16 提到 256,吞吐可能上升,但排队变长,P99 往往先恶化。卷一的交接清单把“峰值 QPS”和“创建订单 P99”列为两个独立未知项,就是因为它们需要分别测量、分别设目标。
站内 性能与可扩展性 已经讨论过纵向扩展、横向扩展和容量曲线,SLO 与容量规划 讲过错误预算。本节不重复这些方法论,只回答一个更基础的问题:arch-lab 的一串请求,应该怎样记录成可以比较的数字。
先明确测量口径。开环压测按固定到达率发请求,系统变慢不会自动减少压力;闭环压测等上一个响应回来再发下一个,慢请求会阻塞后续请求。两者测出的“延迟”含义不同。本节的本机测量是闭环、单并发,测的是服务端处理时间,不是生产容量结论。
平均值会掩盖尾部
假设 1000 个订单请求里 990 个耗时 3ms,9 个耗时 25ms,1 个耗时 120ms。平均值约 3.4ms,看起来很好;但那 1 个 120ms 的请求可能触发客户端超时、用户重试,甚至让一个同步调用链整体失败。平均值把 120ms 摊薄到 0.12ms,正好抹掉了最需要处理的部分。
尾延迟不是一个可以“平均掉”的指标。它需要按分位数记录:P50 描述典型体验,P99 描述慢请求,P999 描述更罕见的抖动,最大值描述最坏观测。四者回答不同问题,不能互相替代。
固定桶直方图
生产上常用的做法是固定桶直方图:预先定义一组上界,每个观测值落进第一个大于等于它的桶。查询分位数时按计数累加,返回目标桶的上界。这样内存固定、写入是 O(log B),并且可以安全地跨时间窗口合并。代价是精度受桶宽限制,返回值是上界而不是精确值。
下面的实现把直方图、成功/失败/超时三本账和扇出算式放在同一个包里。Record 不是并发安全的,Outcomes 用一把锁把三类记录串行化;这把锁只在请求结束后写一次,不参与业务路径。
// file: internal/platform/metrics/latency.go
// tier: L1
// verify: go test ./internal/platform/metrics/ -count=1
package metrics
import (
"math"
"sort"
"sync"
"time"
)
type Snapshot struct {
Count uint64
P50, P99, P999, Max, Mean time.Duration
}
// Histogram uses fixed upper bounds. Quantiles return a bucket bound, so
// they never understate the observed latency. It is not safe for concurrent
// use; Outcomes serializes access.
type Histogram struct {
bounds []time.Duration
counts []uint64
total uint64
sum time.Duration
max time.Duration
}
func NewHistogram(bounds []time.Duration) *Histogram {
b := append([]time.Duration(nil), bounds...)
sort.Slice(b, func(i, j int) bool { return b[i] < b[j] })
return &Histogram{bounds: b, counts: make([]uint64, len(b)+1)}
}
func DefaultBounds() []time.Duration {
return []time.Duration{
time.Millisecond, 3 * time.Millisecond, 5 * time.Millisecond,
10 * time.Millisecond, 20 * time.Millisecond, 30 * time.Millisecond,
50 * time.Millisecond, 100 * time.Millisecond, 150 * time.Millisecond,
300 * time.Millisecond, 500 * time.Millisecond, time.Second, 5 * time.Second,
}
}
func (h *Histogram) Record(d time.Duration) {
if d < 0 {
d = 0
}
i := sort.Search(len(h.bounds), func(i int) bool { return d <= h.bounds[i] })
h.counts[i]++
h.total++
h.sum += d
if d > h.max {
h.max = d
}
}
func (h *Histogram) Quantile(q float64) time.Duration {
if h.total == 0 {
return 0
}
if q >= 1 {
return h.max
}
target := uint64(math.Ceil(q * float64(h.total)))
if target == 0 {
target = 1
}
var seen uint64
for i, c := range h.counts {
seen += c
if seen >= target {
if i < len(h.bounds) {
return h.bounds[i]
}
return h.max
}
}
return h.max
}
func (h *Histogram) Snapshot() Snapshot {
s := Snapshot{Count: h.total, P50: h.Quantile(0.50), P99: h.Quantile(0.99), P999: h.Quantile(0.999), Max: h.max}
if h.total > 0 {
s.Mean = h.sum / time.Duration(h.total)
}
return s
}
type Outcome string
const (
Success Outcome = "success"
Failure Outcome = "failure"
Timeout Outcome = "timeout"
)
type Outcomes struct {
mu sync.Mutex
bounds []time.Duration
by map[Outcome]*Histogram
}
func NewOutcomes(bounds []time.Duration) *Outcomes {
return &Outcomes{bounds: append([]time.Duration(nil), bounds...), by: make(map[Outcome]*Histogram, 3)}
}
func (o *Outcomes) Record(outcome Outcome, d time.Duration) {
o.mu.Lock()
defer o.mu.Unlock()
h := o.by[outcome]
if h == nil {
h = NewHistogram(o.bounds)
o.by[outcome] = h
}
h.Record(d)
}
func (o *Outcomes) Snapshot() map[Outcome]Snapshot {
o.mu.Lock()
defer o.mu.Unlock()
out := make(map[Outcome]Snapshot, len(o.by))
for k, h := range o.by {
out[k] = h.Snapshot()
}
return out
}
// FanoutTail is P(at least one of n independent calls exceeds its SLO)
// when each call has failure probability p.
func FanoutTail(p float64, n int) float64 {
if n <= 0 || p <= 0 {
return 0
}
if p >= 1 {
return 1
}
return 1 - math.Pow(1-p, float64(n))
}
// FanoutPercentile is the fan-out percentile rank of a per-call threshold:
// r=0.99 with n=10 ranks only P90.4 at the fan-out level.
func FanoutPercentile(perCall float64, n int) float64 {
if n <= 0 || perCall <= 0 {
return 0
}
if perCall >= 1 {
return 1
}
return math.Pow(perCall, float64(n))
}
Quantile 返回桶上界,因此不会低报延迟;但它也不能区分同一个桶里的 21ms 和 29ms。桶的密度要围绕 SLO 设置:如果目标是 300ms,却在 100ms 和 500ms 之间没有桶,P99 只能报 500ms,排查时会误以为所有慢请求都超过 500ms。
扇出把尾部放大
一个有 10 个下游依赖的订单详情页,每个下游各自满足 P99=100ms,用户看到的页面延迟并不是 P99=100ms。只要 10 个调用相互独立,至少一个超过 100ms 的概率是:
P(至少一个超时) = 1 - (1 - p)^10
其中 p 是单个下游超过阈值的概率。每个下游的超阈值概率为 1% 时,页面层是 9.56%;为 5% 时,页面层是 40.13%。换句话说,单下游的 P99 阈值,在十路扇出后只相当于页面层的 P90.4,而不是 P99。
| 单下游超阈值概率 p | 10 路扇出至少一个超阈值 | 该阈值在扇出层的分位 |
|---|---|---|
| 0.1% | 0.9955% | P99.9 左右 |
| 0.5% | 4.889% | P95.1 |
| 1% | 9.562% | P90.4 |
| 2% | 18.293% | P81.7 |
| 5% | 40.126% | P59.9 |
这张表解释了为什么“每个服务都达标”不等于“端到端达标”。要守住端到端 P99,单下游阈值必须按 p = 1 - 0.99^(1/10) ≈ 0.1% 反推,而不是继续用 1%。实际系统还有相关性:同一台宿主机、同一个数据库、同一次网络抖动会让多个下游一起慢,独立假设只是下界,真实放大通常更严重。
订单链路要分三本账
订单接口的读数不能只记“成功请求的延迟”。一次调用至少有三种结局:成功返回、业务失败返回、超时或连接失败。三者混在一个直方图里,会得到两个错误结论:失败请求很快返回时拉低 P99,让人误以为系统变快;超时请求根本没有响应,却被排除在样本之外,让超时率凭空消失。
arch-lab 的口径是:
| 结局 | 记录什么 | 分母 | 用途 |
|---|---|---|---|
| 成功 | 从进入 handler 到写完响应的时间 | 全部请求 | SLO、容量、优化对比 |
| 失败 | 同样计时,但单独一个直方图 | 全部请求 | 区分“快失败”和“慢失败” |
| 超时 | 从发起到放弃的时间 | 全部请求 | 客户端超时率、重试放大 |
成功延迟用于承诺;失败延迟用于判断错误处理是否本身变慢;超时延迟用于判断预算是否被耗尽。三者的 P99 不能相加,也不能合并成一个“总 P99”。
本机实测:1000 次请求
下面的测试启动一个真实的 httptest 服务,串行发起 1000 次 HTTP 请求。前 990 次在 handler 内睡 2ms,接着 9 次睡 25ms,最后 1 次睡 120ms;计时点在 handler 内部,测的是服务端处理时间,不包含客户端连接和调度开销。
// file: internal/platform/metrics/latency_test.go
// tier: L1
// verify: go test ./internal/platform/metrics/ -count=1 -v
package metrics
import (
"math"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func TestHistogramQuantiles(t *testing.T) {
h := NewHistogram([]time.Duration{5 * time.Millisecond, 10 * time.Millisecond, 20 * time.Millisecond})
for i := 0; i < 90; i++ {
h.Record(7 * time.Millisecond)
}
for i := 0; i < 9; i++ {
h.Record(14 * time.Millisecond)
}
h.Record(30 * time.Millisecond)
s := h.Snapshot()
if s.Count != 100 || s.P50 != 10*time.Millisecond || s.P99 != 20*time.Millisecond || s.P999 != 30*time.Millisecond {
t.Fatalf("snapshot = %+v", s)
}
if want := (90*7 + 9*14 + 30) * time.Millisecond / 100; s.Mean != want {
t.Fatalf("mean = %s, want %s", s.Mean, want)
}
prev := time.Duration(0)
for _, q := range []float64{0.1, 0.5, 0.9, 0.99, 1} {
if got := h.Quantile(q); got < prev {
t.Fatalf("q=%.2f -> %s below %s", q, got, prev)
} else {
prev = got
}
}
}
func TestFanoutAmplification(t *testing.T) {
cases := []struct {
p float64
want float64
}{
{0.001, 0.009955119790251765},
{0.005, 0.048889869534228136},
{0.01, 0.09561792499119559},
{0.02, 0.1829271931124533},
{0.05, 0.4012630607616213},
}
for _, tc := range cases {
got := FanoutTail(tc.p, 10)
if math.Abs(got-tc.want) > 1e-12 {
t.Fatalf("FanoutTail(%.3f) = %.12f, want %.12f", tc.p, got, tc.want)
}
t.Logf("p=%.3f n=10 fanout_tail=%.6f", tc.p, got)
}
if got := FanoutPercentile(0.99, 10); math.Abs(got-0.9043820750088044) > 1e-12 {
t.Fatalf("FanoutPercentile = %.12f", got)
}
}
func TestOutcomeSeparation(t *testing.T) {
rec := NewOutcomes(DefaultBounds())
for i := 0; i < 3; i++ {
rec.Record(Success, 5*time.Millisecond)
}
for i := 0; i < 2; i++ {
rec.Record(Failure, 500*time.Millisecond)
}
rec.Record(Timeout, time.Second)
snap := rec.Snapshot()
if snap[Success].Count != 3 || snap[Success].P99 != 5*time.Millisecond || snap[Failure].P99 != 500*time.Millisecond {
t.Fatalf("snapshots = %+v", snap)
}
}
func TestHTTPTailMeasurement(t *testing.T) {
const requests = 1000
var seq atomic.Int64
rec := NewOutcomes(DefaultBounds())
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
i := seq.Add(1)
started := time.Now()
switch {
case i > 999:
time.Sleep(120 * time.Millisecond)
case i > 990:
time.Sleep(25 * time.Millisecond)
default:
time.Sleep(2 * time.Millisecond)
}
rec.Record(Success, time.Since(started))
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
client := &http.Client{Timeout: time.Second}
start := time.Now()
for i := 0; i < requests; i++ {
resp, err := client.Get(srv.URL + "/orders")
if err != nil {
rec.Record(Timeout, 0)
continue
}
resp.Body.Close()
}
s := rec.Snapshot()[Success]
t.Logf("requests=%d wall=%s p50=%s p99=%s p999=%s max=%s mean=%s",
requests, time.Since(start).Round(time.Millisecond), s.P50, s.P99, s.P999, s.Max, s.Mean)
if s.Count != requests || s.P99 >= s.P999 || s.P999 != 30*time.Millisecond || s.Max < 100*time.Millisecond {
t.Fatalf("tail not separated: %+v", s)
}
}
真实输出与读数
测量环境:macOS 26.3、Apple M1 Pro(10 核)、Go 1.26.0。命令与原始输出保存在 .local/reports/be-arch/be-arch-perf-01-1-http-httptest-runs.txt,连续运行 3 次,输出未改写:
$ go test ./internal/platform/metrics/ -run TestHTTPTailMeasurement -count=3 -v
requests=1000 wall=3.491s p50=3ms p99=20ms p999=30ms max=120.373916ms mean=2.765151ms
requests=1000 wall=3.731s p50=3ms p99=20ms p999=30ms max=120.831167ms mean=2.831267ms
requests=1000 wall=3.261s p50=3ms p99=3ms p999=30ms max=120.210667ms mean=2.700732ms
三次运行的 P50 都是 3ms;P999 都是 30ms,对应 9 个 25ms 请求的上界;最大值在 120ms 附近,对应最后那个 120ms 请求。P99 在前两次是 20ms,第三次是 3ms,这个波动来自本机探针自身的调度噪声,而不是故意注入的尾部。单次 1000 请求的本地实验不足以把 P99 精确到毫秒,所以正文只把 P50/P999/最大值当作稳定信号,P99 只写“本机三次 20/20/3ms”。
这里还有一个容易误读的点:1000 个样本里只有 1 个 120ms 请求时,P999 的目标秩是 999,恰好落在 9 个 25ms 样本之后,所以 P999 报 30ms 而不是 120ms。要看到最后那一个,必须看最大值,或者把窗口扩到 10000 个请求。尾部分位数越极端,需要的样本越多;用 1000 个样本谈 P99.99 是没有统计意义的。
mean=2.7ms 也值得注意:它明显高于 2ms 的睡眠时间,因为包含 handler 调度、计时和直方图写入。平均值与 P50 之间的差距,本身就是一条线索:如果均值远高于中位数,说明分布被尾部拉长了。
常见错误与边界
第一,只报平均值。平均值会让 1% 的 120ms 请求消失,也会掩盖双峰分布;订单接口至少同时报 P50、P99、P999 和最大值。
第二,把不同实例的 P99 直接平均。分位数不是可加量,两个实例的 P99 平均既不是全局 P99,也没有业务含义。正确做法是合并直方图桶计数后重新计算,或者分别展示。
第三,把失败和超时混进成功延迟。快失败会拉低 P99,超时则可能根本没有进入样本;三本账必须分开记。
第四,忽略相关性和扇出。独立同分布是理想假设;同机房、同数据库、同一次 GC 会让下游一起慢,十路扇出的真实尾部通常比 1-(1-p)^10 更差。
小结
吞吐和延迟必须分开测,延迟必须按分布读。固定桶直方图用固定内存换可控误差,返回桶上界,适合跨窗口合并;订单链路要把成功、失败、超时分开记。十路扇出会把单下游 1% 的超阈值概率放大到 9.56%,所以端到端 SLO 必须反推单下游预算。本机 1000 次请求的实测给出 P50=3ms、P999=30ms、最大值约 120ms,也暴露了本地探针 P99 的噪声边界。下一节从这些读数出发,定位 CPU、内存、I/O 和锁分别把时间花在了哪里。
阅读导航:上一节:目录 · 下一节:1.2 瓶颈定位:CPU、内存、I/O、锁 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。