34.5 性能测试与负载模型

一句话

压测的价值主要取决于负载模型是否像真实流量,工具只占一小部分;用闭环压测报出的 QPS 会系统性高估容量,因为系统变慢时它自动降低了发压速率,把「没发出去的请求」从统计里悄悄删掉了。

六种压测,六个不同问题

类型 回答的问题 做法 时长 通过标准
冒烟 脚本和环境通不通? 1–10 并发跑通全链路 1–5 分钟 错误率 0,数据正确
负载 预期峰值下能不能满足 SLO? 按生产峰值 (或 1.2×) 恒速压 30 分钟–2 小时 P99、错误率、资源都达标
压力 系统的拐点/上限在哪? 阶梯加压直到 QPS 不涨/错误率抬头 30–60 分钟 找到 knee,记录崩溃形态
尖峰 突发 5–10 倍流量能不能扛? 瞬间拉高,保持 1–5 分钟 10–30 分钟 不雪崩,恢复时间可接受
浸泡 跑久了会不会漏? 70%–80% 负载跑 8–72 小时 8–72 小时 内存/连接/GC 平稳,无泄漏
破坏 依赖挂一个会怎样? 杀实例、断网、注入延迟 30–60 分钟 降级正确,不级联失败

🔑 一次压测只回答一个问题。把「找上限」「验 SLO」「泡 24 小时」混在一次里,结果通常是三个问题都没答清楚。

负载模型五要素

要素 要定义什么 常见错误 正确做法
① 到达模型 开环 (固定速率) 还是闭环 (固定并发) 只用 ab/固定线程数 入口流量用开环;连接池/会话型用闭环;多数系统是混合
② 流量比例 读/写/搜索/支付各占多少 只压 GET,或 100% 读 按生产日志的接口比例混合
③ 数据分布 热点比例、记录大小、冷热分布 均匀随机、全小 payload 复刻热点 (如 20% key 承担 80% 请求)、大小分布
④ 节奏 思考时间、ramp-up、pacing 0 思考时间、瞬间拉满 按真实会话设置 Z;5–15 分钟 ramp-up
⑤ 生命周期 压多久、何时降载 只跑 1 分钟 ramp → hold → ramp-down,预热与稳态分开统计

⚠️ 模型错了,工具再准也没用。100% 读、均匀 key、小 payload、1 万行数据,压出来的「5000 QPS」在真实流量下可能连 500 QPS 都撑不住。

开环 vs 闭环:coordinated omission

维度 闭环 (Closed) 开环 (Open)
发压逻辑 N 个并发,完成一个再发下一个 按固定时间表发,不管上一个是否完成
系统变慢时 发压自动变慢 到达率不变,请求堆积
适合 数据库连接、会话、线程池 网关入口、真实用户流量
工具 ab、JMeter 固定线程、Locust 固定用户 wrk2、vegeta、k6 constant-arrival-rate
风险 低估延迟、高估容量 (CO) 把系统压垮,需要控制速率上限

一个 500ms 的卡顿,被 P99 藏起来了

设定:目标 100 QPS (每 10ms 一个),总时长 30 秒;服务平时 2ms,在 [10s, 10.5s) 卡死 500ms。单线程发压,发一个等一个。

package main

import (
	"fmt"
	"sort"
	"time"
)

const (
	targetRPS = 100
	duration  = 30 * time.Second
	stallFrom = 10 * time.Second
	stallTo   = 10*time.Second + 500*time.Millisecond
	normal    = 2 * time.Millisecond
	stalled   = 500 * time.Millisecond
)

func service(at time.Duration) time.Duration {
	if at >= stallFrom && at < stallTo {
		return stalled // 卡死窗口:任何落进来的请求都要 500ms
	}
	return normal
}

// 单线程定速发压:发一个等一个。卡死期间“本该发而未发”的请求不会被记录。
func main() {
	interval := time.Second / targetRPS
	var naive, corrected []time.Duration
	now, next := time.Duration(0), time.Duration(0)
	plannedInStall, sentInStall := 0, 0
	for next < duration {
		if next >= stallFrom && next < stallTo {
			plannedInStall++
		}
		send := next
		if now > send { // 上一个请求还没返回,实际发送被推迟
			send = now
		}
		if send >= stallFrom && send < stallTo {
			sentInStall++
		}
		finish := send + service(send)

		naive = append(naive, finish-send)         // 从“实际发出”算
		corrected = append(corrected, finish-next) // 从“计划发出”算
		now = finish

		next += interval
		if now < next {
			now = next // 空闲时等到下一个时间点
		}
	}

	fmt.Printf("计划发出 %d 个;卡死窗口 [10s,10.5s) 内计划 %d 个,实际只发出 %d 个\n",
		int(duration/interval), plannedInStall, sentInStall)
	report("naive    ", naive)
	report("corrected", corrected)
}

