第 02 篇 · 脚本结构与生命周期:init、setup、default 与 teardown

你的脚本里每一行代码,整轮测试下来跑几次?说不上来的话,两个坑已经在前面等着了:把登录请求写在脚本顶层,想着「先登录一次,后面好用」,一跑直接报错;改完登录,又把计数器挂在顶层变量上,想着统计全轮订单数,跑出来数字怎么都对不上。
这两个坑是同一块石头:没搞清楚 k6 脚本的生命周期。教科书版本见 k6 官方文档的 Test lifecycle 章节:init(加载与初始化)→ setup(整轮一次的前置)→ default(反复迭代)→ teardown(整轮一次的收尾),外带摘要钩子 handleSummary。
这一篇把 5 个角色全部实跑一遍,5 个脚本、5 份终端输出,每个结论都是本机日志(Windows,k6 v2.2.0,Python 3.13.2,被测对象是第 01 篇的模拟工单服务);引用官方口径而未实测的,逐处标注。
学习目标与前置准备
读完你应该能:把文件加载、前置请求、迭代业务、收尾清理放进正确的阶段;说得清 setup 的数据怎么传到每个虚拟用户手里、哪些状态不会跨用户共享;看懂阶段日志,认出异常路径上每个阶段的下场。
前置准备:跑通第 01 篇的最小脚本和工单业务流;另起一个终端,在 examples/ 目录执行 python ticket_service.py,让模拟工单服务跑在 127.0.0.1:8898。本篇 5 个脚本都在 examples/02/ 目录执行。
口径先钉死:本篇全部读数来自本机回环低压力跟练,只证明「生命周期的顺序、次数与异常路径跑通」,不证明任何服务的性能与容量;被测服务每个请求固定加的 20 毫秒模拟耗时是写死在服务代码里的假设值,只为让读数有真实服务的形态。未实测的能力,本篇不宣称。
一、先看全貌:从 JMeter 到 k6 的整体对照
本篇的读者里,相当一批是从 JMeter 转过来的。先给 2 张整体对照图,把你手里的经验映射到 k6 的使用方式上,再逐节拆细节。定位说清:这是使用经验的映射,不是选型评测------选型对比是第 00 篇的事,本篇只负责让既有经验快速生效。

图 1:脚本与执行方式的经验映射。JMeter 前置线程组仍按线程数与循环配置执行;k6 setup 为可选前置钩子,init 会重复求值。

图 2:配置与收尾方式的经验映射,阶段次数与收尾行为须结合配置及异常路径判断。
图中的 XML 是可扩展标记语言;JMeter 全局属性可用 __setProperty 写入、__P 读取。Ramp-up 表示线程启动的爬升时长。
两行提醒。第一,对照图是上手的脚手架,执行语义有差异------比如前置失败时,JMeter 默认放行主流程,k6 直接整轮退出,第六节拆。第二,GUI 与代码的差别不只是手感:代码天然进 Git、进评审、进 CI(持续集成),这是本专栏以脚本为主线的原因。图中的生命周期 4 项对位,下面六节用本机实跑逐一兑现。
二、先看执行顺序,再看次数

