压测跑完,群里甩一张终端截图,配一句「p95 达标,可以上」。
三天后复盘会上有人问:那天 p95 到底多少?拐点出现在第几分钟?失败率是从什么时候开始爬的?
没人答得上来。截图还在群里,但终端那张表只印了聚合后的最终值,时间维度已经被压扁——而压测最值钱的信息恰恰在时间维度上。
这不是「没人存结果」,是报告分层没做。k6 本身给了三层输出能力,绝大多数团队只用了第零层:肉眼看终端。
顺带划一条边界:这篇只讲报告怎么产出、怎么呈现。压测结果怎么转成容量水位线、怎么接进弹性伸缩,是另一件事,不在这里展开。
一、三层各自回答什么问题
先把三层的分工定清楚,不然配出来的东西会互相重叠又互相缺口。
| 层级 | 载体 | 受众 | 时效 | 回答的问题 |
|---|---|---|---|---|
| 第一层 · 终端摘要 | stdout + summary.json + junit.xml |
跑测的人、CI 门禁 | 即时,秒级 | 这次跑没跑过?阈值破没破? |
| 第二层 · HTML 报告 | 自包含单文件 HTML | 开发、产品、跨团队评审 | 归档,随版本留存 | 这次压测的完整结论长什么样? |
| 第三层 · Grafana 看板 | Prometheus 时序库 | 性能负责人、值班 | 长期,可跨版本对比 | 这条曲线和上次比怎么样?拐点在哪一分钟? |

三层的判断标准很简单:第一层给机器读,第二层给人读一次,第三层给人反复读。
二、第一层:handleSummary,先把默认输出留住
k6 默认的终端摘要其实已经不错,但它有个致命特性——只在屏幕上出现一次,进程退出就没了。
handleSummary() 是官方给的收口点。测试结束后 k6 把所有指标聚合成一个 JS 对象交给它,你返回一个 {key: value} 映射,key 决定内容去哪:stdout、stderr,或者任意文件路径(会覆盖同名文件);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_p95、k6_http_req_duration_max 这类独立指标。
K6_PROMETHEUS_RW_STALE_MARKERS=true。默认最后一次 flush 之后指标还会「存活」5 分钟,压测结束了曲线还拖着一条平线,很多人以为系统仍在承压。打开它,测试结束时把时间序列标记为 stale,曲线干净收尾。
--tag testid=。没有它,多次压测的数据全混在同一条时间序列里,跨版本对比根本做不了。这是第三层最核心的一个约定。

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\"}}"

三处工程上的取舍:
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 结果现在还靠截图传,留言区聊聊卡在哪一层。