k6 压测结果别只发终端截图:摘要、HTML、Grafana 三层怎么落地

简介: 本文详解k6压测报告三层架构:终端摘要(秒级判定)、HTML报告(归档可读)、Grafana看板(时序对比)。解决截图失真、时间维度丢失、跨版本难比等痛点,提供可落地的配置方案与避坑清单,让压测结果真正可追溯、可对比、可决策。(239字)

压测跑完,群里甩一张终端截图,配一句「p95 达标,可以上」。

三天后复盘会上有人问:那天 p95 到底多少?拐点出现在第几分钟?失败率是从什么时候开始爬的?

没人答得上来。截图还在群里,但终端那张表只印了聚合后的最终值,时间维度已经被压扁——而压测最值钱的信息恰恰在时间维度上。

这不是「没人存结果」,是报告分层没做。k6 本身给了三层输出能力,绝大多数团队只用了第零层:肉眼看终端。

顺带划一条边界:这篇只讲报告怎么产出、怎么呈现。压测结果怎么转成容量水位线、怎么接进弹性伸缩,是另一件事,不在这里展开。

一、三层各自回答什么问题

先把三层的分工定清楚,不然配出来的东西会互相重叠又互相缺口。

层级 载体 受众 时效 回答的问题
第一层 · 终端摘要 stdout + summary.json + junit.xml 跑测的人、CI 门禁 即时,秒级 这次跑没跑过?阈值破没破?
第二层 · HTML 报告 自包含单文件 HTML 开发、产品、跨团队评审 归档,随版本留存 这次压测的完整结论长什么样?
第三层 · Grafana 看板 Prometheus 时序库 性能负责人、值班 长期,可跨版本对比 这条曲线和上次比怎么样?拐点在哪一分钟?

06-配图1.png

三层的判断标准很简单:第一层给机器读,第二层给人读一次,第三层给人反复读。

二、第一层:handleSummary,先把默认输出留住

k6 默认的终端摘要其实已经不错,但它有个致命特性——只在屏幕上出现一次,进程退出就没了。

handleSummary() 是官方给的收口点。测试结束后 k6 把所有指标聚合成一个 JS 对象交给它,你返回一个 {key: value} 映射,key 决定内容去哪:stdoutstderr,或者任意文件路径(会覆盖同名文件);value 可以是字符串,也可以是 ArrayBuffer。

有个坑必须先说:一旦导出 handleSummary,k6 就不再打印默认摘要。 很多人配完发现终端空空如也,以为脚本挂了,其实是这个机制——想保留终端输出,得自己从 jslib 把 textSummary 请回来。

// perf/load-order-create.js
import http from 'k6/http';
import {
    check } from 'k6';
import {
    textSummary, jUnit } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js';

export const options = {
   
  scenarios: {
   
    ramp: {
   
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        {
    duration: '2m', target: 50 },
        {
    duration: '5m', target: 200 },
        {
    duration: '2m', target: 0 },
      ],
    },
  },
  thresholds: {
   
    // 这三条是报告的判定依据,不是扩缩容阈值
    http_req_duration: ['p(95)<400', 'p(99)<800'],
    http_req_failed: ['rate<0.01'],
    checks: ['rate>0.99'],
  },
};

export default function () {
   
  const res = http.post(`${
     __ENV.BASE_URL}/api/order`, JSON.stringify({
   
    skuId: 'SKU-10086', qty: 1,
  }), {
    headers: {
    'Content-Type': 'application/json' } });

  check(res, {
   
    'status is 200': (r) => r.status === 200,
    'order id returned': (r) => r.json('orderId') !== undefined,
  });
}

export function handleSummary(data) {
   
  // 阈值破没破,直接从这里读,不用去解析终端文本
  const p95 = data.metrics.http_req_duration.values['p(95)'];
  const failed = data.metrics.http_req_failed.values.rate;
  const passed = data.metrics.http_req_duration.thresholds['p(95)<400'].ok
              && data.metrics.http_req_failed.thresholds['rate<0.01'].ok;

  const verdict = {
   
    结论: passed ? 'PASS' : 'FAIL',
    'p95(ms)': Math.round(p95),
    失败率: `${
     (failed * 100).toFixed(3)}%`,
    总请求数: data.metrics.http_reqs.values.count,
    压测时长: `${
     Math.round(data.state.testRunDurationMs / 1000)}s`,
  };

  return {
   
    // 1) 终端照旧打印,别让同事以为脚本坏了
    stdout: textSummary(data, {
    indent: ' ', enableColors: true }),
    // 2) 完整原始对象落盘,第二层第三层都从它派生
    'reports/summary.json': JSON.stringify(data, null, 2),
    // 3) 给人看的一行结论
    'reports/verdict.json': JSON.stringify(verdict, null, 2),
    // 4) JUnit:CI 门禁只认这个,破了阈值就是 failures="1"
    'reports/junit.xml': jUnit(data),
  };
}