图 1:正常路径示意,setup、teardown 为可选钩子;init 会随不同运行环境重复求值,图中位置不代表整轮只执行 1 次。
lifecycle.js(仓库 <examples/02/lifecycle.js> 有同一份)是本篇的主脚本:2 个虚拟用户合计跑 6 次迭代,setup 登录拿一次令牌,default 每迭代查一次工单列表,teardown 收尾确认令牌还在。完整脚本如下:
javascript
import http from 'k6/http';
import { check, sleep } from 'k6';
// 默认压本地模拟工单服务;换环境用 k6 run -e BASE_URL=... 覆盖,不改脚本。
const BASE = __ENV.BASE_URL || 'http://127.0.0.1:8898';
export const options = {
// 顶层 vus/iterations 是共享 iterations 执行器的快捷写法:
// 2 个虚拟用户合计跑 6 次迭代,每次迭代由哪个 VU 执行由运行时调度。
vus: 2,
iterations: 6,
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<500'],
},
};
// 注意:init 上下文只有 __VU 可取,__ITER 尚未定义、不能引用。
// __VU 在 init 里的取值分两种:脚本首次加载(无 VU 归属)为 0,
// VU 初始化求值时为该 VU 的编号(从 1 起);setup/teardown 上下文中恒为 0。
console.log(`[init] VU 初始化:init 上下文求值(当前 VU=${__VU})`);
export function setup() {
console.log('[setup] 全局一次:先登录拿令牌');
const res = http.post(
`${BASE}/login`,
JSON.stringify({ username: 'demo', password: 'demo123' }),
{ headers: { 'Content-Type': 'application/json' } },
);
const token = res.json('token');
console.log(`[setup] 登录状态=${res.status},令牌=${token}`);
return { token };
}
export default function (data) {
const res = http.get(`${BASE}/tickets`, {
headers: { Authorization: `Bearer ${data.token}` },
});
check(res, {
'tickets status is 200': (r) => r.status === 200,
'tickets total > 0': (r) => r.json('total') > 0,
});
console.log(`[default] VU${__VU} 第${__ITER + 1}次迭代`);
sleep(0.5);
}
export function teardown(data) {
console.log(`[teardown] 全局一次:令牌${data.token ? '已拿到' : '未拿到'}(${data.token})`);
}
逐段说。options 里 vus: 2, iterations: 6 是共享迭代执行器的快捷写法:2 个虚拟用户合计跑 6 次迭代,每次迭代由哪个 VU 执行由运行时调度,本节末尾要回来算账。第 4 行注释与第 01 篇同一口径,后文 4 个脚本不再重复。
顶层那行 console.log 打在 init 上下文:脚本一加载就求值,打印的 __VU 是虚拟用户编号------首次加载(还没有 VU 归属)为 0,VU 初始化求值时是该 VU 的编号,从 1 起编号;setup 与 teardown 上下文中恒为 0,三种形态在第二节的日志里能逐一对上。注释那句「__ITER 尚未定义、不能引用」(__ITER 是迭代编号)是本轮真实踩坑的留痕,细节在第三节。setup 向 /login 登录一次,return { token },这个返回值是全轮共享数据的入口,第四节拆它的契约。export default function (data) 里的 data 就是 setup 返回值的副本,令牌直接拼进 Authorization 头;teardown(data) 拿到的同样是副本。
跑起来(前置:服务已在跑):
bash
cd examples/02
k6 run lifecycle.js
终端输出按时间顺序读(节选,k6 启动 banner 与启动信息从略;原文逐字在 <examples/02/run-lifecycle-output.txt>):
text
time="2026-10-14T19:47:35+08:00" level=info msg="[init] VU 初始化:init 上下文求值(当前 VU=0)" source=console ← 第 1 次:脚本加载,banner 还没打印
scenarios: (100.00%) 1 scenario, 2 max VUs, 10m30s max duration (incl. graceful stop):
* default: 6 iterations shared among 2 VUs (maxDuration: 10m0s, gracefulStop: 30s)
time="2026-10-14T19:47:35+08:00" level=info msg="[init] VU 初始化:init 上下文求值(当前 VU=1)" source=console ← 第 2 次:VU 1 初始化
time="2026-10-14T19:47:36+08:00" level=info msg="[init] VU 初始化:init 上下文求值(当前 VU=2)" source=console ← 第 3 次:VU 2 初始化
time="2026-10-14T19:47:36+08:00" level=info msg="[init] VU 初始化:init 上下文求值(当前 VU=0)" source=console ← 第 4 次:setup 上下文
time="2026-10-14T19:47:36+08:00" level=info msg="[setup] 全局一次:先登录拿令牌" source=console
time="2026-10-14T19:47:36+08:00" level=info msg="[setup] 登录状态=200,令牌=demo-token-0001" source=console
time="2026-10-14T19:47:36+08:00" level=info msg="[default] VU1 第1次迭代" source=console
time="2026-10-14T19:47:36+08:00" level=info msg="[default] VU2 第1次迭代" source=console
time="2026-10-14T19:47:36+08:00" level=info msg="[default] VU1 第2次迭代" source=console
time="2026-10-14T19:47:36+08:00" level=info msg="[default] VU2 第2次迭代" source=console
running (00m01.0s), 2/2 VUs, 2 complete and 0 interrupted iterations
default [ 33% ] 2 VUs 00m00.9s/10m0s 2/6 shared iters
time="2026-10-14T19:47:37+08:00" level=info msg="[default] VU2 第3次迭代" source=console
time="2026-10-14T19:47:37+08:00" level=info msg="[default] VU1 第3次迭代" source=console
time="2026-10-14T19:47:37+08:00" level=info msg="[init] VU 初始化:init 上下文求值(当前 VU=0)" source=console ← 第 5 次:[teardown] 紧前
time="2026-10-14T19:47:37+08:00" level=info msg="[teardown] 全局一次:令牌已拿到(demo-token-0001)" source=console
time="2026-10-14T19:47:37+08:00" level=info msg="[init] VU 初始化:init 上下文求值(当前 VU=0)" source=console ← 第 6 次:[teardown] 紧后,摘要阶段
先数次数。[init] 全轮打印了 6 次:脚本加载 1 次(VU=0,banner 之前)→ VU 初始化 2 次(VU=1、VU=2)→ setup 上下文 1 次(VU=0,紧挨 [setup] 之前)→ teardown 上下文 1 次(VU=0,[teardown] 紧前)→ 摘要阶段 1 次(VU=0,[teardown] 紧后),时点见上面节选。第 6 次的来历:setup、teardown、收尾的摘要处理在 k6 里各建一次运行环境,init 代码随之各求值一遍(v2.2.0 源码里 runPart 与 HandleSummary 各自新建 VU;本机已用无收尾、有收尾、带 handleSummary 三种探针脚本复验,见实跑记录的「修订说明」一节)。[setup] 2 行、[teardown] 1 行,各全局一次;[default] 6 行,VU1、VU2 各 3 次。
结论钉死:init 的执行次数不可依赖 。「每个 VU 一次」的直觉,本机单进程这一轮就被打破了------6 次;按同一求值机制推断,云端脚本的加载点可能更多、次数还会往上走(官方文档脚注原话是「In cloud scripts, init code might be called more often」,措辞是可能而非必然,云端本篇未实测;分布式执行下 init 的求值次数官方无对应表述,本篇也不推断)。任何「跑几次」的逻辑都不许放进 init:别在顶层写计费相关的副作用,它会在你想不到的时刻多跑几遍;连一行 console.log 都会刷 6 遍。
交错顺序:VU1 第 1 次 → VU2 第 1 次 → VU1 第 2 次 → VU2 第 2 次 → VU2 第 3 次 → VU1 第 3 次。先交替、后收尾,由运行时调度决定,不保证对称------这一轮客气,第四节会看到它毫不客气的一面。
k6 侧的摘要(节选):
text
█ THRESHOLDS
http_req_duration
✓ 'p(95)<500' p(95)=46.7ms
http_req_failed
✓ 'rate<0.01' rate=0.00%
█ TOTAL RESULTS
checks_succeeded...: 100.00% 12 out of 12
✓ tickets status is 200
✓ tickets total > 0
http_req_duration..............: avg=37.18ms min=22.62ms med=40.35ms max=47.02ms p(90)=46.38ms p(95)=46.7ms
http_req_failed................: 0.00% 0 out of 7
http_reqs......................: 7 3.888214/s
iterations.....................: 6 3.332755/s
服务端这半边确认无误(<examples/02/ticket-service-console-02.log>,本轮新增节选):
text
127.0.0.1 - - [14/Oct/2026 19:47:36] "POST /login HTTP/1.1" 200 - ← setup 的登录
127.0.0.1 - - [14/Oct/2026 19:47:36] "GET /tickets HTTP/1.1" 200 - ← 6 次迭代各 1 条
127.0.0.1 - - [14/Oct/2026 19:47:36] "GET /tickets HTTP/1.1" 200 -
127.0.0.1 - - [14/Oct/2026 19:47:36] "GET /tickets HTTP/1.1" 200 -
127.0.0.1 - - [14/Oct/2026 19:47:36] "GET /tickets HTTP/1.1" 200 -
127.0.0.1 - - [14/Oct/2026 19:47:37] "GET /tickets HTTP/1.1" 200 -
127.0.0.1 - - [14/Oct/2026 19:47:37] "GET /tickets HTTP/1.1" 200 -
k6 侧 http_reqs=7,服务端恰好新增 7 条:1 条 POST /login 是 setup 发的,6 条 GET /tickets 是迭代发的。收尾进度行 default ✓ [ 100% ] 2 VUs 00m01.7s/10m0s 6/6 shared iters,退出码 0。对账直觉就是这么养成的:客户端说的数,服务端认不认。
业务落点:阶段次数表是排查「脚本怎么没按我想的跑」的第一张对照表------先数 init 多跑了几次,再核对 setup 是不是只跑一次,大部分灵异现象到这一步就现形了。
三、init 是准备脚本的地方,不是发请求的地方
第一个实验留下一个悬念:init 到底能干什么、不能干什么。不能干的第一名是发请求------不用猜,我替你试过,<examples/02/init_forbidden.js> 完整如下:
javascript
import http from 'k6/http';
// 默认压本地模拟工单服务;换环境用 k6 run -e BASE_URL=... 覆盖,不改脚本。
const BASE = __ENV.BASE_URL || 'http://127.0.0.1:8898';
console.log('[init] 尝试在 init 上下文直接发请求(教学演示,预期被 k6 拒绝)');
http.get(`${BASE}/`);
export default function () {
// default 是空函数:脚本在进入执行阶段前就会被 k6 拒绝。
}
bash
k6 run init_forbidden.js
输出一共 3 行,连启动 banner 都没来得及打印(逐字原文,完整见 <examples/02/run-init-forbidden-output.txt>):
text
time="2026-10-14T19:48:48+08:00" level=info msg="[init] 尝试在 init 上下文直接发请求(教学演示,预期被 k6 拒绝)" source=console
time="2026-10-14T19:48:48+08:00" level=error msg="GoError: Making http requests in the init context is not supported\n\tat go.k6.io/k6/v2/js/modules/k6/http.(*RootModule).NewModuleInstance.func2 (native)\n\tat file:///C:/Users/123/Downloads/Personal/7dgroup-ai/Tech-Features/columns/k6-mastery/examples/02/init_forbidden.js:7:9(27)\n" hint="script exception" source=stacktrace
退出码=107
两点值得认。第一,报错时机:服务端日志本轮新增 0 条,banner 也没打印------堆栈里那帧 (native) 说明这是 k6 的原生前置检查,在发起连接之前就抛出,不是连接被拒。第二,退出码 107:k6 把「脚本执行中抛异常」这一类失败归为 107,这个数后面还要出现两次;107 不是万能失败码,边界第六节收口时拆。
init 该干的事是这几样:导入模块、定义自定义指标、加载数据文件。官方文档给的禁用理由也值得记住:init 不许发请求,是为了保证这个阶段在任何环境下都可复现------协议响应是动态且不可预测的(Test lifecycle 章节原意)。JMeter 里有个现成对照:HTTP Request Defaults(HTTP 请求默认值)元件只提供配置默认值,自己从不发请求------发请求是 Sampler(取样器)在执行阶段的事,两边是同一个分工逻辑。最后一项给下一篇埋个钩子------下一篇讲请求与参数化,会演示用 SharedArray 在 init 阶段把数据池读进内存,全体虚拟用户共享一份只读副本。
再交代一个本轮真实踩过的坑。lifecycle.js 首跑时,init 那行除了 __VU 还引用了 __ITER(迭代编号)------init 上下文里没有这个变量,k6 直接抛 ReferenceError: __ITER is not defined,退出码 107,脚本没进入执行;改成只引用 __VU 后才跑出了第二节那轮。细节在实跑记录的「与预期不符」一节,首跑失败输出留存于会话记录,落盘文件以修正后为准。教训:init 上下文只有 __VU 可取,它在首次加载时为 0、VU 初始化求值时为该 VU 编号;__ITER 要到 default 阶段才有。
另一个高频误用也在这儿说清:顶层变量不跨 VU。VU1 改了顶层计数器,VU2 根本看不见------每个虚拟用户拿到的是自己那一份。想跨用户共享可变状态?k6 没给这条路(官方数据契约)------先记住这个结论,下一节的实验会解释,这条「走不通的路」恰恰是结果可信的保险带。
四、setup 的数据契约:给的是副本,不是原件
setup 的典型用法是整轮只做一次的事:登录拿令牌、查一次基础数据。lifecycle.js 就是「登录一次」的形态------第 01 篇 ticket_flow.js 注释里埋的钩子该兑现了:「登录一次全轮复用还是每次重来」的取舍,现在拆。
两种写法差在鉴权接口的压力形态。ticket_flow.js 每次迭代都登录:1 个虚拟用户 5 秒跑 5 次迭代,/login 挨 5 次打;2 个虚拟用户 10 次迭代就是 10 次。lifecycle.js 是 setup 登录一次、令牌副本全轮复用:6 次迭代下来 /login 只挨 1 次。选哪个,看鉴权接口本身是不是被测对象------要测登录的容量和延迟,登录就该进 default;登录只是拿一张进门的票,就放进 setup,别让它替你扛不属于它的压力。再提醒一句(超出本轮实测):真实系统的令牌会过期,全轮复用前先确认有效期盖得住整轮时长,盖不住就得设计刷新------本篇模拟服务的令牌写死在服务端,没有过期问题。

