《后端架构设计·高性能与高可用》2.2 压测模型、数据与流量回放

用 arch-lab 订单系统实现固定到达间隔的开环 loadgen,并与固定 worker 的闭环对照;真实运行展示服务变慢时闭环如何因协调遗漏少报延迟,附原始输出、数据构造、预热、回放比例与脱敏边界,以及超时统计、并发上限与报告口径的读法。

同一个服务,两种压测会给出两种结论

压测工具不是中立的测量仪器,它决定了系统承受什么样的到达过程。开环压测按固定间隔发请求,不管上一个请求是否完成;闭环压测用固定数量的 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 压测结果解读与容量余量 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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