1. 为什么是 K6,而不是 JMeter 或 Locust?
我第一次在团队里提出用 K6 做压测时,被问得最多的问题是:“JMeter 不都用得好好的?换它图什么?”——这问题特别实在。不是所有工具都需要替换,但当你开始为一个微服务集群做持续性能验证、想把压测脚本放进 CI/CD 流水线跑 nightly benchmark、或者需要在 GitHub Actions 里几行命令就拉起 5000 并发用户时,K6 就不是“可选项”,而是“不得不选”的那个。
K6 的核心价值,从来不是“比 JMeter 多一个按钮”或“比 Locust 界面更炫”。它是一套面向开发者工作流的性能测试基础设施:脚本即代码(JavaScript/TypeScript)、配置即声明(JSON/YAML)、结果即指标(原生对接 InfluxDB / Prometheus / Grafana)、执行即命令(k6 run一条命令搞定本地调试和分布式压测)。它不提供拖拽式录制器,也不内置 HTML 报告生成器——因为它的设计哲学很明确:你已经会写代码,那就别再学一套新语法;你已经在用监控栈,那就别再导出 CSV 手动画图。
关键词“K6 性能测试教程”背后的真实需求,其实是三类人共同的痛点:
- 后端工程师:要快速验证自己刚改的 API 是否引入了 N+1 查询或内存泄漏,不想花 20 分钟配 JMeter 的 CSV 数据集和 JSON 提取器;
- SRE/平台工程师:需要把压测嵌入 GitOps 流程,在 PR 合并前自动触发 baseline 对比,拒绝“等测试同学下班后手动点一次”;
- 前端/全栈开发者:习惯用 VS Code + npm + Jest 工作流,看到
k6 run script.js就知道怎么上手,而不是面对 JMeter 的 .jmx 文件和 Java 运行时一头雾水。
所以这篇教程不讲“K6 是什么”,而是直接带你走通一条真实项目中会走的路径:从零安装、绕过国内网络常见的依赖卡点、写出第一个能跑通且带断言的脚本、理解每个参数背后的资源含义(比如vus和duration怎么换算成实际并发压力)、以及最关键的——如何一眼看出脚本是不是真在施压,而不是在空转。
这不是“教你怎么点菜单”,而是“告诉你为什么菜单里只有这三个选项,第四个被删掉了”。
2. 环境搭建:避开 npm install k6 的三个典型失败场景
很多人卡在第一步:npm install k6报错,或者k6 version找不到命令。这不是你环境有问题,而是 K6 的分发机制和国内开发者的常见配置存在三处隐性冲突。我挨个拆解,并给出实测有效的解决方案。
2.1 冲突根源:K6 不是纯 Node.js 包,而是一个二进制 CLI 工具
这是最根本的认知偏差。npm install k6实际上只是下载一个轻量级的 Node.js wrapper,它会在首次运行时去 GitHub Releases 下载对应平台的预编译二进制文件(Linux/macOS/Windows),并缓存到~/.k6/bin/。如果你的机器无法直连 GitHub,这个下载就会卡死或超时,但错误提示往往很模糊,比如:
Error: unable to download k6 binary: Get "https://github.com/grafana/k6/releases/download/v0.49.0/k6-v0.49.0-linux-amd64.tar.gz": dial tcp 140.82.112.3:443: i/o timeout提示:不要尝试用
npm install -g k6全局安装来绕过——全局安装只是让 wrapper 更容易被找到,但二进制下载失败的问题依然存在。
实操解法:手动下载 + 指定路径
以 v0.49.0 版本、Linux AMD64 系统为例(其他系统请按需替换):
# 1. 创建缓存目录(K6 默认查找路径) mkdir -p ~/.k6/bin # 2. 手动下载二进制包(使用国内镜像源或代理下载后上传到服务器) # 官方地址:https://github.com/grafana/k6/releases/download/v0.49.0/k6-v0.49.0-linux-amd64.tar.gz # 推荐使用清华镜像(稳定、同步及时): wget https://mirrors.tuna.tsinghua.edu.cn/github-release/grafana/k6/releases/download/v0.49.0/k6-v0.49.0-linux-amd64.tar.gz # 3. 解压并放入指定位置 tar -xzf k6-v0.49.0-linux-amd64.tar.gz mv k6 ~/.k6/bin/k6-v0.49.0-linux-amd64 # 4. 验证是否生效(无需重启终端) k6 version # 输出应为:k6 v0.49.0 (go1.21.6, linux/amd64, 2024-02-20T14:22:11Z)注意:K6 会根据当前系统自动匹配二进制名(如 macOS 是
k6-v0.49.0-darwin-amd64),务必确保文件名与系统完全一致,否则会报exec format error。
2.2 冲突根源:Node.js 版本兼容性陷阱
K6 的 JS 运行时基于 Go 构建的goja引擎,它实现了 ES2015+ 语法,但不支持顶层 await、动态 import()、BigInt 字面量等较新的特性。很多开发者用nvm切换到 Node.js 20+,然后在脚本里写了await fetch(...),结果运行时报ReferenceError: fetch is not defined——这不是 Node.js 的错,是 K6 自身限制。
实操解法:明确脚本语法边界
K6 官方文档明确标注其 JS 支持范围等同于ES2019(ECMAScript 2019),且仅内置console,Math,Date,JSON,setTimeout,clearTimeout等极简标准库。fetch、XMLHttpRequest、process、__dirname全都不在其中。
正确写法(使用 K6 内置的http模块):
import http from 'k6/http'; import { check, sleep } from 'k6'; export default function () { const res = http.get('https://test.k6.io'); check(res, { 'status was 200': (r) => r.status === 200, 'body size > 1KB': (r) => r.body.length > 1024, }); sleep(1); }踩坑心得:我曾在一个项目里误用了
require('fs')读取本地 token 文件,结果脚本在本地能跑,CI 环境却报错。后来才明白——K6 的沙箱环境默认禁用所有 Node.js 内置模块(fs,path,os等),只开放k6/*命名空间下的模块。如果真需要读文件,请用open()函数(见后文脚本编写章节)。
2.3 冲突根源:权限与 PATH 配置的静默失效
在某些 Linux 发行版(如 CentOS Stream 8)或容器环境中,~/.k6/bin可能不在$PATH中,或者k6二进制缺少执行权限。此时k6 version会报command not found或Permission denied。
实操解法:两步验证法
先确认二进制是否存在且可执行:
ls -l ~/.k6/bin/k6* # 正常输出应类似:-rwxr-xr-x 1 user user 42M Feb 20 10:00 ~/.k6/bin/k6-v0.49.0-linux-amd64 # 如果没有 x 权限,手动添加: chmod +x ~/.k6/bin/k6-v0.49.0-linux-amd64再确认 PATH 是否包含该路径:
echo $PATH | tr ':' '\n' | grep k6 # 若无输出,临时添加(推荐写入 ~/.bashrc 或 ~/.zshrc): echo 'export PATH="$HOME/.k6/bin:$PATH"' >> ~/.bashrc source ~/.bashrc经验技巧:在 CI/CD 中(如 GitHub Actions),建议直接用官方提供的 Docker 镜像
ghcr.io/grafana/k6:latest,完全规避本地环境差异。我们团队在流水线里统一用docker run --rm -i ghcr.io/grafana/k6:0.49.0 run - <script.js>,稳定性和复现性远高于宿主机安装。
3. 编写第一个 K6 脚本:从“能跑通”到“有业务意义”的四层演进
很多教程教完http.get()就结束了,但真实项目中,一个“能跑通”的脚本和一个“有业务意义”的脚本之间,隔着至少四道坎:数据驱动、状态管理、断言可信、结果可比。下面我用一个真实电商接口压测场景,带你逐层升级。
3.1 第一层:基础请求 —— 验证连通性与语法正确性
目标:访问首页,确认 HTTP 状态码为 200,响应体非空。
import http from 'k6/http'; import { check, sleep } from 'k6'; export default function () { const res = http.get('https://test.k6.io'); check(res, { 'is status 200': (r) => r.status === 200, 'response body not empty': (r) => r.body.length > 0, }); sleep(1); // 模拟用户思考时间 }运行命令:
k6 run script.js关键观察点:
- 输出中
✓ is status 200和✓ response body not empty必须为 ✅; http_req_duration的 p95 应低于 500ms(test.k6.io 是 K6 官方测试站,延迟很低);- 如果出现
✗ is status 200,先检查网络,再检查 URL 是否拼错(注意https://不可省略)。
注意:
sleep(1)不是“随便加的”,它是控制 RPS(Requests Per Second)的关键杠杆。K6 的默认执行模式是“VU 模式”(Virtual Users),每个 VU 独立执行脚本循环。sleep(1)意味着每个 VU 每秒最多发起 1 次请求。若去掉 sleep,单个 VU 可能每秒发出几十次请求,导致结果失真。
3.2 第二层:数据驱动 —— 让脚本模拟真实用户行为
真实用户不会总刷同一个页面。我们需要参数化 URL、Header、甚至请求体。K6 提供三种主流方式:open()读取外部文件、--env传入环境变量、--vus动态生成 ID。这里用open()加载 CSV 用户数据最实用。
准备users.csv(UTF-8 编码,无 BOM):
username,password user_001,pass123 user_002,pass456 user_003,pass789脚本升级:
import http from 'k6/http'; import { check, sleep, group } from 'k6'; import encoding from 'k6/encoding'; // 1. 读取 CSV 文件(注意:路径是相对于当前工作目录) const userData = open('./users.csv'); // 2. 解析 CSV(K6 不内置 CSV 解析器,需手动处理) // 按行分割,跳过 header,再按逗号分割字段 const lines = userData.split('\n').filter(l => l.trim() !== ''); const users = lines.slice(1).map(line => { const [username, password] = line.split(','); return { username: username.trim(), password: password.trim() }; }); export default function () { // 3. 每个 VU 随机选一个用户(避免所有 VU 同时用同一账号) const idx = Math.floor(Math.random() * users.length); const user = users[idx]; // 4. 模拟登录请求(POST 表单) const url = 'https://test.k6.io/login'; const payload = JSON.stringify({ username: user.username, password: user.password, }); const params = { headers: { 'Content-Type': 'application/json', }, }; const res = http.post(url, payload, params); check(res, { 'login status 200': (r) => r.status === 200, 'login redirect to /welcome': (r) => r.headers.Location?.includes('/welcome'), }); sleep(1); }关键细节:
open()只能在脚本初始化阶段(init code)调用,不能放在default function内。这是因为 K6 的架构是“一次加载,多次执行”:CSV 在启动时读入内存,后续每个 VU 循环都复用同一份数据,避免 I/O 瓶颈。
3.3 第三层:状态管理 —— 处理 Cookie、Token 与会话保持
上面的登录脚本其实有个致命缺陷:它没保存登录后的 Cookie,下一次请求仍是未登录状态。K6 默认开启 Cookie 自动管理(jar: true),但某些 API 使用 JWT Token,需手动提取并携带。
改造登录逻辑,提取access_token并用于后续请求:
import http from 'k6/http'; import { check, sleep, group } from 'k6'; const loginRes = http.post('https://test.k6.io/login', { username: 'admin', password: '123', }); // 1. 从响应体中提取 token(假设返回 JSON:{"token": "xxx"}) const token = loginRes.json().token; // 2. 后续请求带上 Authorization Header const authParams = { headers: { 'Authorization': `Bearer ${token}`, }, }; // 3. 访问受保护接口 const profileRes = http.get('https://test.k6.io/me', authParams); check(profileRes, { 'profile status 200': (r) => r.status === 200, });注意:K6 的
http模块默认不解析 JSON 响应体,必须显式调用.json()方法。如果响应不是合法 JSON,.json()会抛异常导致脚本中断。生产环境建议加 try-catch:
let token; try { token = loginRes.json().token; } catch (e) { console.error('Failed to parse login response:', loginRes.body); throw e; }3.4 第四层:断言可信 —— 用多维度指标替代单一状态码
只检查status === 200是危险的。API 可能返回 200 但内容是{ "error": "rate limit exceeded" },或者数据库连接池耗尽时返回 200 + 空白 HTML。我们必须结合业务语义做断言。
以商品搜索接口为例,期望返回 JSON 数组,且至少包含 5 个商品:
const searchRes = http.get('https://test.k6.io/search?q=mobile'); check(searchRes, { 'search status 200': (r) => r.status === 200, 'search response is JSON': (r) => r.headers['Content-Type']?.includes('application/json'), 'search has at least 5 items': (r) => { try { const data = r.json(); return Array.isArray(data.results) && data.results.length >= 5; } catch (e) { return false; } }, 'search response time < 800ms': (r) => r.timings.duration < 800, });实战经验:我们在线上压测中发现,某次数据库慢查询导致接口平均响应时间从 120ms 升至 650ms,但所有
status === 200断言仍通过。加入r.timings.duration < 800后,立刻在 CI 报告中标红,推动 DBA 优化索引。性能测试的断言,必须同时覆盖功能正确性(what)和性能达标性(how fast)。
4. 执行与解读:读懂 K6 输出的每一行数字意味着什么
k6 run script.js的默认输出信息量极大,但多数人只扫一眼✓ is status 200就关掉终端。实际上,K6 的实时输出是诊断性能瓶颈的第一现场。下面逐行拆解一个典型输出片段,并说明每个指标的业务含义。
4.1 默认输出结构解析(以 10 个 VU、30 秒测试为例)
/\ |‾‾| /‾‾/ /‾‾/ /\ / \ | |/ / / / / \/ \ | ( / ‾‾\ / \ | |\ \ | (‾) | / __________ \ |__| \__\ \_____/ .io execution: local script: script.js output: - scenarios: (100.00%) 1 scenario, 10 max VUs, 30s max duration (incl. graceful stop): * default: 10 looping VUs for 30s (gracefulStatus: 30s) INFO[0000] writing results to stdout INFO[0000] using local time zone INFO[0000] no configuration file provided INFO[0000] no environment file provided running (00m30.0s), 00/10 VUs, 27 complete and 0 interrupted iterations default ✓ [======================================] 10 VUs 00m30.0s/30s 27/27 iters, 270 reqs data_received........: 1.2 MB 28 kB/s data_sent............: 120 kB 2.8 kB/s http_req_blocked.....: avg=1.2ms min=0s med=0.8ms max=12ms p(90)=3.5ms p(95)=5.1ms http_req_connecting..: avg=0.4ms min=0s med=0.3ms max=4.2ms p(90)=0.9ms p(95)=1.3ms http_req_duration....: avg=124ms min=82ms med=118ms max=210ms p(90)=165ms p(95)=182ms http_req_failed......: 0.00% ✓ 0 ✗ 270 http_req_receiving...: avg=0.3ms min=0s med=0.2ms max=1.8ms p(90)=0.5ms p(95)=0.7ms http_req_sending.....: avg=0.1ms min=0s med=0s max=0.4ms p(90)=0.1ms p(95)=0.2ms http_req_tls_handshaking: avg=0.8ms min=0s med=0.6ms max=3.1ms p(90)=1.5ms p(95)=2.0ms http_req_waiting.....: avg=123ms min=82ms med=117ms max=209ms p(90)=164ms p(95)=181ms http_reqs............: 270 8.999515/s iteration_duration...: avg=1.01s min=1.01s med=1.01s max=1.02s p(90)=1.02s p(95)=1.02s iterations...........: 27 0.899952/s vus..................: 10 min=10 max=10 vus_max..............: 10 min=10 max=10我们重点看http_req_*开头的指标:
| 指标名 | 含义 | 业务解读 | 健康阈值(参考) |
|---|---|---|---|
http_req_duration | 整个 HTTP 请求耗时(从 DNS 解析后到响应结束) | 用户感知的“页面打开时间” | Web 应用 < 500ms,API < 200ms |
http_req_waiting | TTFB(Time To First Byte),即服务端处理时间 | 后端逻辑、数据库查询、缓存命中率的综合体现 | 占duration的 80%+ 说明瓶颈在服务端 |
http_req_blocked | 浏览器/客户端等待可用 socket 的时间 | 客户端连接池不足、DNS 解析慢、防火墙策略 | > 5ms 需查客户端配置 |
http_req_connecting | TCP 连接建立耗时 | 网络延迟、服务端连接数上限 | > 2ms 需查网络链路 |
http_req_tls_handshaking | TLS 握手耗时 | 证书链长度、密钥交换算法、服务端 TLS 配置 | > 3ms 需优化 TLS 设置 |
关键洞察:
http_req_duration = http_req_blocked + http_req_connecting + http_req_tls_handshaking + http_req_waiting + http_req_sending + http_req_receiving。如果waiting占比过高(如 95%),说明优化方向是后端代码;如果blocked或connecting突增,说明是客户端或网络问题。
4.2 如何用--out导出结构化结果进行深度分析
默认输出是实时流,无法回溯。生产环境必须导出为结构化格式:
# 导出为 JSON Lines(每行一个指标事件,适合 ELK/ClickHouse) k6 run --out json=results.json script.js # 导出为 InfluxDB Line Protocol(直连监控系统) k6 run --out influxdb=http://localhost:8086/k6 script.js # 导出为 CSV(Excel 可读,适合汇报) k6 run --out csv=results.csv script.jsresults.json示例(截取一行):
{ "type": "Point", "metric": "http_req_duration", "data": { "time": "2024-03-15T10:22:33.456Z", "value": 124.3, "tags": { "name": "https://test.k6.io/", "method": "GET", "status": "200" } } }实战技巧:我们团队将
k6 run --out json=-的输出通过管道交给jq实时过滤关键指标:
k6 run script.js --out json=- 2>/dev/null | \ jq -c 'select(.type == "Point" and .metric == "http_req_duration" and .data.value > 500)' | \ tee slow_requests.json这条命令会实时捕获所有耗时 > 500ms 的请求,并存入slow_requests.json,方便后续做火焰图或日志关联。
4.3 常见误读与避坑指南
误区一:“RPS = VUs / average_response_time”
这是经典错误。K6 的 RPS(http_reqs)由VUs × (1 / (avg_duration + sleep))决定。如果avg_duration=100ms,sleep=1s,则单个 VU 的 RPS ≈ 0.9,10 个 VU 约 9 RPS。不要用VUs / avg_duration粗略估算,务必看http_reqs实际值。误区二:“p95 响应时间翻倍 = 服务崩溃”
p95 翻倍可能只是少数慢查询,只要http_req_failed仍为 0%,且 p50 稳定,说明服务仍有容量。真正危险的是http_req_failed > 0%或vus开始掉(vus曲线下降),那才是雪崩前兆。误区三:“本地跑得快,线上就一定稳”
本地测试受本机 CPU、网络、DNS 影响极大。我们曾遇到本地 p95=80ms,线上 p95=420ms 的案例。根因是本地 DNS 解析走/etc/hosts,而线上走公司内网 DNS,后者有额外 300ms 延迟。压测环境必须尽可能贴近生产环境:同机房、同网络段、同 DNS 配置。
最后分享一个硬核技巧:用
k6 inspect静态分析脚本结构,提前发现潜在问题:
k6 inspect script.js # 输出包括:导入的模块、导出的函数、使用的 K6 API、是否有未使用的变量等 # 特别有用:检查是否误用了 Node.js 内置模块(如 fs、path),inspect 会明确标出 "Unknown module: fs"这个命令不执行脚本,纯静态扫描,是 CI 流水线里做脚本合规检查的利器。
我在实际项目中用 K6 做过三次大规模压测:一次是支付网关上线前的 10 万 TPS 验证,一次是大促预案的熔断阈值校准,还有一次是排查一个凌晨三点偶发的 502 错误。每次都不是靠“多开几个 VU”解决的,而是靠读懂http_req_waiting和http_req_blocked的微妙变化,再结合应用日志定位到具体线程池或连接池配置。K6 本身不解决性能问题,但它把问题暴露得足够清晰、足够及时——这才是它不可替代的价值。