图 2:副本机制示意。副本实验中仅 VU2 执行了迭代,跨 VU 隔离依据官方数据契约,不作为本轮直接观测结论。
数据怎么从 setup 流到每个 VU 手里?规则一句话:给的是副本,不是原件 。<examples/02/setup_isolation.js> 完整如下:
javascript
// 默认压本地模拟工单服务;换环境用 k6 run -e BASE_URL=... 覆盖,不改脚本。
// 本实验纯内存演示,不发任何请求,BASE 仅与其他脚本保持同一契约。
const BASE = __ENV.BASE_URL || 'http://127.0.0.1:8898';
export const options = {
vus: 2,
iterations: 4,
};
console.log('[init] VU 初始化:本实验纯内存演示,不发请求');
export function setup() {
console.log('[setup] 构造共享数据:count=0');
return { count: 0 };
}
export default function (data) {
data.count++;
console.log(`[default] VU${__VU} 自己副本 count=${data.count}`);
}
export function teardown(data) {
console.log(`[teardown] teardown 拿到的 count=${data.count}`);
}
setup 返回 { count: 0 },default 里每个 VU 对自己的副本 data.count++,teardown 最后看拿到什么:
bash
k6 run setup_isolation.js
输出节选(banner 与摘要从略,原文逐字见 <examples/02/run-setup-isolation-output.txt>):
text
time="2026-10-14T19:48:07+08:00" level=info msg="[setup] 构造共享数据:count=0" source=console
time="2026-10-14T19:48:07+08:00" level=info msg="[default] VU2 自己副本 count=1" source=console
time="2026-10-14T19:48:07+08:00" level=info msg="[default] VU2 自己副本 count=2" source=console
time="2026-10-14T19:48:07+08:00" level=info msg="[default] VU2 自己副本 count=3" source=console
time="2026-10-14T19:48:07+08:00" level=info msg="[default] VU2 自己副本 count=4" source=console
time="2026-10-14T19:48:07+08:00" level=info msg="[teardown] teardown 拿到的 count=0" source=console
退出码=0
三个信息。第一,一个 VU 全包:VU2 的副本从 1 累到 4,VU1 的副本本轮根本没被打印------4 次迭代全被 VU2 抢走,VU1 一次没捞着。这正是第二节预告的落地:共享迭代由运行时抢占分发,不保证均分;lifecycle.js 那轮的 3:3 只是调度碰巧客气,这一轮没有 sleep、迭代又轻(纯内存,iteration_duration avg=254.24µs),一个 VU 可以连续拿完全部迭代。第二,改不回去:teardown 拿到的还是 setup 最初返回的 0,迭代里的修改不会回传。第三,跨 VU 互不看见:本轮直接观测到的是「VU2 自己的副本连续累加、改动不回传」;「两个用户的副本互不影响」本轮没有观测到------VU1 一次迭代都没执行,它的副本从未被触碰。这条靠官方数据契约兜底:每个 VU 拿到的是独立副本,跨 VU 没有共享可变状态这条路,几千个虚拟用户互不串数,结果才可信。想亲眼验证互不串数,要等两个 VU 都分到迭代------跟练任务 2 连跑几轮就有机会。
这张契约回指第一节对照表的「传值」一行:JMeter 走 __setProperty/__P 的间接通道,k6 收窄成一条明路------返回值复制成副本直接分发。官方文档的原话是,每个阶段、每个 VU 拿到的都是一份新鲜的「copy」:给你的那份随便改,别人手里的不受影响。
两个限制顺带记住:setup 返回的数据必须可序列化,函数、连接对象这类活对象传不过去;返回值会被整轮持有,数据量大小和令牌有效期一样,列进前置检查清单。再加一条官方口径:setup 不是无限期等你------它必须在 setupTimeout 内完成,默认 60 秒,超时则整轮在任何 VU 启动前失败(官方文档口径,超时路径本篇未实测)。
服务端对账这轮最简单:纯内存实验,0 请求------摘要里连 HTTP 小节都没有,NETWORK 小节逐字是 data_received........: 0 B 0 B/s;服务端日志新增 0 条,两边一致。
业务落点:压测数据互相污染,查出来的「业务异常」全是自己造的------这种假故障,值夜班的人都懂。副本契约就是你和业务数据之间的隔离带。
五、default:一次迭代不等于一次请求