jUnit(data) 是三层里最容易被忽略、收益却最高的一步。它把 thresholds 的通过情况转成标准 JUnit XML,破阈值就输出 <failure message="failed" />CI 因此不必写任何 grep 脚本去解析终端文本,任何支持 JUnit 的插件都能直接把它变成红绿门禁。

verdict.json 是给通知用的。往企业微信/钉钉推消息时读这个四行 JSON 拼一句话,比糊一张终端截图有用得多。

三、第二层:HTML 报告,官方内置那条路很多人不知道

提到 k6 出 HTML,多数文章直接推第三方 reporter。但 k6 自带一个 web dashboard,还能导出自包含的 HTML 报告——单文件、离线可开、能直接发群或塞进制品库。

# 本地跑:浏览器自动开 http://127.0.0.1:5665 实时看
K6_WEB_DASHBOARD=true \
K6_WEB_DASHBOARD_OPEN=true \
k6 run perf/load-order-create.js

# CI 里跑:不要开浏览器,直接导出静态 HTML
K6_WEB_DASHBOARD=true \
K6_WEB_DASHBOARD_PORT=-1 \
K6_WEB_DASHBOARD_EXPORT=reports/k6-report.html \
k6 run perf/load-order-create.js

两个参数是 CI 场景的必需品:K6_WEB_DASHBOARD_PORT=-1 关掉 HTTP 监听——只要有浏览器窗口连着 dashboard,k6 进程就不退出,在 CI 里会挂死到超时;K6_WEB_DASHBOARD_EXPORT 让测试跑完自动导出,不用再回 dashboard 点 Report 按钮。

还有个会静默坑人的细节:报告只在测试时长大于 3 倍聚合周期时才含图表。 聚合周期由 K6_WEB_DASHBOARD_PERIOD 控制,默认 10s。跑一个 20 秒的冒烟压测,导出的 HTML 就只有数字没有曲线。

只有当报告要带公司模板、业务字段或多场景对比时,才轮到自建模板路线——从第一层落盘的 summary.json 派生:

// tools/render-report.js  —— node tools/render-report.js reports/summary.json
const fs = require('fs');
const Handlebars = require('handlebars');

const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf-8'));
const tpl = Handlebars.compile(fs.readFileSync('tools/report.hbs', 'utf-8'));

// 只把模板真正要用的字段抽出来,别把整个 summary 塞进模板
const view = {
   
  meta: {
   
    scenario: data.root_group ? data.root_group.name : 'default',
    durationSec: Math.round(data.state.testRunDurationMs / 1000),
    vusMax: data.metrics.vus_max.values.max,
  },
  latency: {
   
    p95: Math.round(data.metrics.http_req_duration.values['p(95)']),
    p99: Math.round(data.metrics.http_req_duration.values['p(99)']),
    med: Math.round(data.metrics.http_req_duration.values.med),
  },
  errors: (data.metrics.http_req_failed.values.rate * 100).toFixed(3) + '%',
  thresholds: Object.entries(data.metrics)
    .filter(([, m]) => m.thresholds)
    .flatMap(([name, m]) => Object.entries(m.thresholds)
      .map(([expr, t]) => ({
    name, expr, ok: t.ok }))),
};

fs.writeFileSync('reports/k6-report-custom.html', tpl(view));

两条路怎么选:默认走官方导出,只有报告要对外交付、需要固定品牌模板时,才加自建模板这一层。 自建模板的维护成本被普遍低估——k6 v1.5.0 起在推新的 machine-readable summary 格式(--new-machine-readable-summary,将成为 v2 默认),你自己写的取值路径到时候要跟着改一轮。

四、第三层:Grafana,remote write 的配置细节

前两层解决「这一次」,第三层解决「每一次之间怎么比」。k6 用 experimental-prometheus-rw 输出把时序数据实时推到 Prometheus 的 remote write 端点,Grafana 再从 Prometheus 读,官方提供了现成看板,导入即用。