func report(name string, xs []time.Duration) {
	s := append([]time.Duration(nil), xs...)
	sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })
	p := func(q float64) time.Duration {
		idx := int(float64(len(s))*q+0.999999) - 1
		if idx < 0 {
			idx = 0
		}
		return s[idx]
	}
	var sum time.Duration
	over := 0
	for _, v := range s {
		sum += v
		if v > 100*time.Millisecond {
			over++
		}
	}
	fmt.Printf("%s n=%d avg=%.2fms p50=%v p99=%v max=%v (>100ms: %d)\n",
		name, len(s), float64(sum)/float64(len(s))/float64(time.Millisecond),
		p(0.50), p(0.99), s[len(s)-1], over)
}

输出:

计划发出 3000 个;卡死窗口 [10s,10.5s) 内计划 50 个,实际只发出 1 个
naive     n=3000 avg=2.17ms p50=2ms p99=2ms max=500ms (>100ms: 1)
corrected n=3000 avg=7.25ms p50=2ms p99=260ms max=500ms (>100ms: 50)

三个数字,三种命运:

指标 naive (实际发出时刻) corrected (计划发出时刻) 差距
平均 2.17ms 7.25ms 3.3×
P99 2ms 260ms 130×
max 500ms 500ms 一样
>100ms 样本 1 个 50 个 50×

最大值在两种算法里都是 500ms,所以「看 max」不能救你;P99 才是会被骗的那个。naive 只记录了 1 个慢请求;另外 49 个请求虽然最终也发出了,但 naive 按「实际发出时刻」计时,把它们的排队等待算成了 0——这部分时间被 omitted 了。

怎么修正

做法 说明
用开环定速发压 按计划时间发,不因响应慢而减速 (wrk2、k6 constant-arrival-rate、vegeta)
记录 intended start 闭环压测时,记录「本该发出的时间」,延迟 = 完成时间 - 计划时间
用 HdrHistogram 支持记录修正后的延迟分布,避免分位数近似误差
报告两个数字 naive 与 corrected 都给,明确口径;只给 naive 等于隐瞒
压测机不能成为瓶颈 客户端 CPU、端口、TLS 握手、单机带宽都要监控

💡 开环发压也有代价:如果系统真的崩溃,请求会无限堆积,压测机和服务都可能被打挂。生产级做法是开环 + 上限 + 熔断:到达率按计划,但在途请求超过阈值时停止发压并记录。

工具选型

工具 模型 协议 脚本 CO 友好 适合
wrk2 开环定速 (-R) HTTP/1.1 Lua ✅ 单接口吞吐/延迟曲线
k6 开环/闭环场景 HTTP、WebSocket、gRPC JavaScript ✅ (constant-arrival-rate) 多接口混合、CI 集成
vegeta 开环定速 HTTP Go/命令行 ✅ 快速恒定速率压测
JMeter 闭环为主 HTTP、JDBC、JMS GUI/XML ⚠️ 需插件/改造 企业既有体系、复杂协议
Locust 闭环 (用户数) HTTP Python ⚠️ 需自定义 灵活场景、Python 团队
ghz 开环 gRPC 命令行/JSON ✅ gRPC 接口
ab 闭环 HTTP/1.1 无 ❌ 只做冒烟,不要用来报容量

k6 的开环场景:

import http from 'k6/http';

export const options = {
  scenarios: {
    steady: {
      executor: 'constant-arrival-rate',
      rate: 500,          // 每秒 500 个请求
      timeUnit: '1s',
      duration: '10m',
      preAllocatedVUs: 200,
      maxVUs: 2000,       // 系统变慢时允许更多 VU 维持速率
    },
  },
  thresholds: {
    http_req_failed: ['rate<0.001'],
    http_req_duration: ['p(99)<300'],
  },
};

export default function () {
  http.get('http://api.internal/items?page=1');
}

wrk2:

wrk2 -t4 -c200 -d300s -R2000 --latency http://api.internal/items
# -R2000 是目标速率 (开环),-c200 是连接数上限

环境与数据:压测结果的第一大误差源