图 3:主脚本正常完成时,6 次迭代各查 1 次工单,加上 setup 的 1 次登录,共 7 个请求;setup 请求不计入 default 迭代次数。
回到 lifecycle.js 的摘要,先把两个口径分开:
text
http_reqs......................: 7 3.888214/s
iterations.....................: 6 3.332755/s
7 个请求、6 次迭代------多出来的 1 个请求是 setup 的登录。迭代是业务回合的单位,请求只是动作:lifecycle.js 一次迭代是 1 个请求加 2 项检查,第 01 篇 ticket_flow.js 一次迭代是 4 个请求、5 项检查。迭代数 ≠ 请求数,读摘要、报数据,先把分母说清。
VU 之间也不齐步走:lifecycle.js 那轮 6 次迭代 3:3 交错,setup_isolation.js 这轮 4 次全被 VU2 抢走------两种形态都是 shared-iterations 执行器的合法行为。所以脚本逻辑别依赖「第几个 VU 会跑到这条分支」,想按用户分配数据,得自己设计编号规则,这条路下一篇讲参数化时落地。
sleep 是产出节奏的总开关。lifecycle.js 每次迭代末尾 sleep(0.5),本轮 iteration_duration avg=560.65ms------约等于 1 次请求 37ms、加 sleep 0.5 秒、再加运行时开销。sleep 是你设计的节奏,请求耗时是系统的表现,两笔账分开读:闭合模型里,单用户每秒产出 ≈ 1 / 迭代耗时,就是这么被压住的。本机回环的 37ms 只证明链路通,换真实网络服务没有可比性------真实读数重新测量,不在本篇预估范围。
官方文档还有一条与迭代直接相关的口径:VU 把 default 从头执行到尾、开始下一轮时,k6 会重置这个 VU------Cookie 清空,TCP 连接视配置可能被拆除(官方口径,本篇未单独实测)。本篇脚本把令牌放在 setup 返回的 data 副本里、每次迭代手工拼进请求头,不依赖 CookieJar(k6 的 Cookie 会话容器),不受这条影响;如果你的脚本依赖 Cookie 会话保持,迭代间的重置行为要先排进验证清单------会话是不是每个迭代都重来一遍,到下一篇讲请求与会话时再实测。
六、异常路径:收尾不是保证书
teardown 管收尾,而且不止正常结束才轮到它------主动中止也不拦它:官方 k6/execution 的口径是「中止测试不会阻止 teardown() 执行」,本机探针在 default 里调 test.abort() 复验,teardown 照常打印、整轮以 108 退出(探针 E,见探针记录)。真正等不到它的,是 setup 异常结束这类路径。第三个实验:setup 里用错误密码登录,拿到 401 之后主动抛错。脚本 <examples/02/setup_failure.js> 完整如下:
javascript
import http from 'k6/http';
// 默认压本地模拟工单服务;换环境用 k6 run -e BASE_URL=... 覆盖,不改脚本。
const BASE = __ENV.BASE_URL || 'http://127.0.0.1:8898';
export const options = {
vus: 1,
iterations: 1,
};
export function setup() {
console.log('[setup] 用错误密码登录,预期拿到 401');
const res = http.post(
`${BASE}/login`,
JSON.stringify({ username: 'demo', password: 'wrong-password' }),
{ headers: { 'Content-Type': 'application/json' } },
);
console.log(`[setup] 登录状态=${res.status},准备抛出异常`);
throw new Error('鉴权失败示意');
}
export default function () {
// setup 抛错后 default 不会执行,本行不应出现在输出里。
console.log('[default] 本行不应出现');
}
export function teardown() {
// setup 抛错后 teardown 不会执行,本行不应出现在输出里。
console.log('[teardown] 本行不应出现');
}
bash
k6 run setup_failure.js
这轮 banner 正常打印------对比第 4 轮(实跑记录第 4 轮):脚本加载没问题,是 setup 执行期才炸。setup 的两行日志先落地:
text
time="2026-10-14T19:48:21+08:00" level=info msg="[setup] 用错误密码登录,预期拿到 401" source=console
time="2026-10-14T19:48:21+08:00" level=info msg="[setup] 登录状态=401,准备抛出异常" source=console
然后是报错原文(完整见 <examples/02/run-setup-failure-output.txt>):
text
time="2026-10-14T19:48:24+08:00" level=error msg="Error: 鉴权失败示意\n\tat setup (file:///C:/Users/123/Downloads/Personal/7dgroup-ai/Tech-Features/columns/k6-mastery/examples/02/setup_failure.js:19:9(39))\n" hint="script exception" source=stacktrace
退出码=107
default 和 teardown 的下场:进度条停在 Run [ 100% ] setup()、default [ 0% ],输出里没有任何 [default]/[teardown] 行------setup 挂了,teardown 不会替你擦桌子 。k6 还是打印了部分摘要(http_reqs=1、http_req_failed=100.00% 1 out of 1),随后以 107 退出。服务端日志本轮新增 1 条 "POST /login HTTP/1.1" 401 -,与 k6 侧一致------服务端代码里密码不对就是 {"error": "invalid credentials"} 加状态码 401,见 <examples/ticket_service.py> 的 /login 分支。官方文档对此的口径与本篇实测一致:setup 异常结束,teardown 不会被调用(Test lifecycle 章节脚注)。
对照 JMeter 的默认行为,差异更好记:setUp Thread Group 里取样器出错,线程组「取样器错误后的动作」默认是 Continue------后面的普通线程组照常带病跑。k6 是另一个方向的严格:setup 一炸,整轮以 107 退出,default 一次不跑。两种设计没有高下,但排查时脑子里要有这张对照表:JMeter 里要防的是「前置没成、主流程照跑」造出的假数据,k6 里要自己补的是「setup 挂了、没人收尾」的清理预案。
凑齐三类脚本异常,本机实测退出码同为 107:init 求值期 ReferenceError(首跑 lifecycle.js)、setup 抛错、init 禁发请求。看到 107,先问「哪个阶段炸了」,再看堆栈第一行。边界钉住:107 只覆盖「脚本执行中抛异常」这一类------default 迭代里抛错只中断当次迭代、整轮照常跑完,本机用 3 次迭代逐次抛错的探针实测退出码为 0(v2.2.0,见实跑记录的「修订说明」一节);setup/teardown 超时(100/101)、阈值失败(99)另各有退出码(官方 exit codes 定义,本篇未实测);test.abort() 主动中止以 108 收场、且不拦 teardown------本机探针 E 实测(探针记录)。