# docker-compose.perf.yml
services:
  prometheus:
    image: prom/prometheus:v2.53.0
    command:
      - --config.file=/etc/prometheus/prometheus.yml
      # Prometheus 2.x 必须显式打开 remote write 接收端,否则 k6 推不进去
      - --web.enable-remote-write-receiver
      # 想用原生直方图才需要这一行
      - --enable-feature=native-histograms
    ports: ['9090:9090']
    volumes: ['./perf/prometheus.yml:/etc/prometheus/prometheus.yml']

  grafana:
    image: grafana/grafana:11.1.0
    ports: ['3000:3000']
    environment:
      GF_AUTH_ANONYMOUS_ENABLED: 'true'
      GF_AUTH_ANONYMOUS_ORG_ROLE: 'Admin'
K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9090/api/v1/write \
K6_PROMETHEUS_RW_TREND_STATS=p(90),p(95),p(99),max \
K6_PROMETHEUS_RW_PUSH_INTERVAL=5s \
K6_PROMETHEUS_RW_STALE_MARKERS=true \
k6 run -o experimental-prometheus-rw \
  --tag testid=order-create-20260910-01 \
  perf/load-order-create.js

下面几项配置,每一个都对应一个真实踩过的坑:

K6_PROMETHEUS_RW_TREND_STATS 默认只有 p(99)。不显式声明,Grafana 里根本查不到 p95——不是看板坏了,是 k6 压根没推这个统计量。支持 count、sum、min、max、avg、med、p(x),写进去会生成 k6_http_req_duration_p95k6_http_req_duration_max 这类独立指标。

K6_PROMETHEUS_RW_STALE_MARKERS=true。默认最后一次 flush 之后指标还会「存活」5 分钟,压测结束了曲线还拖着一条平线,很多人以为系统仍在承压。打开它,测试结束时把时间序列标记为 stale,曲线干净收尾。

--tag testid=。没有它,多次压测的数据全混在同一条时间序列里,跨版本对比根本做不了。这是第三层最核心的一个约定。

06-配图2.png

counter/gauge 和原生直方图怎么选?官方文档把前者的三条局限写得很直白:一个 trend 指标被拆成多条 Prometheus 指标;百分位这类 gauge 值无法二次聚合(两个 p95 不能平均成一个 p95);k6 侧内存开销偏大。

需要跨场景、跨时段聚合百分位,就上原生直方图:K6_PROMETHEUS_RW_TREND_AS_NATIVE_HISTOGRAM=true 配合 Prometheus 侧 --enable-feature=native-histograms(2.40.0+),查询期用 histogram_quantile() 算分位数。代价是它在 Prometheus 里仍属实验特性,别的 remote write 实现不一定支持。

默认建议:单机自测走 counter/gauge 够用;要做长期趋势和多环境对比,直接上原生直方图。

五、口径对照表:同一个 p95,三层的数可能不一样

这张表值得存下来。三层报出的 p95 不一致时,先来这里对一遍,而不是怀疑 k6 算错了。

维度 第一层 终端/summary.json 第二层 HTML 报告 第三层 Grafana
p95 计算窗口 整场压测全量样本 整场全量(同第一层) PUSH_INTERVAL 分片,默认 5s 一批
数据来源 k6 内存中的完整 trend 结构 派生自 summary.json 只含 TREND_STATS 声明过的统计量
能否跨场次对比 不能,进程退出即消失 能,靠人工翻文件 能,靠 testid 标签
含时间维度 不含,已聚合成终值 含(时长 > 3×period 才有图表) 完整保留
百分位可再聚合 不适用 不适用 counter/gauge 路线不可,原生直方图
门禁可用性 junit.xml 直接可用 不可,需人工判读 不可,需另配 alert

第三行和第五行是真正会出事故的地方。「终端说 p95 是 380ms,Grafana 上看是 520ms」——大概率不是谁算错了:Grafana 那条曲线是每 5 秒一个分片值,你看到的是峰值时段那一片,终端报的是全量样本的分位数。两个数都对,问的问题不一样。

六、把三层串进一条流水线

三层配完不等于链路通了。真正让它变成资产的是这一步:一次 k6 run,三层产物同时落地。

# .github/workflows/perf-report.yml
name: perf-report
on:
  workflow_dispatch:
    inputs:
      scenario: {
    description: '压测场景', required: true, default: 'order-create' }

