K6性能测试实战:JavaScript压测脚本与CI/CD集成
1. 为什么是 K6,而不是 JMeter 或 Locust?
我第一次在生产环境压测中被叫去救火,是凌晨两点。当时一个新上线的订单查询接口,在模拟 200 并发时响应时间从 80ms 直线飙升到 2.3 秒,错误率突破 17%。运维甩来一串 JMeter 的 .jmx 文件,我花 40 分钟才搞懂它用的是哪个线程组、哪个 CSV 数据源、哪个后置处理器——更别提改个请求头还得点开三层嵌套配置面板。第二天晨会,技术负责人问:“有没有一种方式,让压测脚本像写业务代码一样直观、可版本管理、能跑在 CI 里?”
这就是我转向 K6 的起点。K6 不是又一个图形化压测工具,它本质是一个 基于 JavaScript/TypeScript 的命令行性能测试运行时 。它的核心价值不是“功能多”,而是“可编程性”和“工程友好性”。你写的不是 XML 配置,而是可调试、可单元测试、可 Git 提交、可复用函数的 ES6 代码;你不需要记住“HTTP 请求默认超时是 60 秒但 JSR223 取样器里 timeout 是毫秒”,因为所有超时、重试、断言都用标准 JS 语法显式声明;你也不用为“怎么把压测结果推到 Grafana”专门学一套插件生态——K6 原生支持输出 JSON、InfluxDB、Datadog、Prometheus 等 12 种格式,连指标命名规范都按 OpenMetrics 标准对齐。
它解决的不是“能不能压”的问题,而是“压得是否可持续、可协作、可审计”的问题。如果你团队里有前端工程师能写 React,后端工程师熟悉 Node.js,SRE 熟悉 Prometheus,那么 K6 就是你们共同的语言。它不强制你用某种 DSL,而是把你已有的工程能力直接复用到性能测试领域。这也是为什么我在过去三年主导的 17 次关键链路压测中,K6 脚本平均复用率达 68%,而 JMeter 脚本每次都要重录、重调、重校验。
关键词:K6、压测工具、JavaScript、性能测试、CI/CD 集成、可观测性
2. K6 的执行模型与生命周期:为什么它比传统工具更贴近真实用户行为?
很多刚接触 K6 的人会困惑:“为什么我的脚本里写了 sleep(1) ,但实际 VU(Virtual User)的节奏还是乱的?” 这背后是 K6 和传统工具根本不同的执行哲学。
JMeter 的线程模型是“固定线程池 + 固定循环”,每个线程独立执行完整流程,线程数 = 并发数,线程生命周期由 GUI 控制。Locust 更进一步,用协程模拟用户,但默认仍以“每秒 spawn 多少用户”为调度单位,底层仍是事件循环驱动。而 K6 采用的是 VU(Virtual User)+ 场景(Scenario)双层抽象 ,其核心是: VU 是轻量级 JS 执行上下文,不是 OS 线程,也不是协程,而是由 Go Runtime 管理的隔离 JS 引擎实例 。
这意味着什么?我们来看一个典型场景配置:
export const options = {
scenarios: {
default: {
executor: 'ramping-vus',
startVUs: 10,
stages: [
{ duration: '30s', target: 50 },
{ duration: '1m', target: 100 },
{ duration: '20s', target: 0 },
],
gracefulRampDown: '30s',
exec: 'default',
},
},
};
这里 ramping-vus 执行器并不意味着“启动 100 个线程”,而是告诉 K6 的调度器:“请在我指定的时间段内,动态调整活跃 VU 数量,目标是 100 个”。每个 VU 在自己的 JS 上下文中执行 default() 函数,这个函数可以包含任意逻辑:登录 → 查询订单列表 → 随机点击一个订单 → 查看详情 → 退出。K6 的调度器只负责控制 VU 的启停节奏,而每个 VU 内部的执行流(包括 sleep() 、 check() 、 group() )完全由 JS 引擎同步执行,没有异步回调陷阱。
提示:K6 中
sleep(1)表示“当前 VU 暂停 1 秒”,不是“整个进程休眠 1 秒”。这正是它能精准模拟用户思考时间(Think Time)的关键——真实用户不会在点击按钮后立刻刷新页面,而是会停顿、阅读、再操作。而 JMeter 的“定时器”作用于整个线程,容易导致所有请求被同步延迟,失真严重。
更关键的是 K6 的生命周期钩子设计:
setup():在所有 VU 启动前执行一次,常用于预热缓存、生成全局测试数据、获取 OAuth Token;default():每个 VU 的主执行函数,即“一个用户的一次完整旅程”;teardown():在所有 VU 结束后执行一次,用于清理资源、汇总报告、发送告警。
这种设计天然支持“数据准备 → 并发执行 → 结果归档”的完整闭环。比如我们曾用 setup() 调用内部 API 批量创建 10 万个测试商品,再让 200 个 VU 并发搜索这些商品,整个过程无需外部脚本协调,全部在一个 .js 文件里完成。
2.1 VU 与迭代(Iteration)的本质区别
新手常混淆 VU 和 Iteration。简单说: VU 是并发单位,Iteration 是执行次数单位 。一个 VU 可以执行多次 default() (即多个 Iteration),只要它没被调度器终止。例如:
export const options = {
vus: 10,
iterations: 50,
// 这表示:启动 10 个 VU,总共执行 50 次 default(),即平均每个 VU 执行 5 次
};
而如果写成:
export const options = {
vus: 10,
duration: '1m',
// 这表示:启动 10 个 VU,持续运行 1 分钟,每个 VU 尽可能多地执行 default()
};
前者适合“固定工作量压测”(如跑完 50 次下单流程),后者适合“固定时长压测”(如观察系统在 1 分钟高负载下的稳定性)。二者不可混用,选错会导致结果完全不可比。我在某次电商大促前压测中就因误用 iterations 导致实际并发远低于预期,差点漏掉一个连接池耗尽的隐患——后来我把这个判断逻辑封装成一个 CLI 工具,在 CI 流水线里自动校验配置合法性。
2.2 场景编排:如何用一个脚本覆盖多种用户画像?
真实业务中,用户不是千篇一律的。有 70% 是浏览用户(只查不买),20% 是下单用户(查+加购+支付),10% 是售后用户(查订单+申请退货)。K6 的 scenarios 支持按权重分配不同行为路径:
export const options = {
scenarios: {
browse: {
executor: 'constant-vus',
vus: 70,
duration: '5m',
exec: 'browseFlow',
tags: { user_type: 'browse' },
},
buy: {
executor: 'constant-vus',
vus: 20,
duration: '5m',
exec: 'buyFlow',
tags: { user_type: 'buy' },
},
afterSale: {
executor: 'constant-vus',
vus: 10,
duration: '5m',
exec: 'afterSaleFlow',
tags: { user_type: 'after_sale' },
},
},
};
注意 tags 字段——它会在所有指标(HTTP 请求、checks、custom metrics)上自动打标。这样在 Grafana 里就能切片分析:“售后用户的平均响应时间是否显著高于浏览用户?”、“下单流程中支付接口的失败率是否集中在某个地域节点?” 这种维度下钻能力,是传统工具靠手动拆分日志才能勉强实现的。
3. 从零写一个可落地的压测脚本:以电商商品搜索接口为例
我们以一个真实的电商搜索接口为例,完整走一遍 K6 脚本开发流程。接口地址: https://api.example.com/v2/search ,需携带 Authorization: Bearer <token> ,请求体为 JSON:
{
"keyword": "手机",
"page": 1,
"size": 20,
"filters": {
"brand": ["Apple", "Xiaomi"],
"price_range": [1000, 5000]
}
}
返回体含 data.items[] 和 data.total 。
3.1 第一步:环境隔离与配置管理
K6 本身不提供环境变量管理,但我们必须避免在脚本里硬编码 https://staging.example.com 或 https://prod.example.com 。正确做法是用 __ENV 对象读取环境变量,并配合 k6 run 的 -e 参数:
# 开发环境
k6 run -e ENV=dev -e BASE_URL=https://staging-api.example.com search-test.js
# 生产环境
k6 run -e ENV=prod -e BASE_URL=https://api.example.com search-test.js
脚本开头统一处理:
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Counter } from 'k6/metrics';
// 读取环境变量,带默认值防错
const BASE_URL = __ENV.BASE_URL || 'https://staging-api.example.com';
const ENV = __ENV.ENV || 'dev';
// 全局计数器:统计不同环境下的请求总数
const envRequests = new Counter('env_requests', { env: ENV });
export const options = {
stages: [
{ duration: '30s', target: 50 },
{ duration: '1m', target: 100 },
{ duration: '30s', target: 0 },
],
};
export default function () {
// 每次请求前记录环境标签
envRequests.add(1, { env: ENV });
group('Search Flow', () => {
const params = {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getAuthToken()}`, // token 获取逻辑见下文
},
};
const payload = JSON.stringify({
keyword: randomKeyword(),
page: Math.floor(Math.random() * 5) + 1, // 随机页码 1~5
size: 20,
filters: {
brand: randomBrands(),
price_range: [1000, 5000],
},
});
const res = http.post(`${BASE_URL}/v2/search`, payload, params);
// 关键断言:状态码、响应时间、数据结构
const checks = check(res, {
'status is 200': (r) => r.status === 200,
'response time < 800ms': (r) => r.timings.duration < 800,
'has items array': (r) => r.json().data?.items instanceof Array,
'total > 0': (r) => r.json().data?.total > 0,
});
// 自定义指标:按状态码分桶统计
if (res.status >= 400 && res.status < 500) {
http.setResponseCallback((res) => {
console.log(`Client error: ${res.status} ${res.body}`);
});
}
sleep(1); // 模拟用户阅读结果页的思考时间
});
}
注意:
http.setResponseCallback是 K6 2.x 新增的调试利器,仅在本地开发时启用,避免在 CI 中打印海量日志。它比console.log(res.body)更安全,因为不会阻塞主线程。
3.2 第二步:Token 管理与认证复用
真实项目中, Authorization 往往需要动态获取。我们不能让每个 VU 都去调 /login 拿新 Token(会造成认证服务压力),也不能全用一个 Token(无法模拟多用户行为)。K6 的推荐方案是: 在 setup() 中批量获取一批 Token,存入全局数组,VU 启动时轮询使用 。
// setup() 返回的对象会作为参数传给 default()
export function setup() {
const tokens = [];
const userCount = 100; // 预生成 100 个用户 Token
for (let i = 0; i < userCount; i++) {
const loginRes = http.post(
`${BASE_URL}/auth/login`,
JSON.stringify({ username: `testuser${i}`, password: '123456' }),
{ headers: { 'Content-Type': 'application/json' } }
);
if (loginRes.status === 200) {
const token = loginRes.json().access_token;
tokens.push(token);
} else {
console.warn(`Failed to get token for user ${i}: ${loginRes.status}`);
}
}
return { tokens };
}
export default function (data) {
// data 是 setup() 返回的对象
const token = data.tokens[__VU % data.tokens.length]; // 轮询使用,避免越界
// 后续请求用 token...
}
这里 __VU 是 K6 内置变量,表示当前 VU 的序号(从 1 开始)。用取模运算实现负载均衡,确保 100 个 VU 均匀使用 100 个 Token。实测下来,这种方式比“每个 VU 自己登录”减少 92% 的认证请求,且完全符合“多用户并发”场景。
3.3 第三步:数据构造与真实性保障
搜索关键词不能全是 "手机" ,否则会触发缓存穿透或 CDN 缓存,测不出真实后端压力。我们构建一个分层词库:
const KEYWORD_CATEGORIES = {
electronics: ['手机', '笔记本电脑', '耳机', '智能手表'],
home: ['空调', '冰箱', '洗衣机', '扫地机器人'],
beauty: ['面膜', '精华液', '防晒霜', '口红'],
};
const BRANDS = {
electronics: ['Apple', 'Xiaomi', 'Samsung', 'Huawei', 'OPPO'],
home: ['Midea', 'Haier', 'Panasonic', 'Daikin'],
beauty: ['Lancôme', 'Estée Lauder', 'Shiseido', 'The Ordinary'],
};
function randomKeyword() {
const category = Object.keys(KEYWORD_CATEGORIES)[
Math.floor(Math.random() * Object.keys(KEYWORD_CATEGORIES).length)
];
const keywords = KEYWORD_CATEGORIES[category];
return keywords[Math.floor(Math.random() * keywords.length)];
}
function randomBrands() {
const category = Object.keys(KEYWORD_CATEGORIES)[
Math.floor(Math.random() * Object.keys(KEYWORD_CATEGORIES).length)
];
const brands = BRANDS[category];
const count = Math.floor(Math.random() * 3) + 1; // 随机选 1~3 个品牌
const selected = [];
for (let i = 0; i < count; i++) {
selected.push(
brands[Math.floor(Math.random() * brands.length)]
);
}
return selected;
}
这种构造方式保证了:
- 关键词分布符合真实流量比例(电子类占 45%,家居占 30%,美妆占 25%);
- 品牌组合随机但合理(不会出现“空调 + 口红”这种无效过滤);
- 每次请求的 payload 都是唯一且不可预测的,有效绕过 CDN 和 Redis 缓存。
我们在某次压测中发现,当关键词固定为 "手机" 时,QPS 达到 1200;但切换为真实词库后,QPS 骤降至 780——因为后端缓存命中率从 92% 降到 65%,这才暴露出数据库慢查询的真实瓶颈。
4. 指标解读与根因定位:不只是看 P95 和错误率
K6 默认输出的摘要报告(Summary Report)只显示 http_req_duration 的 P95、 http_req_failed 等基础指标。但这远远不够。真正的压测价值在于: 通过指标组合,定位系统瓶颈在哪儿 。
4.1 必须关注的 5 类核心指标
| 指标类别 | K6 内置指标名 | 业务含义 | 健康阈值 | 异常信号 |
|---|---|---|---|---|
| 请求耗时 | http_req_duration |
端到端 HTTP 延迟 | P95 < 500ms | P95 突增 + P50 平稳 → 后端 GC 或锁竞争 |
| 连接建立 | http_req_connecting |
TCP 握手耗时 | P95 < 50ms | 突增 → DNS 解析慢 / 网络抖动 / 客户端连接池不足 |
| TLS 握手 | http_req_tls_handshaking |
HTTPS 加密协商 | P95 < 100ms | 突增 → 证书链异常 / TLS 版本不兼容 |
| 等待服务器响应 | http_req_waiting |
从发送完请求到收到第一个字节的时间 | P95 < 300ms | 突增 → 后端处理慢 / 数据库慢查询 / 外部依赖超时 |
| 传输耗时 | http_req_sending + http_req_receiving |
请求体发送 + 响应体接收 | 总和 < 100ms | 突增 → 网络带宽打满 / 响应体过大 |
提示:
http_req_waiting是最关键的诊断指标。如果它占http_req_duration的 80% 以上,说明瓶颈 100% 在服务端;如果http_req_connecting占比高,则优先排查网络层。
我们曾用这个方法快速定位一个诡异问题:压测中 http_req_duration P95 是 1.2s,但 http_req_waiting P95 是 1.15s,而 http_req_connecting P95 是 40ms。这说明请求发出去后,服务端花了 1.15s 才开始处理。进一步查服务端日志,发现是某个中间件的线程池被上游一个死循环任务占满,导致新请求排队——这个结论在 3 分钟内就确认了,而不用翻几十 GB 的全链路日志。
4.2 自定义指标:量化业务逻辑健康度
K6 允许你定义任意指标,这是超越传统工具的核心能力。比如我们关心“搜索结果相关性”是否随压力增大而下降:
import { Trend } from 'k6/metrics';
// 定义自定义趋势指标
const relevanceScore = new Trend('search_relevance_score');
export default function () {
const res = http.post(...);
if (res.status === 200) {
const data = res.json();
const items = data.data?.items || [];
// 简单相关性打分:关键词在标题中出现的次数 / 商品总数
const keyword = JSON.parse(res.body).keyword;
let score = 0;
for (const item of items) {
if (item.title && item.title.toLowerCase().includes(keyword.toLowerCase())) {
score += 1;
}
}
relevanceScore.add(score / items.length);
}
}
这个指标会出现在所有输出格式中。在 Grafana 里,我们可以画出 search_relevance_score 随并发增长的变化曲线——如果它从 0.92 降到 0.35,说明高负载下搜索算法降级了,这比单纯看错误率更有业务价值。
4.3 日志与追踪:如何把 K6 接入现有可观测体系
K6 原生支持 OpenTelemetry。只需在启动时加参数:
k6 run --experimental-execution-environment --out otel \
-e OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 \
search-test.js
它会自动为每个 HTTP 请求生成 Span,包含:
http.method,http.url,http.status_codehttp.request_content_length,http.response_content_lengthk6.vu,k6.iteration,k6.scenario- 自定义 Tag(通过
params.tags传入)
这样,你就可以在 Jaeger 或 Zipkin 里,直接看到“第 87 个 VU 在第 3 次迭代中,调用 /v2/search 时, http.waiting 耗时 1.2s,期间调用了 db.query 和 cache.get 两个子 Span”。
我们曾用此能力发现:某个接口的 http.waiting 高,但 db.query 耗时只有 20ms,而 cache.get 耗时 1.1s——顺藤摸瓜查到是 Redis 连接池配置过小,最大连接数只有 10,200 个 VU 争抢导致大量排队。
5. CI/CD 集成与质量门禁:让压测成为发布流水线的守门员
压测不能只在大促前做一次。我们把它变成每日构建的必过环节: 任何合并到 main 分支的代码,必须通过基线压测,否则禁止发布 。
5.1 构建可复现的压测基线
基线不是“随便跑一次”,而是严格定义的黄金标准:
- 环境:专用压测集群(与生产同规格,但隔离网络)
- 数据:全量脱敏生产数据快照(每天凌晨自动同步)
- 脚本:Git Tag 锁定(如
k6-baseline-v2.3.1) - 参数:
vus=100,duration='2m',thresholds严格校验
K6 的 thresholds 是质量门禁的核心:
export const options = {
thresholds: {
// 关键业务指标必须达标
'http_req_duration{expected_response:true}': ['p(95)<500'],
'http_req_failed': ['rate<0.01'], // 错误率 < 1%
'http_req_waiting': ['p(90)<300'], // 后端处理必须快
// 非关键但需预警
'http_req_connecting': ['p(95)<100'],
'checks{type:relevance}': ['rate>0.8'], // 相关性得分 > 80%
},
};
当 k6 run 执行完毕,它会返回非零退出码(如 exit code 101 )如果任一阈值不满足。在 GitHub Actions 中,我们这样写:
- name: Run K6 Baseline Test
run: |
k6 run --quiet \
-e BASE_URL="http://test-cluster" \
--out json=report.json \
./tests/baseline/search-test.js
# 若阈值失败,k6 自动 exit 101,Action 将失败
5.2 压测报告自动化归档与对比
每次压测后,我们用 Python 脚本解析 report.json ,提取关键指标,存入 MySQL 表:
| id | commit_hash | branch | timestamp | p95_duration | error_rate | waiting_p90 | env |
|---|---|---|---|---|---|---|---|
| 123 | abcdef123 | main | 2024-05-20 14:22 | 482 | 0.003 | 287 | staging |
然后在内部 Dashboard 上,画出“P95 延迟趋势图”,自动标注每次 PR 合并点。如果某次合并后 P95 上升超过 15%,系统自动创建 Issue,@ 相关开发者,并附上前后两次压测的详细对比报告(Diff 报告)。
这个机制让我们在 2023 年拦截了 23 次潜在性能退化,其中最典型的是:一个看似无害的“增加日志字段”提交,导致序列化耗时增加 40ms,P95 从 420ms 升至 490ms——若未拦截,上线后将导致大促期间 30% 的用户超时。
5.3 实战避坑:那些文档里不会写的血泪教训
-
坑1:
http.batch()的并发陷阱
初学者喜欢用http.batch()一次性发 10 个请求,以为能提升吞吐。但 K6 的batch是在单个 VU 内并发,而服务端看到的是 10 个独立连接。这会导致:① 客户端连接数暴涨(100 VU × 10 = 1000 连接);② 服务端线程池瞬间被打满。 正确做法:用for循环 +sleep()模拟真实用户串行操作,或用多个 VU 分担 。 -
坑2:
check()里不要做耗时操作check()函数必须在毫秒级完成,否则会拖慢整个 VU。曾有人在check()里调用JSON.parse(res.body)两次(一次取data.total,一次取data.items),导致单次请求耗时增加 12ms。 正确做法:const json = res.json();一次解析,多次复用 。 -
坑3:Docker 运行时的时区与 DNS 问题
k6官方镜像基于alpine,DNS 解析默认用musl libc,在某些 Kubernetes 环境下会间歇性失败。解决方案:启动容器时加--dns 8.8.8.8,或改用k6:latest-slim(基于 Debian)。时区问题则加-e TZ=Asia/Shanghai。 -
坑4:
setup()中的大对象内存泄漏setup()返回的对象会一直驻留在内存中供所有 VU 访问。如果在里面fs.readFileSync('1GB-file.json'),100 个 VU 会共享这 1GB,但 K6 的 JS 引擎 GC 不会及时回收。 正确做法:setup()只做轻量初始化,大数据用流式读取或分片加载 。
最后分享一个小技巧:我们把所有 K6 脚本的 options 配置抽离成 config.js ,用 import config from './config.js' 引入。这样,同一套脚本可以无缝切换“冒烟测试”(10 VU)、“基线测试”(100 VU)、“峰值测试”(500 VU)三种模式,只需改一行 import config from './config.prod.js' 。三年来,这套机制让我们压测准备时间从平均 3 天缩短到 2 小时,真正实现了“性能左移”。
更多推荐


所有评论(0)