同一个服务,两种压测会给出两种结论
压测工具不是中立的测量仪器,它决定了系统承受什么样的到达过程。开环压测按固定间隔发请求,不管上一个请求是否完成;闭环压测用固定数量的 worker 循环“发请求、等响应、再发下一个”。面对一个变慢的服务,开环会继续施加原定压力并把排队暴露出来,闭环会自动降低发送速率,于是报告里的延迟看起来仍然“健康”。
站内 性能与可扩展性 已经讲过吞吐、延迟和扩展性的基本关系,本节不重复指标定义,只交付一个可运行的最小 loadgen,并用同一组参数真实跑出开环与闭环的差异。重点不是复刻 JMeter,而是理解为什么压测报告里的 P99 可能骗人。
开环、闭环与协调遗漏
开环的输入是到达率:每 4ms 发一个请求,目标 250 请求/秒。请求之间互不等待,服务变慢时在途请求数会按 Little’s Law 增长:L = λW,λ 不变,W 变大,L 就变大。如果工具记录了在途请求数和从计划到达时刻起算的延迟,排队会直接出现在 P99 和超时率里。
闭环的输入是并发数:8 个 worker 一起循环。服务变慢时,每个 worker 的循环周期变长,整体发送速率自动下降。它测到的是“8 个并发在服务变慢时能完成多少”,不是“目标到达率下服务会怎样”。这就是协调遗漏(coordinated omission):压测器为了避免自己过载,少发了请求,也就漏掉了那些“本来应该到达、但因为服务慢而被推迟”的请求的延迟。
协调遗漏不是闭环的 bug,而是闭环的定义决定的。要测容量,必须固定到达率;要测单用户视角,闭环更合适。两者不能互相替代,报告里必须写清楚用的是哪一种。
最小开环 loadgen
下面的实现只保留四件事:固定间隔调度、每个请求独立 goroutine、从计划到达时刻计算延迟、完成侧统计。延迟不使用“实际开始发送”的时刻,因为那会把生成器自身的排队藏起来;使用 tick 的计划时刻,才能让 planned 与 started 的差异可见。
// file: internal/loadgen/loadgen.go
// tier: L1
// verify: go test ./internal/loadgen/ -count=1 -v
package loadgen
import (
"context"
"errors"
"io"
"math"
"net/http"
"os"
"sort"
"sync"
"time"
)
var ErrRate = errors.New("rate must be positive")
// Interval 把目标速率换算成固定到达间隔,下限 1µs 是生成器的时间精度。
func Interval(rate float64) (time.Duration, error) {
if rate <= 0 {
return 0, ErrRate
}
d := time.Duration(float64(time.Second) / rate)
if d < time.Microsecond {
d = time.Microsecond
}
return d, nil
}
// Percentile 用最近秩法:rank = ceil(q*n),取 1-based 排名。
func Percentile(values []time.Duration, q float64) time.Duration {
if len(values) == 0 {
return 0
}
q = math.Min(math.Max(q, 0), 1)
sorted := append([]time.Duration(nil), values...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
rank := int(math.Ceil(q*float64(len(sorted)))) - 1
rank = max(0, min(rank, len(sorted)-1))
return sorted[rank]
}
// Result 是完成侧统计;Planned 只对开环有意义。
type Result struct {
Planned, Started, Finished, Completed, TimedOut, Failed, MaxInFlight int
Latencies []time.Duration
}
func (r Result) RPS(wall time.Duration) float64 { return float64(r.Started) / wall.Seconds() }
func (r Result) P(q float64) time.Duration { return Percentile(r.Latencies, q) }
type stats struct {
mu sync.Mutex
res Result
inFlight int
}
func (s *stats) enter() {
s.mu.Lock()
s.inFlight++
s.res.MaxInFlight = max(s.res.MaxInFlight, s.inFlight)
s.res.Started++
s.mu.Unlock()
}
func (s *stats) leave(d time.Duration, timedOut, failed bool) {
s.mu.Lock()
s.inFlight--
s.res.Finished++
s.res.Latencies = append(s.res.Latencies, d)
switch {
case timedOut:
s.res.TimedOut++
case failed:
s.res.Failed++
default:
s.res.Completed++
}
s.mu.Unlock()
}
func (s *stats) snapshot() Result {
s.mu.Lock()
defer s.mu.Unlock()
out := s.res
out.Latencies = append([]time.Duration(nil), s.res.Latencies...)
return out
}
// RunOpen 按固定间隔发起请求,不等待前一个请求结束;延迟从计划到达时刻起算。
func RunOpen(url string, rate float64, duration, timeout time.Duration) (Result, time.Duration, error) {
step, err := Interval(rate)
if err != nil {
return Result{}, 0, err
}
client := &http.Client{Timeout: timeout}
s := &stats{}
var wg sync.WaitGroup
start := time.Now()
ticker := time.NewTicker(step)
timer := time.NewTimer(duration)
defer ticker.Stop()
defer timer.Stop()
loop:
for {
select {
case <-timer.C:
break loop
case scheduled := <-ticker.C:
s.mu.Lock()
s.res.Planned++
s.mu.Unlock()
wg.Add(1)
go func() {
defer wg.Done()
s.enter()
to, failed := request(client, url)
s.leave(time.Since(scheduled), to, failed)
}()
}
}
wg.Wait()
return s.snapshot(), time.Since(start), nil
}
// RunClosed 用固定 worker 数循环请求;吞吐由服务端响应速度决定。
func RunClosed(url string, workers int, duration, timeout time.Duration) (Result, time.Duration) {
client := &http.Client{Timeout: timeout}
s := &stats{}
deadline := time.Now().Add(duration)
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for time.Now().Before(deadline) {
started := time.Now()
s.enter()
to, failed := request(client, url)
s.leave(time.Since(started), to, failed)
}
}()
}
wg.Wait()
return s.snapshot(), time.Since(start)
}
func request(client *http.Client, url string) (timedOut, failed bool) {
ctx, cancel := context.WithTimeout(context.Background(), client.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false, true
}
resp, err := client.Do(req)
if err != nil {
if os.IsTimeout(err) {
return true, false
}
return false, true
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
return false, resp.StatusCode >= 500
}
MaxInFlight 是开环最重要的观测量。服务能及时处理时,它稳定在 λ × W 附近;服务变慢时,它会持续上升,直到超时或内存把生成器压垮。TimedOut 不能只当作错误数:超时样本的延迟被截断在超时值上,真实排队可能更长,所以超时率超过 1% 时,P99 只能写成“下界”。
测试:同一个服务,两种模型
测试用一个 4 槽位、每请求 25ms 的 httptest 服务模拟订单处理器,理论容量是 4 ÷ 0.025 = 160 请求/秒。开环按 250 请求/秒跑 6 秒,明显超过容量;闭环用 8 个 worker 跑 6 秒。两次使用独立服务实例,避免上一轮积压污染下一轮。
// file: internal/loadgen/open_closed_test.go
// tier: L1
// verify: go test ./internal/loadgen/ -count=1 -v
package loadgen
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func sink(t *testing.T, slots int, service time.Duration) *httptest.Server {
t.Helper()
sem := make(chan struct{}, slots)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sem <- struct{}{}
defer func() { <-sem }()
time.Sleep(service)
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
return srv
}
func TestOpenVsClosed(t *testing.T) {
openSrv := sink(t, 4, 25*time.Millisecond)
open, wall, err := RunOpen(openSrv.URL, 250, 6*time.Second, 500*time.Millisecond)
if err != nil {
t.Fatal(err)
}
t.Logf("open planned=%d started=%d finished=%d completed=%d timed_out=%d offered_rps=%.2f max_in_flight=%d p99=%s",
open.Planned, open.Started, open.Finished, open.Completed, open.TimedOut,
open.RPS(wall), open.MaxInFlight, open.P(0.99).Round(time.Millisecond))
if open.TimedOut == 0 || open.P(0.99) < 400*time.Millisecond {
t.Fatalf("open loop did not expose queueing: timeouts=%d p99=%s", open.TimedOut, open.P(0.99))
}
closedSrv := sink(t, 4, 25*time.Millisecond)
closed, wall2 := RunClosed(closedSrv.URL, 8, 6*time.Second, 500*time.Millisecond)
t.Logf("closed started=%d completed=%d timed_out=%d rps=%.2f max_in_flight=%d p99=%s",
closed.Started, closed.Completed, closed.TimedOut,
closed.RPS(wall2), closed.MaxInFlight, closed.P(0.99).Round(time.Millisecond))
if closed.TimedOut != 0 || closed.P(0.99) > 200*time.Millisecond {
t.Fatalf("closed loop should stay healthy: timeouts=%d p99=%s", closed.TimedOut, closed.P(0.99))
}
if closed.RPS(wall2) > 220 {
t.Fatalf("closed loop rps=%.2f, expected to be capped near service capacity", closed.RPS(wall2))
}
}
断言本身也在表达口径:开环必须出现超时和 400ms 以上的 P99,否则说明它没有真正超载;闭环必须没有超时、P99 低于 200ms,且吞吐被压在服务容量附近。如果闭环轻易跑到 250 请求/秒,说明服务容量假设错了,或者 worker 根本没有形成并发。
真实运行结果
以下输出来自本机 macOS(darwin/arm64,Go 1.26.0),go test ./internal/loadgen/ -count=1 -v 单次运行,原始输出保存在 .local/reports/be-arch/be-arch-perf-02-2-load-model-traffic-replay-open-vs-closed.txt:
=== RUN TestOpenVsClosed
open_closed_test.go:29: open planned=1499 started=1499 finished=1499 completed=194 timed_out=1305 offered_rps=230.71 max_in_flight=127 p99=503ms
open_closed_test.go:38: closed started=876 completed=876 timed_out=0 rps=144.89 max_in_flight=8 p99=68ms
--- PASS: TestOpenVsClosed (12.55s)
PASS
ok archlab/internal/loadgen 13.035s
开环计划 1499 个请求,实际发起 1499 个,其中只有 194 个在 500ms 内完成,1305 个超时;P99 503ms,在途请求峰值 127。闭环完成 876 个,零超时,P99 68ms,吞吐 144.89 请求/秒。把两份报告并排看:闭环的尾延迟漂亮得多,但它只完成了目标速率 250 的 58%;开环的 P99 难看,却准确描述了“如果业务真的以 250 请求/秒到达,用户会看到什么”。
这也是协调遗漏的量化形态:闭环不是测出了更低的延迟,而是测了更低的负载。报告里如果只写“P99 68ms,通过”,读者不会知道实际到达率只有 144.89;必须同时写目标速率、实际速率、超时率和在途峰值。
本机单次运行的数字不能当作稳定基准。httptest 服务与应用在同一进程,没有真实网络、TLS、连接池和数据库;绝对吞吐只对这台机器、这次运行有效。它的用途是展示模型差异,不是给 arch-lab 下生产容量结论。
数据构造、预热与回放
压测模型决定“怎么发”,数据决定“发什么”。arch-lab 的回放数据来自订单链路的 24 小时访问日志与 trace,按 order_id 把下单、库存预占、支付创建、结果查询串成一条事务链。500,000 单/日对应约 200 万条请求事件;直接全量回放会先被数据准备压垮,所以按阶段选择比例。
| 阶段 | 回放比例 | 目标 | 持续时间 | 通过条件 |
|---|---|---|---|---|
| 冒烟 | 1% | 验证脚本、数据、鉴权 | 5 分钟 | 无 4xx/5xx 异常,字段完整 |
| 基线 | 10% | 建立延迟与错误率基线 | 15 分钟 | 稳态 10 分钟,到达率误差 <5% |
| 峰值 | 50% | 验证峰值系数与扩容阈值 | 15 分钟 | 目标 QPS 下超时率 <1% |
| 验收 | 100% | 容量验收与故障演练 | 30 分钟 | 连续 3 次,P99 与错误率达标 |
比例不是拍脑袋:1% 用于发现“数据根本跑不起来”,10% 用于发现“脚本能跑但指标不可信”,50% 用于验证峰值模型,100% 才用于容量签字。每次运行至少保留 60 秒预热并从统计中剔除;稳态窗口不少于 5 分钟,且到达率必须达到目标的 95% 以上,否则结论只能写“不可判定”。
数据构造要保留三类东西:事务链的因果关系、热点分布、幂等键的唯一性。订单 ID 重新编号,客户 ID 用 HMAC-SHA256 加盐后截断,手机号、地址、支付凭证全部替换为合成值;时间戳整体平移到目标窗口,但保留原始间隔和突发形状。不能只保留字段、丢掉关联:如果回放时库存预占和支付创建用了不同的订单号,测到的就不是同一条链路。
脱敏边界要写进方案,而不是口头约定。生产数据不出生产环境;跨环境回放只能用不可逆哈希后的 ID 和合成 PII;原始日志的保留期、访问权限和删除时间要可审计。对于无法脱敏的字段,直接不进入回放数据集,而不是“先拷过去再处理”。回放流量还要有独立标识,避免污染真实订单、库存和支付对账。
常见错误与边界
第一,只用闭环压测就宣布容量。闭环会自动降速,服务越慢它发得越少,报告越好看;容量结论必须来自固定到达率的开环。
第二,把超时样本从分位数里删掉。超时请求的延迟被截断,删掉它们会让 P99 系统性偏低;正确做法是保留超时计数,并把 P99 标记为下界。
第三,回放只保留请求体,不保留到达间隔。把所有请求压成“尽快发完”,测到的是瞬时冲击,不是业务峰值;要保留原始间隔分布,再按比例缩放速率。
第四,数据脱敏只做字段替换,不做关联保持。订单号、客户号、幂等键各自随机后,链路断裂,缓存命中、去重和库存冲突都会失真;脱敏必须保持 join key 的一致性。
小结
开环按到达率施压,闭环按并发数循环;服务变慢时,闭环会因协调遗漏少发请求、少报延迟。本节实现的 loadgen 用固定间隔、计划到达时刻计时、超时统计和在途峰值,把这层差异变成可观测的数字:同一服务、目标 250 请求/秒,开环 P99 503ms、1305 次超时,闭环 P99 68ms、零超时,但只完成 144.89 请求/秒。容量结论必须同时报告目标速率、实际速率、超时率和回放比例,否则“P99 通过”只是一句没有分母的话。
阅读导航:上一节:2.1 从业务指标到 QPS 与并发数 · 下一节:2.3 压测结果解读与容量余量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。