jobs:
  k6:
    runs-on: ubuntu-latest
    env:
      TEST_ID: ${
   {
    github.event.inputs.scenario }}-${
   {
    github.run_id }}
    steps:
      - uses: actions/checkout@v4
      - run: mkdir -p reports

      - name: 起 Prometheus + Grafana
        run: docker compose -f docker-compose.perf.yml up -d --wait

      - name: 跑 k6(三层同时产出)
        run: |
          K6_WEB_DASHBOARD=true \
          K6_WEB_DASHBOARD_PORT=-1 \
          K6_WEB_DASHBOARD_EXPORT=reports/k6-report.html \
          K6_WEB_DASHBOARD_PERIOD=5s \
          K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9090/api/v1/write \
          K6_PROMETHEUS_RW_TREND_STATS=p(90),p(95),p(99),max \
          K6_PROMETHEUS_RW_STALE_MARKERS=true \
          k6 run -o experimental-prometheus-rw \
            --tag testid=$TEST_ID \
            -e BASE_URL=$PERF_BASE_URL \
            perf/load-order-create.js

      # junit.xml 是门禁:破阈值 -> failures=1 -> 这一步失败
      - name: 门禁判定
        if: always()
        uses: dorny/test-reporter@v1
        with:
          name: k6 thresholds
          path: reports/junit.xml
          reporter: java-junit

      - name: 归档 HTML 与原始 summary
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: k6-report-$TEST_ID
          path: |
            reports/k6-report.html
            reports/summary.json
            reports/verdict.json
          retention-days: 90

      - name: 推送一行结论(不是截图)
        if: always()
        run: |
          VERDICT=$(cat reports/verdict.json | jq -c .)
          curl -sX POST "$WECOM_WEBHOOK" -H 'Content-Type: application/json' \
            -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"压测 $TEST_ID $VERDICT\"}}"

06-配图3.png

三处工程上的取舍:

retention-days: 90 给 HTML 和 summary.json,不给 Grafana。 制品库里是按场次归档的死数据,Grafana 里是可查询的活数据,保留策略本就该分开定,Prometheus 侧按存储预算单独配。

最后推的是 verdict.json 的一行文本,不是终端截图。 这是整篇最想改的习惯:截图不可搜索、不可比对、不可归档,而结构化文本能直接进通知、周报和复盘材料。

门禁只挂 junit.xml。 第一层负责判定,第二三层负责解释。把判定权交给需要人看图的那一层,门禁就等于没有。

七、三层的静默失效清单

这三层最麻烦的地方是:配错了不报错,只是安静地产出一个空的或误导性的结果。

第一层

  • 导出了 handleSummary 但没请回 textSummary → 终端一片空白,误以为脚本挂了。
  • handleSummary 里读 data.metrics.xxx.values['p(95)'],但改过 summaryTrendStats → 取到 undefined。官方文档明确:改了它,trend 的 values 键会跟着变。
  • junit.xml 做门禁,却没在 CI 里配 JUnit 解析器 → 阈值破了但 job 是绿的。handleSummary 只负责生成文件,它自己不改变 k6 的退出码

第二层

  • CI 里没设 K6_WEB_DASHBOARD_PORT=-1 → 只要有 dashboard 连接,k6 进程不退出,job 挂到超时。
  • 压测时长不足 3 倍 K6_WEB_DASHBOARD_PERIOD(默认 10s,即 30s)→ 导出的 HTML 只有数字没有图表。
  • 自建模板直接吃整个 summary.json → k6 升到 v1.5.0+ 后新的 machine-readable 格式一变,模板取值路径全线失效。只抽模板真正要的字段,能把这个爆炸半径压到最小。

第三层

  • K6_PROMETHEUS_RW_SERVER_URL 只写到 :9090,漏了 /api/v1/write → 推不进去,k6 侧不一定有明显报错。
  • Prometheus 2.x 没开 --web.enable-remote-write-receiver → 接收端根本不监听。
  • 没设 K6_PROMETHEUS_RW_TREND_STATS → 默认只推 p(99),Grafana 里查不到 p95,误判成看板坏了。
  • 没打 --tag testid= → 多次压测混在同一条时间序列里,跨版本对比这件事从第一天起就做不了。
  • 没开 K6_PROMETHEUS_RW_STALE_MARKERS → 测试结束后曲线还拖 5 分钟平线,看起来像系统仍在承压。

