做完性能优化,拿到一份“平均响应时间下降、P99 达标、错误率接近零”的压测报告,很容易得出结论:系统已经变快了。
但在接受这个结论之前,我更愿意先追问一句:
系统变慢的时候,压测工具有没有继续按照原定强度发送请求?
这个问题并不多余。固定虚拟用户数量的压测,在请求变慢后,后续迭代的启动速度也可能随之下降。原本想验证的是“持续流量下系统能不能扛住”,实际测到的却可能是“系统变慢后,压测工具主动减少请求时的表现”。Grafana k6 官方文档明确讨论了这种负载模型与响应时间相互影响的问题。
本文围绕这一点,拆解固定并发、协调遗漏、开放负载模型,以及如何设计一份不容易“自我美化”的压测脚本。
下文的接口、容量数字和统计结果均用于解释方法,不是某个线上系统的实测报告。
一、固定并发,不等于固定请求到达速率
先看一种常见的压测方式:配置 20 个虚拟用户,每个用户不断请求同一个接口,收到响应后再发起下一次请求。
这里的虚拟用户,也就是 VU,可以理解为执行测试脚本的独立执行单元。
它的行为如下:
graph LR
A["虚拟用户"] --> B["发起请求"]
B --> C["等待响应"]
C --> D["进入下一轮"]
D --> A
假设每轮只有一个串行 HTTP 请求,没有休眠,也暂时忽略脚本执行开销,那么在稳定状态下,可以做一个近似估算:
每秒请求数 ≈ 虚拟用户数量 ÷ 每轮平均耗时。
由此得到:
| 虚拟用户数量 | 每轮平均耗时 | 理论上接近的请求速率 |
|---|---|---|
| 20 | 100 毫秒 | 200 次/秒 |
| 20 | 500 毫秒 | 40 次/秒 |
| 20 | 2 秒 | 10 次/秒 |
这组数字只是算术推演,却说明了一个重要问题:
虚拟用户数量没变,不代表施加给系统的请求速率没变。
当响应时间从 100 毫秒上升到 2 秒,压测程序可能把请求速率从每秒 200 次降到了每秒 10 次。
如果你的业务场景是“固定数量的用户等待页面返回后才继续操作”,这种模型可以有意义。但如果你要验证的是“外部请求持续到达时的容量”,就不能把它直接当成固定到达速率测试。两种模型回答的不是同一个问题。
二、协调遗漏:不是漏记日志,而是少产生了应该观察的请求
上面的现象,与一个容易被忽略的概念有关:协调遗漏,Coordinated Omission。
它并不是说压测工具把已经发生的慢请求从日志里删除了。
更关键的问题是:当系统变慢,压测工具等待响应,原本按照目标负载应该继续出现的请求,没有在对应时间发起。于是,高延迟期间本应接受考验的那部分流量,没有充分进入测量过程。wrk2 的项目说明专门解释了这种测量偏差。
可以把它想象成一次排队能力测试。
你原本要验证的是:
每秒持续有 100 位顾客进店,店里变忙后会发生什么?
但实际执行的却是:
只要店里有人没办完,后面的人就暂时不进来了。
最后得到的等待时间,不一定能代表第一种场景。
这也解释了为什么不能只盯着 P99:
P99 只能描述进入统计的那批样本,不能替没有产生的样本回答问题。
这里同样需要避免另一个极端:协调遗漏不意味着所有固定并发测试都无效,也不意味着 P99 一定会保持低值。真正要检查的是,测试模型是否匹配你想验证的流量来源。
三、开放负载模型,控制的是“按计划启动迭代”
在 k6 中,可以使用 constant-arrival-rate 执行器,按照设定速率启动迭代。
例如,rate: 20 配合 timeUnit: '1s',表示计划每秒启动 20 次迭代,而不是等上一轮执行结束后再决定是否开始下一轮。只要有可用 VU,执行器就会继续按计划调度。
它的逻辑可以画成:
graph LR
A["到达速率计划"] --> B["尝试分配空闲 VU"]
B --> C["执行本轮请求"]
C --> D["执行结束并释放 VU"]
B --> E["无空闲 VU 时记录丢弃"]
这里有两个必须提前说清楚的细节。
1. 迭代速率,不一定等于 HTTP 请求速率
一次迭代可能只查询一篇文章,也可能先登录、再查询列表、最后获取详情。
因此,每秒 20 次迭代,不一定等于每秒 20 个 HTTP 请求。只有在“每轮一个请求、没有额外重定向、没有重试或其他请求”的条件下,两者才容易对应起来。
先定义一轮代表什么业务动作,再谈每秒多少次。
2. 不要用迭代末尾的休眠重复控制发压节奏
到达速率执行器已经通过 rate 和 timeUnit 控制启动节奏,官方文档明确提示,不需要再通过迭代末尾的 sleep() 来实现同样的节流目的。(Grafana Labs)
但这不等于脚本里永远不能等待。业务本身需要的等待,例如模拟用户阅读后再操作,应当属于业务模型,而不是为了凑请求速率临时添加的延迟。
四、一份同时检查“发压完整性、业务正确性和延迟”的脚本
下面以文章详情接口为例。
假设请求路径为 /api/articles/1001,成功响应约定为:
{
"code": 0,
"data": {
"id": 1001,
"title": "测试文章"
}
}
不能只判断 HTTP 状态码是否为 200,还需要确认业务状态和文章编号符合预期。k6 官方文档也特别提醒:HTTP 200 响应体中仍然可能包含错误信息。
下面的脚本保存为 arrival-rate.js。执行前需要按实际接口调整路径、认证方式和响应校验条件,并准备一条稳定存在的测试数据。
压测仅应在自己拥有或获得授权的环境中执行;先做低流量校验,不要直接把示例参数当成生产环境的安全上限。
import http from 'k6/http';
import { check } from 'k6';
import { Counter, Rate, Trend } from 'k6/metrics';
function positiveInt(name, fallback) {
const value = Number(
__ENV[name] === undefined ? fallback : __ENV[name]
);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} 必须是正整数`);
}
return value;
}
const BASE_URL = (__ENV.BASE_URL || '')
.trim()
.replace(/\/+$/, '');
if (!/^https?:\/\/[^/\s]+/.test(BASE_URL)) {
throw new Error(
'请通过 BASE_URL 指定有权测试的 HTTP 或 HTTPS 地址'
);
}
const ARTICLE_ID = __ENV.ARTICLE_ID || '1001';
const RATE = positiveInt('RATE', 20);
const VUS = positiveInt('VUS', 80);
const SECONDS = positiveInt('SECONDS', 120);
const BUDGET_MS = 800;
const started = new Counter('biz_started');
const completed = new Counter('biz_completed');
const failed = new Rate('biz_failed');
const onTime = new Rate('biz_on_time');
const duration = new Trend('biz_duration', true);
// 本例只把 HTTP 200 视为符合预期的 HTTP 响应。
http.setResponseCallback(http.expectedStatuses(200));
export const options = {
maxRedirects: 0,
discardResponseBodies: false,
summaryTrendStats: [
'avg',
'med',
'p(95)',
'p(99)',
'max',
],
scenarios: {
article_read: {
executor: 'constant-arrival-rate',
rate: RATE,
timeUnit: '1s',
duration: `${SECONDS}s`,
preAllocatedVUs: VUS,
maxVUs: VUS,
gracefulStop: '10s',
},
},
thresholds: {
dropped_iterations: ['count==0'],
// 本例为固定速率、固定时长的单场景测试。
biz_started: [`count>=${RATE * SECONDS}`],
biz_completed: [`count>=${RATE * SECONDS}`],
http_req_failed: ['rate<0.001'],
biz_failed: ['rate<0.001'],
biz_on_time: ['rate>=0.99'],
biz_duration: [
'p(95)<300',
'p(99)<800',
],
},
};
export default function () {
started.add(1);
const begin = Date.now();
let ok = false;
try {
const headers = {
Accept: 'application/json',
};
if (__ENV.TOKEN) {
headers.Authorization = `Bearer ${__ENV.TOKEN}`;
}
const response = http.get(
`${BASE_URL}/api/articles/${encodeURIComponent(ARTICLE_ID)}`,
{
timeout: '3s',
headers,
tags: {
name: 'GET /api/articles/:id',
},
}
);
let body = null;
try {
body = response.json();
} catch (_) {
// 非 JSON 响应同样进入业务失败统计。
}
ok = check(response, {
'article response is correct': (r) =>
r.status === 200 &&
body !== null &&
body.code === 0 &&
body.data != null &&
String(body.data.id) === ARTICLE_ID,
});
} finally {
const elapsed = Date.now() - begin;
duration.add(elapsed);
failed.add(!ok);
onTime.add(ok && elapsed < BUDGET_MS);
completed.add(1);
}
}
先用低流量验证脚本和接口约定:
k6 run \
-e BASE_URL=http://127.0.0.1:8080 \
-e ARTICLE_ID=1001 \
-e RATE=5 \
-e VUS=20 \
-e SECONDS=30 \
arrival-rate.js
确认请求路径、认证和业务校验没有问题后,再根据测试环境能力逐步提高负载。例如:
k6 run \
-e BASE_URL=http://127.0.0.1:8080 \
-e ARTICLE_ID=1001 \
-e RATE=20 \
-e VUS=80 \
-e SECONDS=120 \
arrival-rate.js
上面的脚本不是只输出一个延迟数字,而是同时设置了几道门槛。
| 指标 | 回答的问题 |
|---|---|
biz_started |
计划中的业务迭代是否真正开始执行? |
biz_completed |
已启动的尝试是否走到了结束统计位置? |
dropped_iterations |
是否存在因没有空闲 VU 而未启动的迭代? |
http_req_failed |
HTTP 响应是否符合配置的预期? |
biz_failed |
业务状态和返回数据是否正确? |
biz_on_time |
是否既业务成功,又在时限内完成? |
biz_duration |
从本轮开始计时到校验结束的耗时分布如何? |
其中,biz_completed 包含成功和失败的尝试,不等于业务成功数量;biz_on_time 的分母,则是实际写入该指标的尝试数量,并不是所有计划迭代。
脚本通过 setResponseCallback() 把 HTTP 预期收紧为 200,再单独校验业务内容,避免混淆 HTTP 成功和业务成功。
还有一个容易漏掉的地方:check() 失败本身,并不会自动让整场 k6 测试以失败状态结束。需要配合阈值,才能把校验结果变成自动化测试门槛。本例同时给业务失败率和其他关键指标设置了阈值。
这里的耗时、错误率和计数要求都是示例标准,不是所有系统都应照搬的通用标准。改成多场景、分阶段或提前终止测试时,也需要重新计算计划迭代数量。
五、开放模型也会“发不出请求”:VU 预算必须算清楚
开放模型解耦的是启动计划与前一轮完成时间,但它并没有无限的执行资源。
每次迭代仍然需要一个可用 VU。迭代时间越长,需要占用的 VU 越多;如果没有空闲 VU,k6 就可能丢弃原本应该启动的迭代。
可以先用一个近似关系建立直觉:
同时占用的 VU 数量 ≈ 每秒启动的迭代数 × 平均迭代耗时。
假设每秒启动 20 次迭代,每轮平均耗时 100 毫秒,那么平均同时执行的迭代大约只有 2 个。
但如果每轮耗时增长到接近 3 秒,同时占用数量就可能接近 60。
这也是为什么示例没有只分配 2 个 VU,而是预留了更多执行单元。
不过,不能进一步推导成“配置 60 个就一定够”。平均值不能覆盖所有时间段的波动,脚本执行也有开销,需要结合实际迭代时长和空闲 VU 情况校准。
示例把 preAllocatedVUs 和 maxVUs 设成相同值,是为了让这轮测试使用提前准备好的固定资源预算。动态创建更多 VU 并不是免费的,官方文档也建议优先做好预分配,而不是单纯依赖测试期间不断增加 maxVUs。
增加 VU 是为了让发压计划能够执行,不是给被测服务增加处理能力。
六、dropped_iterations 不能当成普通 HTTP 错误看待
对于到达速率执行器,dropped_iterations 表示没有启动的迭代,而不是服务器已经接收到请求之后返回了错误。
如果它从测试刚开始就出现,应先检查执行资源是否配置不足;如果它随着响应时间上升逐渐出现,则需要调查是不是迭代持续占用 VU,导致后续计划无法执行。官方文档明确区分了配置不足和被测系统性能下降等不同原因。
更重要的是:
没启动的迭代,不会自动变成延迟分布里的一条“超慢请求”。
看一个假设案例。计划以每秒 100 次迭代运行 60 秒,最终统计如下:
| 项目 | 数量 |
|---|---|
| 计划启动的迭代 | 6,000 |
| 实际启动并结束的迭代 | 4,800 |
| 未能启动的迭代 | 1,200 |
| 业务正确且在时限内完成的迭代 | 4,752 |
如果只在实际执行的 4,800 次里计算:
按时成功比例 = 4,752 ÷ 4,800 = 99%。
这个数字看起来不错。
但如果问的是“原定负载计划有多少被正确且按时完成”,那么:
计划兑现比例 = 4,752 ÷ 6,000 = 79.2%。
这两个比例都可以算,但含义完全不同。
后者不是线上真实用户成功率,因为那 1,200 次迭代根本没有发生;它描述的是这次测试对原定负载计划的兑现程度。
这组统计也不能单独证明服务器只能处理每秒 80 次请求,因为缺口可能来自发压机或 VU 配置。它能证明的是:
这轮测试没有完整兑现每秒 100 次的负载计划,不能据此宣称该负载已经验证通过。
这正是示例脚本同时检查计数、丢弃迭代和业务结果的原因。
七、延迟从哪里开始计时,决定了你到底测到了什么
除了负载模型,另一个需要明确的问题是:延迟的起点在哪里?
1. http_req_duration 不是所有客户端等待时间
k6 的 http_req_duration 等于发送、等待响应和接收响应时间之和,不包含最初的 DNS 查询和连接建立时间。TCP 连接、TLS 握手等另有相关指标。
因此,不能把它直接理解成“从准备执行任务到业务结果可用”的完整时间。
2. 自定义 biz_duration 扩大了计时范围,但仍有边界
示例在调用 http.get() 前记录时间,在响应解析和校验结束后记录耗时。
因此,它覆盖的是脚本中这段实际执行区间。
但它仍然没有包含:
这次迭代按照计划应该开始,到它真正获得执行机会之间的等待。
可以用三个时间点说明:
| 时间点 | 含义 |
|---|---|
| T1 | 按负载计划应该启动 |
| T2 | 实际进入本轮执行 |
| T3 | 请求与业务校验结束 |
示例中的 biz_duration 近似测量 T3 − T2,而不是 T3 − T1。
这是直接由脚本计时位置决定的,不能因为它叫“业务耗时”,就默认已经覆盖了整个计划执行过程。
3. 开放负载模型,不等于自动补偿所有遗漏
wrk2 的测量方法明确考虑了“请求本应发送的时间”,而不只是实际发送时间。这与单纯提高请求启动独立性,是两个相关但不同的维度。(GitHub)
因此,使用 k6 到达速率执行器之后,仍然需要核对迭代缺口和实际启动情况,不能直接宣称所有协调遗漏问题都已经消失。
负载生成方式和延迟记录方式,需要分别检查。
八、压测结束方式,也可能影响结论
一个请求刚开始执行,测试时间就到了,会发生什么?
k6 的 gracefulStop 用于给正在执行的迭代留出结束时间。超过这个窗口仍然没有结束的迭代,可能被强制中断;官方文档提醒,这种中断可能影响指标和测试结果。
示例设置请求超时为 3 秒,收尾窗口为 10 秒,是为了给正常的请求超时及脚本收尾留出余量。
但不能只依赖 finally 里的计数,还要检查运行结果中的中断迭代,以及开始和结束计数是否匹配。
同时,需要区分两个统计窗口:
发压窗口负责启动新的迭代;收尾窗口负责等待已启动的迭代结束。
因此,验证“是否维持了目标到达速率”时,应关注发压阶段的启动情况,而不是直接拿包含收尾时间的全程平均值代替。
k6 的 HTTP 指标在请求结束或超时时输出,这也意味着“请求完成时间上的统计”与“请求开始时间上的统计”不是同一个观察视角。(Grafana Labs)
九、怎样用这套方法验证性能优化
完成脚本,只是建立了一个测量工具。要判断优化是否有效,还需要约束比较方法。
先固定测试对象,不要让业务场景偷偷变化
对于上面的文章详情接口,我会先明确:测试数据是否相同,是否带认证,是否允许缓存命中,是否经过相同网关,以及响应校验是否一致。
固定查询一篇文章,适合做一个可重复的小场景;但它不能代表整个站点的访问分布。
需要验证更多业务时,应分别定义不同文章、不同访问身份、不同接口组合的场景,而不是把同一个热点请求的成绩直接当作整站容量。
再分别观察稳态、高压和恢复阶段
我会把低流量功能校验、稳定负载验证、逐步增压和退压恢复分开看。
这样做的目的,是让每一段测试都有明确问题:
稳定阶段是否持续达标?提高负载后首先出现的是业务错误、延迟上升,还是发压缺口?降低负载以后,系统能否回到原来的表现?
不要让一个全程汇总的 P99,替所有阶段做出同一个结论。
最后,把发压机也纳入监控
当发压机本身的 CPU、内存或网络资源不足时,测试结果也可能失真。Grafana 官方的大规模测试指南明确要求监控负载发生器,并提醒 CPU 饱和可能导致不准确的延迟表现。
因此,当请求没有按计划发出时,不能直接归因于服务端。
尤其不要一看到丢弃迭代,就无限增大 VU;如果发压机已经接近资源上限,这样做可能只是在放大另一侧的瓶颈。
我最终会接受的优化结论,不是单独一句“P99 降低了”,而是:
在相同业务场景和负载计划下,实际发压完整,业务正确性保持达标,响应时间改善,而且测试两端都没有出现新的资源瓶颈。
十、总结:容量验证,先确认测试真的发生了
性能测试最容易让人产生错觉的地方,是报告上的数字看起来都很精确。
但数字精确,不代表问题问对了。
固定并发测试可以回答固定用户群体的行为表现,却不能天然替代持续到达流量下的容量验证。开放负载模型能改善启动计划与响应速度之间的耦合,但仍然需要检查执行资源、丢弃迭代和计时边界。
所以,在判断系统是否变快之前,先把三个问题连起来:
请求是否按计划执行,业务是否正确完成,完成时间是否满足要求。
真正可信的容量结论,不是“已经发出的请求看起来很快”,而是:
在明确的业务场景和到达负载下,系统持续完成了应该完成的工作。

评论区 0