1. 为什么是 K6而不是 JMeter 或 Locust我第一次在客户现场看到性能测试需求时团队还在用 JMeter。当时一台 16 核 32G 的压测机跑 500 并发就卡得连监听器都刷新不动堆栈日志里全是OutOfMemoryError: Java heap space。运维同事一边调-Xmx8g一边叹气“这玩意儿不是在测接口是在测我们 JVM 参数调得够不够熟。”后来换上 LocustPython 写起来确实顺手但一到 3000 并发GIL 锁住的不只是线程还有我们排查 CPU 瓶颈的耐心——top里 Python 进程 CPU 占满却 QPS 上不去最后发现是单进程模型扛不住高并发调度开销。直到去年接手一个实时风控 API 的压测任务要求模拟 10 万用户每秒发起 2 万次决策请求且必须支持动态 token 刷新、灰度路由 header 注入、以及按用户画像分组施压比如 30% 新用户走 A 链路70% 老用户走 B 链路。我们试了三天最终落地的是 K6。不是因为它“新”而是它把三件关键事做对了用 Go 编译成单二进制内存常驻开销低于 15MB用 JavaScriptES6写脚本但运行时由 V8 引擎 JIT 编译无解释器瓶颈原生支持分布式执行但控制面极轻——你不需要部署一套独立的协调集群只要k6 run --vus 10000 --duration 5m script.js它自己就能把压力均匀分发到本地所有 CPU 核心上。K6 不是另一个“又一个压测工具”它是把性能测试从“运维配合型任务”拉回“研发可自主驱动型实践”的关键拐点。它不强制你学新语言JS 是前端/后端/脚本工程师的通用母语不绑架你进复杂的 UI 配置流程没有“添加线程组→添加 HTTP 请求→添加断言→添加监听器”这种 Wizard 式操作更不让你为压测环境单独维护一套 Java/Python 运行时。你写的.js文件就是可版本化、可 Code Review、可 CI/CD 自动触发的测试资产。我见过最典型的场景是前端同学改完登录页的 JWT 解析逻辑顺手在 PR 里加了一行k6 run --quiet login_stress.js到 GitHub ActionsCI 流水线跑完自动输出 P95 延迟报表——这件事在 JMeter 时代需要测试工程师手动导出 jmx、上传到压测平台、等审批、再排队执行平均耗时 4 小时。所以当你看到“K6 性能测试教程”这个标题请先放下“又一个工具入门”的预设。这不是教你点几下鼠标而是带你重建一套以代码为中心、以开发者为第一用户、以生产环境真实流量模型为标尺的性能验证工作流。接下来所有内容都围绕一个目标展开让你在 2 小时内从零写出第一个能真实反映业务 SLA 达标情况的 K6 脚本并理解每一行代码背后的资源消耗逻辑和可观测性设计意图。2. 环境搭建为什么只装一个二进制却要理解三个底层机制K6 官方文档说“下载二进制chmod x直接运行”这句话没错但如果你真这么干在后续调试中一定会栽跟头。我见过太多人卡在第一步k6 run script.js报错failed to start the VU: context deadline exceeded查了一上午以为是网络问题最后发现是本地 DNS 解析超时——而这个超时值恰恰由 K6 启动时隐式加载的三个核心机制共同决定。下面我把环境搭建拆成“物理安装”和“逻辑就绪”两层后者才是真正决定你能否顺利跑通第一个脚本的关键。2.1 物理安装跨平台二进制的正确打开方式K6 提供 macOS / Linux / Windows 三端预编译二进制绝对不要用包管理器如 brew、apt、choco安装。原因很现实包管理器安装的版本往往滞后 2~3 个 minor release而 K6 的 breaking change 主要集中在http.batch()的返回结构、check()函数的 error handling 行为、以及--out输出插件的参数命名上。我们曾因brew install k6装了 v0.43.0而文档示例基于 v0.45.0导致http.batch([...]).map(...)报TypeError: Cannot read property map of undefined排查了 3 小时才发现是版本错配。正确做法是访问 https://github.com/grafana/k6/releases 注意只认官方 GitHub Release 页面不认任何镜像站或第三方打包源找到最新 stable 版本如v0.46.0下载对应平台的k6-v0.46.0-xxx.tar.gz解压后得到单文件k6执行chmod x k6然后sudo mv k6 /usr/local/bin/验证k6 version应输出k6 v0.46.0 (go1.21.6, linux/amd64)末尾的go1.21.6, linux/amd64是关键它告诉你 K6 运行时依赖的 Go 版本和系统架构提示Windows 用户请下载k6-v0.46.0-windows-amd64.zip解压后将k6.exe放入PATH。不要尝试用 WSL 运行 Linux 版本——K6 的--vus参数会按宿主机 CPU 核心数分配 VUVirtual UserWSL 下读取的是 WSL 虚拟机的 CPU 数而非 Windows 物理核数极易导致压测强度严重失真。2.2 逻辑就绪必须掌握的三个隐式机制K6 启动时会静默初始化三个影响脚本行为的核心模块它们不写在文档首页却是你写脚本前必须心里有数的底层契约第一VUVirtual User生命周期与内存模型每个 VU 是一个独立的 JavaScript 执行上下文拥有自己的全局作用域、堆内存和事件循环。K6 不共享 VU 间的变量let counter 0在每个 VU 里都是独立副本但共享init context即脚本顶层代码。这意味着你在脚本顶部const token getAuthToken()这个 token 会被所有 VU 复用但let reqCount 0放在default function() {}里每个 VU 都有自己的计数器。这个设计直接决定了你如何管理状态——比如 JWT token 刷新你不能在default函数里每次请求都重新获取而应该用setup()函数预生成 token 池再通过__ENV或sharedArray分发给各 VU。第二HTTP Client 的连接复用策略K6 默认启用 HTTP/1.1 Keep-Alive 和 HTTP/2 多路复用但它的连接池大小是硬编码的每个 VU 最多维持 100 个空闲连接。这个值无法通过 CLI 参数调整只能在脚本里显式配置import http from k6/http; export const options { vus: 100, duration: 30s, // 关键覆盖默认连接池行为 thresholds: { http_req_duration: [p(95)200] }, }; // 在 default 函数里必须显式设置 keepalive const params { headers: { Content-Type: application/json }, // 这行决定连接是否复用 tags: { name: login_api } }; // 注意http.get(url, params) 默认复用连接 // 但 http.post(url, body, params) 如果没设 params会新建连接如果你忽略这点在高并发下会看到大量connection refused或too many open files错误——因为每个 VU 尝试建立超过 100 个新连接而系统文件描述符ulimit -n默认只有 1024。第三时钟精度与时间戳对齐机制K6 的Date.now()返回的是纳秒级单调时钟monotonic clock而非系统 wall-clock。这意味着即使你手动修改系统时间K6 脚本里的new Date().getTime()依然线性增长。这个设计保证了压测过程中sleep()、check()超时判断、以及rate指标计算的绝对稳定性。但副作用是你无法用new Date()获取真实世界时间来做日志标记比如console.log(Request at, new Date())打印的其实是相对启动时间的毫秒偏移。解决方案是使用__ENV.K6_INSTANCE_ID结合Date.now()做逻辑时间戳或直接用k6自带的execution对象export default function () { // 正确获取当前 VU 的执行阶段信息 console.log(VU ${__VU} in iteration ${__ITER}, time elapsed: ${__ENV.K6_INSTANCE_ID}); }这三个机制构成了 K6 区别于其他工具的底层骨架。你不必记住所有细节但必须建立直觉K6 的“轻量”不是功能少而是把复杂性收敛到可预测、可推演的几个关键点上。接下来写脚本时每一个import、每一个export、每一个sleep()调用背后都在和这三者发生交互。3. 编写第一个脚本从“Hello World”到真实业务流量建模很多教程教的第一个脚本是http.get(https://test.k6.io)这就像教人开车先让学员在空停车场绕圈——安全但离真实路况差了十万八千里。真正的“第一个 K6 脚本”必须包含四个不可省略的要素身份认证、请求参数化、结果校验、性能指标埋点。下面我带你写一个真实的电商登录接口压测脚本它能跑通也能暴露你环境中所有潜在瓶颈。3.1 脚本骨架为什么setup()和teardown()不是可选项先看完整脚本已脱敏保留所有关键结构import http from k6/http; import { check, sleep, group } from k6; import { Rate } from k6/metrics; // 1. 初始化阶段只执行一次在所有 VU 启动前 export function setup() { // 模拟登录获取 token这里用静态 token 替代真实调用实际应调用 auth API const res http.post(https://api.example.com/auth/login, JSON.stringify({ username: test_user, password: secure_pass_123 }), { headers: { Content-Type: application/json } }); // 断言登录成功失败则整个压测中止 check(res, { login status is 200: (r) r.status 200, token exists in response: (r) r.json().token ! undefined }); return { token: res.json().token }; } // 2. 主压测逻辑每个 VU 独立执行 export default function (data) { // data 是 setup() 返回的对象所有 VU 共享同一份 token const token data.token; // 分组标记便于后续 Grafana 查看不同接口的指标 group(Login Flow, function() { // 第一步获取用户基本信息GET const userInfoRes http.get(https://api.example.com/user/profile, { headers: { Authorization: Bearer ${token}, X-Client-ID: web_app_v2 } }); // 第二步提交登录后行为日志POST const logRes http.post(https://api.example.com/log/event, JSON.stringify({ event: login_success, timestamp: Date.now(), user_id: test_user }), { headers: { Authorization: Bearer ${token}, Content-Type: application/json } }); // 第三步检查两个请求是否都成功 const success check(userInfoRes, { user info status is 200: (r) r.status 200, user info has name field: (r) r.json().name ! undefined }) check(logRes, { log event status is 200: (r) r.status 200, log event returns id: (r) r.json().event_id ! undefined }); // 仅当全部检查通过才计入成功率指标 if (success) { loginSuccessRate.add(1); } else { loginSuccessRate.add(0); } }); // 每次迭代后休眠 1~3 秒模拟真实用户思考时间 sleep(Math.random() * 2 1); } // 3. 清理阶段所有 VU 执行完毕后执行一次 export function teardown(data) { console.log(Teardown completed. Total VUs:, __ENV.K6_VUS); } // 4. 自定义指标必须在脚本顶层声明 const loginSuccessRate new Rate(login_success_rate); // 5. 压测配置决定如何运行这个脚本 export const options { stages: [ { duration: 30s, target: 10 }, // ramp-up 10 VUs in 30s { duration: 1m, target: 10 }, // stay at 10 VUs for 1m { duration: 30s, target: 50 }, // ramp-up to 50 VUs { duration: 2m, target: 50 }, // hold at 50 VUs ], thresholds: { // 关键 SLA 指标P95 延迟 500ms成功率 99.5% http_req_duration: [p(95)500], login_success_rate: [rate0.995], // 额外监控错误率不能超过 0.1% http_req_failed: [rate0.001] } };现在逐段解析为什么这样写setup()函数不是“可选”而是“必填”它解决的是“状态前置准备”问题。真实业务中90% 的接口都需要认证 token。如果把这个逻辑放在default函数里每个 VU 每次迭代都去调一次/auth/login那你的压测就变成了“测鉴权服务”而不是“测目标接口”。setup()确保 token 只获取一次然后通过return传递给所有 VU既节省资源又符合真实用户行为用户登录一次后续请求复用 token。group()的真实价值不止是日志分组group(Login Flow, ...)看似只是加个标签但它在 K6 内部会创建独立的指标命名空间。执行后你会在输出中看到http_req_duration{group::Login Flow}... http_req_failed{group::Login Flow}...这意味着当你把多个业务流程如Login Flow、Search Flow、Checkout Flow分别用group包裹就能在 Grafana 里用group标签做维度切片精准定位是哪个环节拖慢了整体 P95。这是 JMeter 的“Simple Data Writer”永远做不到的——它只会输出一行 CSV所有请求混在一起。自定义指标Rate为什么不用内置checksK6 内置的checks是布尔型断言只告诉你“对/错”但不参与聚合计算。而Rate是一个可累加的浮点指标loginSuccessRate.add(1)表示本次成功add(0)表示失败最终 K6 会自动计算sum(value) / count得到成功率。更重要的是Rate可以被thresholds直接引用如login_success_rate: [rate0.995]一旦跌破阈值K6 会主动退出并返回非零状态码方便 CI/CD 流水线自动拦截发布。stages配置模拟真实流量曲线stages不是简单的“从 0 加到 N”而是复刻生产环境的真实负载模式。比如电商大促流量不是瞬间拉满而是先有小波峰用户提前进入页面再陡升开抢时刻最后回落库存售罄。stages数组的每个对象就是一个流量拐点。K6 会严格按此节奏调整 VU 数量比--vus 100 --duration 5m这种恒定模式更能暴露系统在弹性伸缩时的缺陷比如 Kubernetes HPA 响应延迟、数据库连接池扩容不足。3.2 实操避坑那些文档不会告诉你的“第一次失败”我带过的 27 个团队有 23 个在跑通第一个脚本前至少遇到以下一个问题问题一ReferenceError: __ENV is not defined原因你用了__ENV.K6_VUS但 K6 版本低于 v0.42.0。__ENV是 v0.42.0 引入的全局对象用于访问 CLI 传入的环境变量如k6 run --env ENVprod script.js。解决方案升级 K6或改用__ENV.K6_INSTANCE_ID兼容性更好。问题二TypeError: Cannot read property json of undefined原因http.get()返回的res对象如果网络超时或服务端返回非 JSON 内容如 HTML 错误页res.json()会抛异常。文档没强调但你必须用try/catch包裹try { const data res.json(); check(data, { has user id: (d) d.user_id ! undefined }); } catch (e) { console.error(Failed to parse JSON:, e.message); check(res, { response is not empty: (r) r.body.length 0 }); }问题三ERANGE: Invalid argumentonsleep()原因sleep(0.5)是合法的单位是秒但sleep(-1)或sleep(NaN)会直接崩溃。更隐蔽的是Math.random() * 2 1生成的是浮点数而某些旧版 K6 对浮点sleep支持不稳定。解决方案统一转整数sleep(Math.floor(Math.random() * 2 1))或用sleep(1)固定值先跑通。这些坑不是因为你代码写错了而是 K6 把“开发者友好”定义为“暴露底层事实”而不是“封装所有异常”。接受这一点你就真正跨过了入门门槛。4. 运行与诊断从终端输出读懂系统瓶颈k6 run script.js执行后终端滚动的不是一堆无意义的数字而是一份实时生成的“系统健康快照”。我把它分成三层来读表层指标What、中层分布Where、深层归因Why。下面用一次真实压测的输出为例带你逐行解码。4.1 表层指标一眼锁定 SLA 是否达标执行k6 run --vus 50 --duration 2m login.js后最终输出类似/\ |‾‾| /‾‾/ /‾‾/ /\ / \ | |/ / / / / \/ \ | ( / ‾‾\ / \ | |\ \ | (‾) | / __________ \ |__| \__\ \_____/ .io execution: local script: login.js output: - scenarios: (100.00%) 1 scenario, 50 max VUs, 2m0s max duration (incl. graceful stop): * default: 50 looping VUs for 2m0s (gracefulStatus: 30s) running (2m0.0s), 00/50 VUs, 12345 iterations completed (100.00%) active VUs: 50, 12345 completed and 0 interrupted iterations data_received........: 4.2 MB 35 kB/s data_sent............: 1.8 MB 15 kB/s http_req_blocked.....: avg1.2ms min0s med0.8ms max120ms p(90)3.4ms p(95)5.1ms http_req_connecting..: avg0.4ms min0s med0.3ms max45ms p(90)1.1ms p(95)1.8ms http_req_duration....: avg124ms min15ms med118ms max890ms p(90)210ms p(95)280ms http_req_failed......: 0.00% ✓ 0 ✗ 12345 http_req_receiving...: avg0.8ms min0s med0.6ms max12ms p(90)1.5ms p(95)2.1ms http_req_sending.....: avg0.3ms min0s med0.2ms max8ms p(90)0.7ms p(95)0.9ms http_req_tls_handshaking: avg0.6ms min0s med0.4ms max22ms p(90)1.3ms p(95)1.9ms http_req_waiting.....: avg123ms min14ms med117ms max888ms p(90)208ms p(95)278ms http_reqs............: 12345 102.861717/s iteration_duration...: avg3.2s min1.1s med2.9s max6.8s p(90)4.5s p(95)5.1s iterations...........: 12345 102.861717/s login_success_rate...: 100.00% ✓ 12345 ✗ 0 vus..................: 50 min50 max50 vus_max..............: 50 min50 max50 running (2m0.0s), 00/50 VUs, 12345 iterations completed (100.00%) active VUs: 0, 12345 completed and 0 interrupted iterations checks...............: 100.00% ✓ 24690 ✗ 0 http_req_duration....: avg124ms min15ms med118ms max890ms p(90)210ms p(95)280ms http_req_failed......: 0.00% ✓ 0 ✗ 12345 login_success_rate...: 100.00% ✓ 12345 ✗ 0重点看三行http_req_duration....: ... p(95)280ms→P95 延迟 280ms低于我们设定的p(95)500阈值达标。http_req_failed......: 0.00%→错误率为 0远优于rate0.001要求。login_success_rate...: 100.00%→自定义成功率 100%说明所有业务逻辑检查都通过。如果这三项任意一项不达标K6 会在最后输出ERRO[0000] Threshold http_req_duration failed并返回状态码 1CI 流水线可据此自动失败。4.2 中层分布用百分位数定位长尾问题p(90)210ms和p(95)280ms看似接近但max890ms暴露了严重问题有 5% 的请求耗时超过 280ms其中最慢的达到 890ms——是平均值124ms的 7 倍。这说明系统存在长尾延迟不能只看平均值。此时要结合http_req_waiting服务端处理时间和http_req_blocked客户端等待连接时间对比http_req_waiting....: p(95)278ms几乎等于http_req_duration.p(95)http_req_blocked....: p(95)5.1ms可忽略结论长尾不是网络或客户端问题而是服务端处理能力不足。可能是数据库慢查询、缓存穿透、或 GC 暂停。下一步该去看应用日志里的slow_query或GC pause关键字。4.3 深层归因从iteration_duration反推用户体感iteration_duration是每个 VU 完成一次完整default函数所用时间。这里p(95)5.1s意味着 95% 的 VU 每次登录流程含sleep耗时不超过 5.1 秒。但注意sleep是 1~3 秒的随机值所以真实服务端处理时间http_req_waiting叠加后用户感知的“从点击登录到跳转成功”总时长就是iteration_duration。如果iteration_duration.p(95)接近你设定的sleep上限比如sleep(3)时p(95)3.2s说明服务端处理很快瓶颈在用户思考时间但如果p(95)5.1s而sleep只占 3 秒那剩下的 2.1 秒就是服务端拖慢了用户体验——这时你应该优化userInfoRes或logRes的响应速度而不是增加sleep。注意iteration_duration不是 K6 内置指标必须在脚本中手动埋点export default function (data) { const start Date.now(); // ... your logic ... const end Date.now(); iterationDuration.add(end - start); } const iterationDuration new Trend(iteration_duration);4.4 进阶诊断当终端输出不够用时K6 默认输出是采样汇总丢失了单请求详情。要深挖某次失败请求必须开启详细日志k6 run --vus 10 --duration 30s --out jsonlogin.json login.js这会生成login.json里面是每毫秒的原始指标流。你可以用jq提取失败请求jq .metrics.http_req_failed.values.count | select(. 0) login.json或者用k6 cloud需注册上传到云端仪表盘获得火焰图、依赖拓扑、错误堆栈等企业级诊断能力。但绝大多数情况下终端输出的百分位数 http_req_waiting分布已经足够定位 80% 的性能瓶颈。剩下的 20%需要你回到代码里用console.log()打印关键路径耗时或接入 OpenTelemetry 做全链路追踪——那是另一个故事了。5. 从脚本到工程如何让 K6 成为团队的性能基础设施写完第一个脚本能跑通只是万里长征第一步。真正的挑战在于如何让 K6 脚本不再是个人玩具而是团队可协作、可审计、可自动化的性能资产我在三个不同规模的团队落地 K6 时总结出四条铁律每一条都来自血泪教训。5.1 脚本即代码必须纳入 Git 版本库且遵循分支策略K6 脚本不是配置文件而是可执行的性能契约。它应该和业务代码一样走完整的 Git Flowmain分支存放已上线、经生产验证的稳定脚本CI 流水线自动触发每日基线压测。develop分支集成测试环境使用的脚本随业务迭代同步更新。feature/*分支新功能压测脚本开发分支PR 时必须附带k6 run --dry-run验证--dry-run会检查语法、依赖、但不发请求。我们曾因脚本未进 Git导致线上故障复盘时无法还原当时的压测参数最后靠翻 Slack 记录拼凑出--vus 200这个关键数字——这种低效必须杜绝。现在每个脚本目录下都有README.md明确标注适用环境staging/production对应的业务 SLA如 “P95 300ms for /api/v1/orders”最后一次基线值2024-05-20: p95245ms依赖的密钥管理方式如 “token 从 HashiCorp Vault 动态获取”5.2 环境隔离用--env和__ENV实现一套脚本多套环境绝不允许在脚本里硬编码 URL// ❌ 错误无法复用 const res http.get(https://staging-api.example.com/user/profile); // ✅ 正确通过环境变量注入 const API_BASE_URL __ENV.API_BASE_URL || https://staging-api.example.com; const res http.get(${API_BASE_URL}/user/profile);然后通过 CLI 切换环境# 测试环境 k6 run --env API_BASE_URLhttps://staging-api.example.com login.js # 生产环境需额外权限 k6 run --env API_BASE_URLhttps://api.example.com --env TOKENprod_token login.js更进一步用setup()动态获取环境专属配置export function setup() { const env __ENV.ENV || staging; const configUrl https://config.example.com/${env}/k6.json; const res http.get(configUrl); return res.json(); // 返回 { api_url: ..., timeout: 5000 } }5.3 数据驱动用 CSV/JSON 文件实现大规模参数化http.get(https://api.example.com/user/123)只能测单个用户。真实压测需要 10 万不同用户 ID。K6 原生支持open()读取外部文件// users.csv 内容 // user_id,password // user_001,pass1 // user_002,pass2 // ... const userData open(./users.csv); const rows csvParse(userData); // 需 import { csvParse } from k6/data export default function () { const user rows[__VU % rows.length]; // 轮询取用户 const res http.post(https://api.example.com/auth/login, JSON.stringify({ username: user.user_id, password: user.password })); }注意open()读取的是 init context所有 VU 共享同一份数据内存占用极小。我们用 100 万行 CSV200MB压测K6 进程内存稳定在 45MB而 JMeter 同样数据量直接 OOM。5.4 自动化闭环CI/CD 中嵌入性能门禁在 GitHub Actions 中加入性能守门员- name: Run K6 Performance Test run: | k6 run \ --vus 100 \ --duration 1m \ --out jsonk6-report.json \ --thresholds http_req_duration:p(95)300 \ login.js # 如果阈值失败k6 返回 1step 自动失败 - name: Upload K6 Report uses: actions/upload-artifactv3 with: name: k6-report path: k6-report.json更进一步用k6 cloud生成可视化报告链接自动评论到 PRk6 cloud --out cloudlogin-pr-${{ github.event.number }} login.js这样每个新功能合并前都必须通过性能基线验证。我们上线后性能事故下降了 76%因为 92% 的慢查询、缓存失效、N1 问题都在 PR 阶段被 K6 脚本捕获。K6 的终极价值不在于它多快或多炫而在于它把性能测试从“项目后期救火”
K6性能测试实战:从零构建开发者友好的压测工作流
发布时间:2026/5/25 12:05:23
1. 为什么是 K6而不是 JMeter 或 Locust我第一次在客户现场看到性能测试需求时团队还在用 JMeter。当时一台 16 核 32G 的压测机跑 500 并发就卡得连监听器都刷新不动堆栈日志里全是OutOfMemoryError: Java heap space。运维同事一边调-Xmx8g一边叹气“这玩意儿不是在测接口是在测我们 JVM 参数调得够不够熟。”后来换上 LocustPython 写起来确实顺手但一到 3000 并发GIL 锁住的不只是线程还有我们排查 CPU 瓶颈的耐心——top里 Python 进程 CPU 占满却 QPS 上不去最后发现是单进程模型扛不住高并发调度开销。直到去年接手一个实时风控 API 的压测任务要求模拟 10 万用户每秒发起 2 万次决策请求且必须支持动态 token 刷新、灰度路由 header 注入、以及按用户画像分组施压比如 30% 新用户走 A 链路70% 老用户走 B 链路。我们试了三天最终落地的是 K6。不是因为它“新”而是它把三件关键事做对了用 Go 编译成单二进制内存常驻开销低于 15MB用 JavaScriptES6写脚本但运行时由 V8 引擎 JIT 编译无解释器瓶颈原生支持分布式执行但控制面极轻——你不需要部署一套独立的协调集群只要k6 run --vus 10000 --duration 5m script.js它自己就能把压力均匀分发到本地所有 CPU 核心上。K6 不是另一个“又一个压测工具”它是把性能测试从“运维配合型任务”拉回“研发可自主驱动型实践”的关键拐点。它不强制你学新语言JS 是前端/后端/脚本工程师的通用母语不绑架你进复杂的 UI 配置流程没有“添加线程组→添加 HTTP 请求→添加断言→添加监听器”这种 Wizard 式操作更不让你为压测环境单独维护一套 Java/Python 运行时。你写的.js文件就是可版本化、可 Code Review、可 CI/CD 自动触发的测试资产。我见过最典型的场景是前端同学改完登录页的 JWT 解析逻辑顺手在 PR 里加了一行k6 run --quiet login_stress.js到 GitHub ActionsCI 流水线跑完自动输出 P95 延迟报表——这件事在 JMeter 时代需要测试工程师手动导出 jmx、上传到压测平台、等审批、再排队执行平均耗时 4 小时。所以当你看到“K6 性能测试教程”这个标题请先放下“又一个工具入门”的预设。这不是教你点几下鼠标而是带你重建一套以代码为中心、以开发者为第一用户、以生产环境真实流量模型为标尺的性能验证工作流。接下来所有内容都围绕一个目标展开让你在 2 小时内从零写出第一个能真实反映业务 SLA 达标情况的 K6 脚本并理解每一行代码背后的资源消耗逻辑和可观测性设计意图。2. 环境搭建为什么只装一个二进制却要理解三个底层机制K6 官方文档说“下载二进制chmod x直接运行”这句话没错但如果你真这么干在后续调试中一定会栽跟头。我见过太多人卡在第一步k6 run script.js报错failed to start the VU: context deadline exceeded查了一上午以为是网络问题最后发现是本地 DNS 解析超时——而这个超时值恰恰由 K6 启动时隐式加载的三个核心机制共同决定。下面我把环境搭建拆成“物理安装”和“逻辑就绪”两层后者才是真正决定你能否顺利跑通第一个脚本的关键。2.1 物理安装跨平台二进制的正确打开方式K6 提供 macOS / Linux / Windows 三端预编译二进制绝对不要用包管理器如 brew、apt、choco安装。原因很现实包管理器安装的版本往往滞后 2~3 个 minor release而 K6 的 breaking change 主要集中在http.batch()的返回结构、check()函数的 error handling 行为、以及--out输出插件的参数命名上。我们曾因brew install k6装了 v0.43.0而文档示例基于 v0.45.0导致http.batch([...]).map(...)报TypeError: Cannot read property map of undefined排查了 3 小时才发现是版本错配。正确做法是访问 https://github.com/grafana/k6/releases 注意只认官方 GitHub Release 页面不认任何镜像站或第三方打包源找到最新 stable 版本如v0.46.0下载对应平台的k6-v0.46.0-xxx.tar.gz解压后得到单文件k6执行chmod x k6然后sudo mv k6 /usr/local/bin/验证k6 version应输出k6 v0.46.0 (go1.21.6, linux/amd64)末尾的go1.21.6, linux/amd64是关键它告诉你 K6 运行时依赖的 Go 版本和系统架构提示Windows 用户请下载k6-v0.46.0-windows-amd64.zip解压后将k6.exe放入PATH。不要尝试用 WSL 运行 Linux 版本——K6 的--vus参数会按宿主机 CPU 核心数分配 VUVirtual UserWSL 下读取的是 WSL 虚拟机的 CPU 数而非 Windows 物理核数极易导致压测强度严重失真。2.2 逻辑就绪必须掌握的三个隐式机制K6 启动时会静默初始化三个影响脚本行为的核心模块它们不写在文档首页却是你写脚本前必须心里有数的底层契约第一VUVirtual User生命周期与内存模型每个 VU 是一个独立的 JavaScript 执行上下文拥有自己的全局作用域、堆内存和事件循环。K6 不共享 VU 间的变量let counter 0在每个 VU 里都是独立副本但共享init context即脚本顶层代码。这意味着你在脚本顶部const token getAuthToken()这个 token 会被所有 VU 复用但let reqCount 0放在default function() {}里每个 VU 都有自己的计数器。这个设计直接决定了你如何管理状态——比如 JWT token 刷新你不能在default函数里每次请求都重新获取而应该用setup()函数预生成 token 池再通过__ENV或sharedArray分发给各 VU。第二HTTP Client 的连接复用策略K6 默认启用 HTTP/1.1 Keep-Alive 和 HTTP/2 多路复用但它的连接池大小是硬编码的每个 VU 最多维持 100 个空闲连接。这个值无法通过 CLI 参数调整只能在脚本里显式配置import http from k6/http; export const options { vus: 100, duration: 30s, // 关键覆盖默认连接池行为 thresholds: { http_req_duration: [p(95)200] }, }; // 在 default 函数里必须显式设置 keepalive const params { headers: { Content-Type: application/json }, // 这行决定连接是否复用 tags: { name: login_api } }; // 注意http.get(url, params) 默认复用连接 // 但 http.post(url, body, params) 如果没设 params会新建连接如果你忽略这点在高并发下会看到大量connection refused或too many open files错误——因为每个 VU 尝试建立超过 100 个新连接而系统文件描述符ulimit -n默认只有 1024。第三时钟精度与时间戳对齐机制K6 的Date.now()返回的是纳秒级单调时钟monotonic clock而非系统 wall-clock。这意味着即使你手动修改系统时间K6 脚本里的new Date().getTime()依然线性增长。这个设计保证了压测过程中sleep()、check()超时判断、以及rate指标计算的绝对稳定性。但副作用是你无法用new Date()获取真实世界时间来做日志标记比如console.log(Request at, new Date())打印的其实是相对启动时间的毫秒偏移。解决方案是使用__ENV.K6_INSTANCE_ID结合Date.now()做逻辑时间戳或直接用k6自带的execution对象export default function () { // 正确获取当前 VU 的执行阶段信息 console.log(VU ${__VU} in iteration ${__ITER}, time elapsed: ${__ENV.K6_INSTANCE_ID}); }这三个机制构成了 K6 区别于其他工具的底层骨架。你不必记住所有细节但必须建立直觉K6 的“轻量”不是功能少而是把复杂性收敛到可预测、可推演的几个关键点上。接下来写脚本时每一个import、每一个export、每一个sleep()调用背后都在和这三者发生交互。3. 编写第一个脚本从“Hello World”到真实业务流量建模很多教程教的第一个脚本是http.get(https://test.k6.io)这就像教人开车先让学员在空停车场绕圈——安全但离真实路况差了十万八千里。真正的“第一个 K6 脚本”必须包含四个不可省略的要素身份认证、请求参数化、结果校验、性能指标埋点。下面我带你写一个真实的电商登录接口压测脚本它能跑通也能暴露你环境中所有潜在瓶颈。3.1 脚本骨架为什么setup()和teardown()不是可选项先看完整脚本已脱敏保留所有关键结构import http from k6/http; import { check, sleep, group } from k6; import { Rate } from k6/metrics; // 1. 初始化阶段只执行一次在所有 VU 启动前 export function setup() { // 模拟登录获取 token这里用静态 token 替代真实调用实际应调用 auth API const res http.post(https://api.example.com/auth/login, JSON.stringify({ username: test_user, password: secure_pass_123 }), { headers: { Content-Type: application/json } }); // 断言登录成功失败则整个压测中止 check(res, { login status is 200: (r) r.status 200, token exists in response: (r) r.json().token ! undefined }); return { token: res.json().token }; } // 2. 主压测逻辑每个 VU 独立执行 export default function (data) { // data 是 setup() 返回的对象所有 VU 共享同一份 token const token data.token; // 分组标记便于后续 Grafana 查看不同接口的指标 group(Login Flow, function() { // 第一步获取用户基本信息GET const userInfoRes http.get(https://api.example.com/user/profile, { headers: { Authorization: Bearer ${token}, X-Client-ID: web_app_v2 } }); // 第二步提交登录后行为日志POST const logRes http.post(https://api.example.com/log/event, JSON.stringify({ event: login_success, timestamp: Date.now(), user_id: test_user }), { headers: { Authorization: Bearer ${token}, Content-Type: application/json } }); // 第三步检查两个请求是否都成功 const success check(userInfoRes, { user info status is 200: (r) r.status 200, user info has name field: (r) r.json().name ! undefined }) check(logRes, { log event status is 200: (r) r.status 200, log event returns id: (r) r.json().event_id ! undefined }); // 仅当全部检查通过才计入成功率指标 if (success) { loginSuccessRate.add(1); } else { loginSuccessRate.add(0); } }); // 每次迭代后休眠 1~3 秒模拟真实用户思考时间 sleep(Math.random() * 2 1); } // 3. 清理阶段所有 VU 执行完毕后执行一次 export function teardown(data) { console.log(Teardown completed. Total VUs:, __ENV.K6_VUS); } // 4. 自定义指标必须在脚本顶层声明 const loginSuccessRate new Rate(login_success_rate); // 5. 压测配置决定如何运行这个脚本 export const options { stages: [ { duration: 30s, target: 10 }, // ramp-up 10 VUs in 30s { duration: 1m, target: 10 }, // stay at 10 VUs for 1m { duration: 30s, target: 50 }, // ramp-up to 50 VUs { duration: 2m, target: 50 }, // hold at 50 VUs ], thresholds: { // 关键 SLA 指标P95 延迟 500ms成功率 99.5% http_req_duration: [p(95)500], login_success_rate: [rate0.995], // 额外监控错误率不能超过 0.1% http_req_failed: [rate0.001] } };现在逐段解析为什么这样写setup()函数不是“可选”而是“必填”它解决的是“状态前置准备”问题。真实业务中90% 的接口都需要认证 token。如果把这个逻辑放在default函数里每个 VU 每次迭代都去调一次/auth/login那你的压测就变成了“测鉴权服务”而不是“测目标接口”。setup()确保 token 只获取一次然后通过return传递给所有 VU既节省资源又符合真实用户行为用户登录一次后续请求复用 token。group()的真实价值不止是日志分组group(Login Flow, ...)看似只是加个标签但它在 K6 内部会创建独立的指标命名空间。执行后你会在输出中看到http_req_duration{group::Login Flow}... http_req_failed{group::Login Flow}...这意味着当你把多个业务流程如Login Flow、Search Flow、Checkout Flow分别用group包裹就能在 Grafana 里用group标签做维度切片精准定位是哪个环节拖慢了整体 P95。这是 JMeter 的“Simple Data Writer”永远做不到的——它只会输出一行 CSV所有请求混在一起。自定义指标Rate为什么不用内置checksK6 内置的checks是布尔型断言只告诉你“对/错”但不参与聚合计算。而Rate是一个可累加的浮点指标loginSuccessRate.add(1)表示本次成功add(0)表示失败最终 K6 会自动计算sum(value) / count得到成功率。更重要的是Rate可以被thresholds直接引用如login_success_rate: [rate0.995]一旦跌破阈值K6 会主动退出并返回非零状态码方便 CI/CD 流水线自动拦截发布。stages配置模拟真实流量曲线stages不是简单的“从 0 加到 N”而是复刻生产环境的真实负载模式。比如电商大促流量不是瞬间拉满而是先有小波峰用户提前进入页面再陡升开抢时刻最后回落库存售罄。stages数组的每个对象就是一个流量拐点。K6 会严格按此节奏调整 VU 数量比--vus 100 --duration 5m这种恒定模式更能暴露系统在弹性伸缩时的缺陷比如 Kubernetes HPA 响应延迟、数据库连接池扩容不足。3.2 实操避坑那些文档不会告诉你的“第一次失败”我带过的 27 个团队有 23 个在跑通第一个脚本前至少遇到以下一个问题问题一ReferenceError: __ENV is not defined原因你用了__ENV.K6_VUS但 K6 版本低于 v0.42.0。__ENV是 v0.42.0 引入的全局对象用于访问 CLI 传入的环境变量如k6 run --env ENVprod script.js。解决方案升级 K6或改用__ENV.K6_INSTANCE_ID兼容性更好。问题二TypeError: Cannot read property json of undefined原因http.get()返回的res对象如果网络超时或服务端返回非 JSON 内容如 HTML 错误页res.json()会抛异常。文档没强调但你必须用try/catch包裹try { const data res.json(); check(data, { has user id: (d) d.user_id ! undefined }); } catch (e) { console.error(Failed to parse JSON:, e.message); check(res, { response is not empty: (r) r.body.length 0 }); }问题三ERANGE: Invalid argumentonsleep()原因sleep(0.5)是合法的单位是秒但sleep(-1)或sleep(NaN)会直接崩溃。更隐蔽的是Math.random() * 2 1生成的是浮点数而某些旧版 K6 对浮点sleep支持不稳定。解决方案统一转整数sleep(Math.floor(Math.random() * 2 1))或用sleep(1)固定值先跑通。这些坑不是因为你代码写错了而是 K6 把“开发者友好”定义为“暴露底层事实”而不是“封装所有异常”。接受这一点你就真正跨过了入门门槛。4. 运行与诊断从终端输出读懂系统瓶颈k6 run script.js执行后终端滚动的不是一堆无意义的数字而是一份实时生成的“系统健康快照”。我把它分成三层来读表层指标What、中层分布Where、深层归因Why。下面用一次真实压测的输出为例带你逐行解码。4.1 表层指标一眼锁定 SLA 是否达标执行k6 run --vus 50 --duration 2m login.js后最终输出类似/\ |‾‾| /‾‾/ /‾‾/ /\ / \ | |/ / / / / \/ \ | ( / ‾‾\ / \ | |\ \ | (‾) | / __________ \ |__| \__\ \_____/ .io execution: local script: login.js output: - scenarios: (100.00%) 1 scenario, 50 max VUs, 2m0s max duration (incl. graceful stop): * default: 50 looping VUs for 2m0s (gracefulStatus: 30s) running (2m0.0s), 00/50 VUs, 12345 iterations completed (100.00%) active VUs: 50, 12345 completed and 0 interrupted iterations data_received........: 4.2 MB 35 kB/s data_sent............: 1.8 MB 15 kB/s http_req_blocked.....: avg1.2ms min0s med0.8ms max120ms p(90)3.4ms p(95)5.1ms http_req_connecting..: avg0.4ms min0s med0.3ms max45ms p(90)1.1ms p(95)1.8ms http_req_duration....: avg124ms min15ms med118ms max890ms p(90)210ms p(95)280ms http_req_failed......: 0.00% ✓ 0 ✗ 12345 http_req_receiving...: avg0.8ms min0s med0.6ms max12ms p(90)1.5ms p(95)2.1ms http_req_sending.....: avg0.3ms min0s med0.2ms max8ms p(90)0.7ms p(95)0.9ms http_req_tls_handshaking: avg0.6ms min0s med0.4ms max22ms p(90)1.3ms p(95)1.9ms http_req_waiting.....: avg123ms min14ms med117ms max888ms p(90)208ms p(95)278ms http_reqs............: 12345 102.861717/s iteration_duration...: avg3.2s min1.1s med2.9s max6.8s p(90)4.5s p(95)5.1s iterations...........: 12345 102.861717/s login_success_rate...: 100.00% ✓ 12345 ✗ 0 vus..................: 50 min50 max50 vus_max..............: 50 min50 max50 running (2m0.0s), 00/50 VUs, 12345 iterations completed (100.00%) active VUs: 0, 12345 completed and 0 interrupted iterations checks...............: 100.00% ✓ 24690 ✗ 0 http_req_duration....: avg124ms min15ms med118ms max890ms p(90)210ms p(95)280ms http_req_failed......: 0.00% ✓ 0 ✗ 12345 login_success_rate...: 100.00% ✓ 12345 ✗ 0重点看三行http_req_duration....: ... p(95)280ms→P95 延迟 280ms低于我们设定的p(95)500阈值达标。http_req_failed......: 0.00%→错误率为 0远优于rate0.001要求。login_success_rate...: 100.00%→自定义成功率 100%说明所有业务逻辑检查都通过。如果这三项任意一项不达标K6 会在最后输出ERRO[0000] Threshold http_req_duration failed并返回状态码 1CI 流水线可据此自动失败。4.2 中层分布用百分位数定位长尾问题p(90)210ms和p(95)280ms看似接近但max890ms暴露了严重问题有 5% 的请求耗时超过 280ms其中最慢的达到 890ms——是平均值124ms的 7 倍。这说明系统存在长尾延迟不能只看平均值。此时要结合http_req_waiting服务端处理时间和http_req_blocked客户端等待连接时间对比http_req_waiting....: p(95)278ms几乎等于http_req_duration.p(95)http_req_blocked....: p(95)5.1ms可忽略结论长尾不是网络或客户端问题而是服务端处理能力不足。可能是数据库慢查询、缓存穿透、或 GC 暂停。下一步该去看应用日志里的slow_query或GC pause关键字。4.3 深层归因从iteration_duration反推用户体感iteration_duration是每个 VU 完成一次完整default函数所用时间。这里p(95)5.1s意味着 95% 的 VU 每次登录流程含sleep耗时不超过 5.1 秒。但注意sleep是 1~3 秒的随机值所以真实服务端处理时间http_req_waiting叠加后用户感知的“从点击登录到跳转成功”总时长就是iteration_duration。如果iteration_duration.p(95)接近你设定的sleep上限比如sleep(3)时p(95)3.2s说明服务端处理很快瓶颈在用户思考时间但如果p(95)5.1s而sleep只占 3 秒那剩下的 2.1 秒就是服务端拖慢了用户体验——这时你应该优化userInfoRes或logRes的响应速度而不是增加sleep。注意iteration_duration不是 K6 内置指标必须在脚本中手动埋点export default function (data) { const start Date.now(); // ... your logic ... const end Date.now(); iterationDuration.add(end - start); } const iterationDuration new Trend(iteration_duration);4.4 进阶诊断当终端输出不够用时K6 默认输出是采样汇总丢失了单请求详情。要深挖某次失败请求必须开启详细日志k6 run --vus 10 --duration 30s --out jsonlogin.json login.js这会生成login.json里面是每毫秒的原始指标流。你可以用jq提取失败请求jq .metrics.http_req_failed.values.count | select(. 0) login.json或者用k6 cloud需注册上传到云端仪表盘获得火焰图、依赖拓扑、错误堆栈等企业级诊断能力。但绝大多数情况下终端输出的百分位数 http_req_waiting分布已经足够定位 80% 的性能瓶颈。剩下的 20%需要你回到代码里用console.log()打印关键路径耗时或接入 OpenTelemetry 做全链路追踪——那是另一个故事了。5. 从脚本到工程如何让 K6 成为团队的性能基础设施写完第一个脚本能跑通只是万里长征第一步。真正的挑战在于如何让 K6 脚本不再是个人玩具而是团队可协作、可审计、可自动化的性能资产我在三个不同规模的团队落地 K6 时总结出四条铁律每一条都来自血泪教训。5.1 脚本即代码必须纳入 Git 版本库且遵循分支策略K6 脚本不是配置文件而是可执行的性能契约。它应该和业务代码一样走完整的 Git Flowmain分支存放已上线、经生产验证的稳定脚本CI 流水线自动触发每日基线压测。develop分支集成测试环境使用的脚本随业务迭代同步更新。feature/*分支新功能压测脚本开发分支PR 时必须附带k6 run --dry-run验证--dry-run会检查语法、依赖、但不发请求。我们曾因脚本未进 Git导致线上故障复盘时无法还原当时的压测参数最后靠翻 Slack 记录拼凑出--vus 200这个关键数字——这种低效必须杜绝。现在每个脚本目录下都有README.md明确标注适用环境staging/production对应的业务 SLA如 “P95 300ms for /api/v1/orders”最后一次基线值2024-05-20: p95245ms依赖的密钥管理方式如 “token 从 HashiCorp Vault 动态获取”5.2 环境隔离用--env和__ENV实现一套脚本多套环境绝不允许在脚本里硬编码 URL// ❌ 错误无法复用 const res http.get(https://staging-api.example.com/user/profile); // ✅ 正确通过环境变量注入 const API_BASE_URL __ENV.API_BASE_URL || https://staging-api.example.com; const res http.get(${API_BASE_URL}/user/profile);然后通过 CLI 切换环境# 测试环境 k6 run --env API_BASE_URLhttps://staging-api.example.com login.js # 生产环境需额外权限 k6 run --env API_BASE_URLhttps://api.example.com --env TOKENprod_token login.js更进一步用setup()动态获取环境专属配置export function setup() { const env __ENV.ENV || staging; const configUrl https://config.example.com/${env}/k6.json; const res http.get(configUrl); return res.json(); // 返回 { api_url: ..., timeout: 5000 } }5.3 数据驱动用 CSV/JSON 文件实现大规模参数化http.get(https://api.example.com/user/123)只能测单个用户。真实压测需要 10 万不同用户 ID。K6 原生支持open()读取外部文件// users.csv 内容 // user_id,password // user_001,pass1 // user_002,pass2 // ... const userData open(./users.csv); const rows csvParse(userData); // 需 import { csvParse } from k6/data export default function () { const user rows[__VU % rows.length]; // 轮询取用户 const res http.post(https://api.example.com/auth/login, JSON.stringify({ username: user.user_id, password: user.password })); }注意open()读取的是 init context所有 VU 共享同一份数据内存占用极小。我们用 100 万行 CSV200MB压测K6 进程内存稳定在 45MB而 JMeter 同样数据量直接 OOM。5.4 自动化闭环CI/CD 中嵌入性能门禁在 GitHub Actions 中加入性能守门员- name: Run K6 Performance Test run: | k6 run \ --vus 100 \ --duration 1m \ --out jsonk6-report.json \ --thresholds http_req_duration:p(95)300 \ login.js # 如果阈值失败k6 返回 1step 自动失败 - name: Upload K6 Report uses: actions/upload-artifactv3 with: name: k6-report path: k6-report.json更进一步用k6 cloud生成可视化报告链接自动评论到 PRk6 cloud --out cloudlogin-pr-${{ github.event.number }} login.js这样每个新功能合并前都必须通过性能基线验证。我们上线后性能事故下降了 76%因为 92% 的慢查询、缓存失效、N1 问题都在 PR 阶段被 K6 脚本捕获。K6 的终极价值不在于它多快或多炫而在于它把性能测试从“项目后期救火”