这十一条里,有十条的共同特征是:它们不会让流水线变红。 唯一会红的是 CI 里没关 dashboard 端口那条——它会挂到超时。而一条不报错的配置错误,远比一条报错的危险:后者你当天就会修,前者会一直挂在那儿,直到某次复盘会上有人问出一个你答不上来的问题。

八、写在最后

压测报告的分层,本质是在回答一个问题:这次跑出来的数,下次还能不能用。

终端截图的答案是不能。它服务的是「跑完那一刻的沟通」,而压测真正的价值在「第 N 次和第 N-1 次的对比」上——那个对比只发生在有时序、有标签、有归档的地方。三层配齐后,压测的产出物就从「一次结论」变成「一条可持续查询的曲线」。

而这条曲线能不能跨版本比,取决于你今天有没有给 testid 打标、有没有把 TREND_STATS 从默认的 p(99) 展开成 p(90),p(95),p(99),max。这两个动作各花十秒,但只在第一天做才有用。

我们在整理性能测试报告链路的工程化落地,如果你那套 k6 结果现在还靠截图传,留言区聊聊卡在哪一层。

相关文章
|
6天前
|
人工智能 运维 BI
阿里云千问办公QwenWork深度解析:基于Qwen3.8,六大核心能力重构企业全自动化工作流与计费选型指南
传统AI办公工具大多停留在对话问答、文档摘要、简单文案生成层面,只能完成单点碎片化任务,无法自主拆解复杂业务流程,很难串联多工具、多文档、外部业务系统完成端到端完整工作交付。很多企业在落地AI办公的时候,需要组合多款不同工具,来回切换界面,手动复制粘贴中间结果,智能化改造落地门槛居高不下。千问办公QwenWork是整合多款智能体产品能力打造的一体化企业办公智能体平台,底层基座依托Qwen3.8大模型,打通桌面端Agent、云端Agent、企业协同Agent三种运行形态,不再局限简单问答,接收业务目标之后自主拆解任务步骤,调用各类工具,处理文档、表格、浏览器自动化、数据查询,直接输出可交付的办公
1520 0
|
6天前
|
人工智能 自然语言处理 安全
阿里云AI数智鉴密:AI 生成内容如何拿到一张"防篡改的身份证"
隐形水印 + C2PA签名:让AI生成内容“持证上岗”。
1134 0
|
15天前
|
人工智能 自然语言处理 安全
阿里云千问办公、Qoder Teams、Qoder CN区别与选择指南:模型能力、适用场景与最新活动参考
本文聚焦阿里云2026年推出的三款自研AI办公产品,清晰拆解千问办公、Qoder Teams、Qoder CN的差异化定位与能力边界:千问办公主打职场全场景提效,支持自然语言指令一键完成PPT生成、数据分析等高频办公任务;Qoder Teams面向程序员团队,深度整合AI代码生成、团队协同与企业知识库能力;Qoder CN则专为金融、政务等强合规场景打造,实现数据不出境与VPC私有化部署。文章同步给出分场景选型指南与最新活动定价,帮助不同类型的企业按需组合产品,实现业务岗、研发岗与强合规场景的AI能力全覆盖。
3799 4
阿里云千问办公、Qoder Teams、Qoder CN区别与选择指南:模型能力、适用场景与最新活动参考
|
3天前
|
SQL 人工智能 前端开发
QoderWake 1.0 正式发布:从桌面里的 Agent,到工作现场的数字员工
QoderWake v1.0正式发布:企业级数字员工团队平台。支持“一句话建岗”,预置10类特训岗位;Waker常驻钉钉/飞书群,@即响应、自动协作、跨任务记忆;具备定时/事件/API多触发方式与统一任务看板;已沉淀27.6万条记忆、12.3万项技能,助力组织实现人机协同增效。
655 0
|
2天前
|
人工智能 API 内存技术
刚刚 DeepSeek V4.1 Flash 开启内测,1 分钟教你用上!
刚刚 DeepSeek 内测群发布了 DeepSeek V4.1 Flash 中间版本内测的消息,这次的模型采用了新的结构,原生支持多模态、能力更强、速度更快、且成本更低。
1449 2
|
7天前
|
网络协议 Linux iOS开发
【2026实测】Wireshark下载+安装+汉化+使用教程(图文版,巨详细)
Wireshark 是一款免费开源的网络协议分析工具,可实时捕获、解析并可视化数据包,助你诊断网络故障、分析通信协议(如HTTP、DNS、TCP等)。支持Windows/macOS/Linux,含中文界面,新手入门便捷。(239字)