项 要求 不满足的后果
机器规格 CPU/内存/磁盘/网络与生产同构 压出的容量不可迁移
数据量 接近生产 (至少覆盖索引深度、分片数) 查询计划、缓存命中率全变
数据分布 热点比例、大小分布、冷热分层 均匀数据低估锁争用和热点
缓存状态 预热到生产命中率,或明确测冷启动 第一轮 100% 回源,P99 虚高
环境隔离 独立环境/影子流量,无邻居干扰 噪声让结果不可复现
版本固定 commit、镜像、配置、DB 参数一致 不知道测的是哪个版本
监控齐全 客户端 + 服务端 + 下游 + 系统 只看到「慢」,看不到「为什么慢」

两个具体的失真机制:

  • B+ 树高度:百万行约 3 层,亿行约 4 层;更关键的是 buffer pool 命中率——测试库能全放内存,生产库要随机 IO,同一条 SQL 可能差 10 倍。
  • 查询计划:1 万行时优化器可能选全表扫,1 亿行时可能选索引;测试数据太小,根本压不出真实的执行计划。

⚠️ 别用「生产数据脱敏后的 1%」直接压:数据量变了,索引选择性、缓存命中率、锁冲突都会变。至少要让数据量、热点分布、索引层级三个特征接近。

采集什么:客户端和服务端都要

层 必采指标 用途
客户端 计划发送时间、实际发送时间、延迟 (两种口径)、错误率、连接复用、客户端 CPU/端口 发现 CO 和发压瓶颈
服务端 QPS、P50/P95/P99/P999、错误率、CPU、内存、GC 停顿、队列长度 判断 SLO 与资源
连接池 活跃/空闲/等待数、获取连接耗时、超时次数 定位排队
下游 QPS、延迟、错误率、连接池、限流 定位级联瓶颈
系统 load、上下文切换、重传、磁盘 await、网络带宽 区分 CPU/IO/网络

🔑 只记录服务端处理耗时,会把排队时间、网络时间、客户端时间全部漏掉。端到端 P99 和服务端 P99 的差值,往往就是答案。

结果解读:吞吐-延迟曲线的四个阶段

四个阶段依次是:线性 → 拐点 → 饱和 → 崩溃;表中的前两行都属于线性阶段,只是并发不同。

一条典型的曲线 (数值示意,闭环模型:平均延迟 ≈ 并发 / QPS,忽略思考时间):

并发 QPS P50 P99 阶段 现象
1 200 4ms 8ms 线性 延迟稳定,资源空闲
10 700 10ms 35ms 线性 吞吐随并发线性涨
32 1050 20ms 90ms 接近拐点 延迟开始抬头
64 1100 40ms 180ms 拐点 (knee) QPS 不涨,P99 翻倍
128 1080 90ms 900ms 饱和 排队主导,吞吐停滞
256 800 250ms 5s 崩溃 超时/重试放大,吞吐下降

拐点判定规则

QPS 增幅 < 10%,同时 P99 增幅 > 50%  →  到达拐点

上例中,并发 32→64:QPS 只涨 4.8%,P99 从 90ms 涨到 180ms (+100%),拐点在 64 并发、1100 QPS。

规划容量 = 拐点 QPS × 70% = 1100 × 0.7 = 770 QPS

如果生产峰值是 700 QPS,余量只有 10%,一次尖峰就会越过拐点;如果峰值是 1000 QPS,必须优化或扩容,而不是「压测过了 1100 就行」。

⚠️ 拐点不是固定的:缓存命中率、数据量、下游延迟、GC 都会移动它。每次大版本发布后重新压,别拿半年前的数字做容量规划。

压测报告模板

部分 必须写清 示例
目标 SLO + 业务峰值 大促 1000 QPS,P99 < 300ms,错误率 < 0.1%
负载模型 开环/闭环、比例、数据分布、时长 开环 1000 QPS 恒速 30min,读写 8:2,20% 热点 key
环境 机器、版本、数据量、依赖规格 8C16G × 4,MySQL 8C32G,订单表 5000 万行
结果 QPS、P50/P99/P999、错误率、资源 1000 QPS 下 P99 420ms,API CPU 78%,DB CPU 85%
瓶颈 证据链 (USE/profile/慢查询) 连接池等待 120ms + 慢查询缺索引
结论 是否达标、容量、余量 不达标;优化后 P99 180ms,容量 1400 QPS
风险 未覆盖的场景、下次压测计划 未测尖峰/故障注入;下周补

一份合格的报告,读者不看图表也能回答三个问题:压的是什么模型、瓶颈在哪、结论能不能用于生产容量规划。