图 4:结果限定于本篇脚本与探针配置;后 2 行在 default 中触发,迭代内抛错探针未设置失败阈值。退出码不替代业务判定。
工程上的落法只有一条:清理不能只押在 teardown 一个钩子上------setup 异常结束时它根本不会被调用。setup 里创建资源前先记一笔登记,清理设计成幂等------重复执行不报错、不重复删;测试中断后,拿着登记簿和服务端日志对账,谁建的、删了没有,一目了然。本篇 5 个脚本全程只对模拟服务的登录与查询接口发请求、数据都放在内存里,没对真实业务环境做过任何删除动作,这条红线不用替你试。顺带一个调试期的官方开关:想跳过这两个阶段直接调 default,可以用 k6 run --no-setup --no-teardown(官方文档口径,本篇未实测)。
七、handleSummary:收尾时的汇总入口
最后一个角色 handleSummary:整轮跑完,指标汇总成 data 交给它,它返回什么,终端和磁盘上就长出什么(官方定义见 Custom summary 章节,2026-10-15 核验:handleSummary 在生命周期末尾被调用,返回对象的键决定输出位置------stdout(标准输出)、stderr(标准错误输出)或文件路径)。脚本 <examples/02/summary_hook.js> 完整如下:
javascript
import http from 'k6/http';
import { check } from 'k6';
// 默认压本地模拟工单服务;换环境用 k6 run -e BASE_URL=... 覆盖,不改脚本。
const BASE = __ENV.BASE_URL || 'http://127.0.0.1:8898';
export const options = {
vus: 1,
iterations: 2,
};
export default function () {
const res = http.get(`${BASE}/`);
check(res, { 'home status is 200': (r) => r.status === 200 });
}
export function handleSummary(data) {
const iter = data.metrics.iterations.values.count;
const reqs = data.metrics.http_reqs.values.count;
const checkVals = data.metrics.checks.values;
const p95 = data.metrics.http_req_duration.values['p(95)'];
console.log(`[summary] 迭代数=${iter}`);
const text = [
'[summary] 自拼摘要(默认摘要已被 handleSummary 的 stdout 键替换)',
`迭代次数=${iter},HTTP 请求数=${reqs},检查通过=${checkVals.passes} 失败=${checkVals.fails}`,
`http_req_duration p(95)=${p95}ms`,
].join('\n');
return {
// 取舍说明:summary.json 只保留 metrics 里 iterations/http_reqs/checks/
// http_req_duration 4 个字段,避免把全量 data 序列化成超大文件;
// 自拼 stdout 文本不依赖 https://jslib.k6.io 远程导入,全程离线可跑。
'summary.json': JSON.stringify(
{
iterations: data.metrics.iterations,
http_reqs: data.metrics.http_reqs,
checks: data.metrics.checks,
http_req_duration: data.metrics.http_req_duration,
},
null,
2,
),
stdout: text,
};
}
它做了两件事:返回对象的 summary.json 键把 4 个指标字段落盘(取舍写在脚本注释里),stdout 键把终端摘要整份换掉。
bash
k6 run summary_hook.js
两件事都在这轮输出里。第一,默认摘要被整体替换------THRESHOLDS、TOTAL RESULTS 完全不再出现,终端只剩自拼的 3 行(完整见 <examples/02/run-summary-hook-output.txt>)。官方口径与本篇实测一致:只要导出了 handleSummary,k6 就不再打印默认摘要;想保留默认排版,官方给的路线是引入 jslib 的 textSummary------那是远程导入、需要联网,本篇脚本自拼 stdout 正是为了让跟练全程离线可跑:
text
[summary] 自拼摘要(默认摘要已被 handleSummary 的 stdout 键替换)
迭代次数=2,HTTP 请求数=2,检查通过=2 失败=0
http_req_duration p(95)=26.014595ms
一个口径提醒:自拼摘要里的 p(95)=26.014595ms 是从指标对象里取的原始浮点,默认摘要会显示两位小数(26.01ms)------同一轮数据,呈现精度不同,引用时先说清口径。第二,summary.json 真实落盘(681 B,<examples/02/summary.json>),节选:
json
"iterations": {
"type": "counter",
"contains": "default",
"values": {
"count": 2,
"rate": 22.787296537926036
}
},
......(中间略去 http_reqs 与 checks 两个字段)
"http_req_duration": {
"type": "trend",
"contains": "time",
"values": {
"min": 25.4692,
"med": 25.75625,
"max": 26.0433,
"p(90)": 25.985889999999998,
"p(95)": 26.014595,
"avg": 25.75625
}
}
时序上有个细节值得认:[summary] 迭代数=2 这行出现在场景描述之后、收尾进度行(running (00m00.1s), 0/1 VUs, 2 complete and 0 interrupted iterations)之前------handleSummary 在全轮指标收齐之后执行。服务端对账:本轮 2 个请求,服务端日志新增 2 条 "GET / HTTP/1.1" 200 -,退出码 0。
定位说清:handleSummary 是数据汇总的入口,teardown 是环境清理的出口,两码事别混用。拿它定制 HTML 报告、对接输出后端,是后面「结果输出与报告」那篇的正经活,本篇只立入口。
本篇 3 个跟练任务
- 主练:在
examples/02/目录跑k6 run lifecycle.js,把终端日志按 init/setup/default/teardown 归类成一张阶段次数表(init 各次出现在哪个时点、setup 和 teardown 各几次、6 次迭代怎么交错),再核对服务端日志新增条数与http_reqs是否对账。留记录:阶段次数表 + 双侧对账结果。 - 对照:跑
k6 run setup_isolation.js,观察 4 次迭代落在哪个 VU、count 各自怎么变、teardown 拿到什么;连跑 2---3 轮,看迭代分发形态变不变(本轮两个脚本各见过一种:lifecycle.js 6 次迭代 3:3 交错,setup_isolation.js 4 次迭代被 VU2 全包)。留记录:每轮的迭代归属与副本值,用自己的话解释为什么互不影响。 - 迁移练习:把第 01 篇 ticket_flow.js 的登录挪进 setup(令牌经返回值分发给每个 VU,全轮复用),对照两种写法服务端 /login 的请求条数;再给 setup 补一份失败预案(登记 + 幂等清理),写清每步动作。留记录:改动前后脚本、服务端日志条数对比、预案说明。