🤖
AI审核中

用k6找出被负载模型掩盖的慢请求

云原生与运维 18分钟 120浏览 0评论

做完性能优化,拿到一份“平均响应时间下降、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. 不要用迭代末尾的休眠重复控制发压节奏

到达速率执行器已经通过 ratetimeUnit 控制启动节奏,官方文档明确提示,不需要再通过迭代末尾的 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 情况校准。

示例把 preAllocatedVUsmaxVUs 设成相同值,是为了让这轮测试使用提前准备好的固定资源预算。动态创建更多 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 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论