与其他章节的边界

章节 负责什么 本节的关系
34.1 性能指标 指标定义、分位数、利用率红线 本节用它的口径出报告
34.2 性能建模 压测前预测上限、瓶颈 用模型设计压测点,用压测校准模型
34.5 本节 负载模型、执行、结果解读 —
50 可观测性 生产监控、SLO、告警 压测指标应与生产同源
56.1 混沌工程 故障注入、破坏性实验 「破坏」类压测的延伸
57.1 压测方法论 容量治理流程、周期性压测 本节讲单次怎么做,57.1 讲怎么持续做

反模式

反模式 后果
只用闭环压测 (ab/固定线程) 低估 P99、高估容量 (CO)
不设 ramp-up,瞬间拉满 连接池/缓存/JIT 未预热,数据不可用
测试数据量远小于生产 索引、缓存、查询计划全失真
均匀随机 key,不造热点 锁争用、热 key、缓存击穿压不出来
只压 GET,不压写/混合链路 真实资源竞争 (锁、事务、连接) 没覆盖
压测机和被测服务混部 客户端和服务器抢 CPU,结果互相污染
把超时算成成功 错误率 0,但用户全在等超时
只看服务端耗时 漏掉排队、网络、客户端
只跑一次就下结论 噪声、缓存、邻居干扰都可能让结果偏 20%
压完不清理数据 下次压测数据量变了,结果不可比
用平均延迟写报告 老板满意,用户超时
拿压测 QPS 直接当线上容量 没扣 70% 余量,也没考虑拐点漂移

自检

Q1 为什么闭环压测会高估容量?用「10 并发、正常 100ms、卡顿 1s」举例说明。

▸ 答案:闭环下吞吐 = 并发 / 延迟。正常时 10/0.1 = 100 QPS;卡顿 1s 时变成 10/1 = 10 QPS。压测机在卡顿期间只发了 10 个请求,而生产环境的开环流量在同一秒会发 100 个,多出来的 90 个会排队、会超时。闭环把这些「没发出去」的请求从统计中删掉了,于是报告里只有 10 个 1s 的样本,P99 可能仍是毫秒级,容量被高估。修正:开环定速,或按「计划发送时间」计算延迟。

Q2 一份压测报告写「5000 QPS,P99 50ms,错误率 0」,但上线后大量超时。列三个最可能的原因。

▸ 答案:① 负载模型失真:只压了单接口/只读/均匀 key,生产是混合读写 + 热点 + 大 payload;② 数据规模失真:测试库小,索引和缓存命中率远好于生产;③ 闭环 + CO:压测机没发出真实流量,P99 被低估;④ 环境差异 (网络、TLS、邻居、下游规格);⑤ 只测服务端耗时,漏掉网关/网络排队。任选三个都能解释「压测很好、上线超时」。

Q3 如何确定系统的拐点和规划容量?给出可执行的判定规则。

▸ 答案:用开环阶梯加压 (或闭环逐步加并发),记录每个点的 QPS、P99、错误率、资源。判定:QPS 增幅 < 10% 且 P99 增幅 > 50% 时到达拐点;同时看队列长度/饱和度是否单调增长。规划容量取拐点 QPS 的 70%,并为峰值系数留余量。拐点会随数据量、缓存命中率、版本变化,发布后要重测。

Q4 为什么压测数据不能只用生产数据的 1%?举两个具体机制。

▸ 答案:① 索引与查询计划:数据量小的时候优化器可能选全表扫,B+ 树高度、buffer pool 命中率也不同,同一条 SQL 的耗时可能差 10 倍;② 热点与锁争用:1% 数据下 key 分散,锁冲突和缓存击穿几乎压不出来,生产的热点 key 会把单分片打满;③ 缓存命中率、连接池行为、GC 压力也会随数据量变化。至少要保证数据量、热点分布、索引层级接近生产。

Q5 压测时客户端 CPU 100%,服务端 CPU 30%,QPS 上不去。结论是什么?怎么改?

▸ 答案:客户端成了瓶颈,这轮结果不能用于容量结论。原因可能是 TLS 握手、JSON 序列化、单机端口耗尽 (TIME_WAIT)、连接数上限、压测脚本效率低。改法:分布式发压 (多台压测机)、复用连接、减少客户端序列化开销、监控客户端 CPU/端口/带宽;或者用更轻量的协议/工具 (wrk2、vegeta)。只有确认客户端余量充足,服务端的数字才可信。