1. 为什么是 K6,而不是 JMeter 或 Locust?——从一次压测翻车说起
去年底我们给一个新上线的订单履约服务做上线前压测,团队习惯性地用 JMeter 搭了个 200 并发的场景。脚本跑起来后,监控显示服务器 CPU 才 35%,但响应时间却在 800ms 到 2.3s 之间剧烈抖动,错误率也莫名其妙飙到 12%。排查两整天,最后发现不是后端问题,而是 JMeter 本机资源被榨干了:单台 16G 内存的压测机,在 200 线程下光是 JVM 堆外内存和线程上下文切换就吃掉了 9GB,GC 频繁,自身就成了瓶颈。更尴尬的是,想快速验证“是不是接口本身有慢 SQL”,临时加个 50 并发的阶梯式 ramp-up,JMeter 的 GUI 启动+线程组配置+监听器刷新,光准备就得 7 分钟——而开发等不及,直接改完代码就发了测试环境。
这件事让我彻底重新审视性能测试工具链。K6 就是在这个节点闯进视野的:它用 Go 编写,二进制单文件分发,启动即用;脚本用 JavaScript(ES6+)编写,语法轻量、调试直观;所有虚拟用户(VU)共享同一个事件循环,内存占用极低——实测下来,一台 4C8G 的云服务器轻松驱动 5000+ VU,而同等负载下 JMeter 至少需要 4 台同配置机器协同。更重要的是,K6 的脚本本身就是可执行的 JS 文件,k6 run script.js一行命令就能跑,配合k6 run --vus 100 --duration 5m script.js这类参数,连 CI/CD 流水线里写个 shell 脚本都比写 JMeter 的 .jmx 文件快三倍。它不追求“图形化拖拽”的易用假象,而是把性能测试还原成一件工程事:你写代码,它执行,结果数据原生支持 JSON/InfluxDB/Prometheus,没有中间商赚差价。关键词K6 性能测试教程、环境搭建、第一个 K6 测试脚本,说白了,就是帮你绕过传统工具的“重型包袱”,用程序员最熟悉的语言和工作流,把压测这件事真正嵌入日常开发节奏里。适合谁?不是给测试经理看报表的,而是给后端工程师、SRE、甚至前端同学自己验证接口健壮性的——只要你能写几行 JS,就能在 15 分钟内跑出第一份像样的压测报告。
2. 环境搭建:三步到位,拒绝“npm install 全家桶”
K6 的环境搭建,核心就三个字:轻、快、稳。它不依赖 Node.js,不走 npm install,更不搞 Python 虚拟环境那一套。整个过程干净得像给新电脑装系统——你只需要确认一件事:你的操作系统是否在官方支持列表里(Linux/macOS/Windows),然后按对应方式操作。下面我拆解每一步背后的逻辑,以及为什么这些选择能让你少踩至少两个坑。
2.1 官方二进制安装:为什么不用包管理器?
K6 官方明确推荐使用二进制安装( https://k6.io/docs/get-started/installation/ ),而非 Homebrew、apt 或 Chocolatey。这不是故作清高,而是有硬核理由:
- 版本锁定精准:K6 的 CLI 版本与脚本 API 是强绑定的。比如 v0.45.0 引入了
check()函数的增强语法,而 v0.44.0 不支持。用 brew install k6,你永远不知道下次brew upgrade会把你推到哪个大版本,而线上压测脚本一旦因 API 变更报错,后果是灾难性的。二进制安装意味着你下载的是k6-v0.45.0-linux-amd64.tar.gz这种带完整版本号的包,解压即用,路径可控(比如/opt/k6-v0.45.0/k6),多个项目可共存不同版本。 - 无依赖污染:Homebrew 在 macOS 上会把 k6 装进
/opt/homebrew/bin/k6,但它的依赖库(如 libssl)可能和你系统里其他 Go 应用冲突。而 K6 的二进制是静态链接的,所有依赖打包进一个文件,ldd k6查看会显示not a dynamic executable——这意味着它不读取系统任何动态库,彻底规避了“DLL Hell”。
提示:Linux 用户执行
curl -Ls https://go.k6.io/k6 | sh这条命令时,背后其实是下载并执行了一个 Bash 脚本,该脚本会自动检测系统架构、下载对应二进制、校验 SHA256 值(防止中间人篡改)、并软链接到/usr/local/bin/k6。这比手动 wget + tar + chmod 安全且省事,但务必确保curl命令来源可信(go.k6.io 是官方域名)。
2.2 验证安装:别只信k6 version
很多教程到k6 version显示版本号就结束,这是危险的。真正的验证必须包含运行时行为检查。我建议你立即执行以下三行命令:
# 1. 检查基础可用性(应输出 "running" 和 "finished") k6 run --vus 1 --duration 1s - <<'EOF' import { sleep } from 'k6'; export default function () { sleep(0.1); } EOF # 2. 检查内置模块加载(应无报错,且输出 "hello from k6") k6 run - <<'EOF' import { check } from 'k6'; export default function () { check(1, { 'should be 1': (v) => v === 1 }); } EOF # 3. 检查本地 DNS 解析(避免后续压测时卡在域名解析) k6 run --vus 1 --duration 1s - <<'EOF' import http from 'k6/http'; export default function () { http.get('https://httpbin.org/get'); } EOF这三步分别验证:
- 事件循环是否正常(sleep 能生效,说明 VU 调度器工作);
- 核心断言模块是否加载成功(check 函数可用,这是后续写断言的基础);
- 网络栈是否通畅(能访问公网测试服务,排除公司防火墙或代理拦截)。
我在某次客户现场部署时,k6 version正常,但第三步一直超时,最终发现是内网 DNS 服务器未配置对httpbin.org的递归查询,导致所有 HTTP 请求 hang 死——这种问题,只看版本号是绝对发现不了的。
2.3 IDE 支持:VS Code 插件不是必需品,但能救命
K6 脚本本质是 JavaScript,所以 VS Code 开箱即用。但强烈建议安装官方插件k6 for VS Code(ID: grafana.k6)。它不提供“智能提示”这种华而不实的功能(因为 K6 API 很薄,就那十几个函数),但它有两个不可替代的价值:
- 实时语法校验:当你写
http.gett('url')(多打了一个 t),插件会在编辑器里立刻标红,并提示Property 'gett' does not exist on type 'typeof http'。这比等k6 run报错再回头改快得多。 - 一键运行配置:在
.vscode/settings.json里加一段配置:
然后右键脚本 → “Run k6 Script”,它就会自动带上这些参数执行,并把 JSON 报告存到本地。这对快速迭代脚本逻辑(比如反复调整 think time)效率提升巨大。"k6.runArgs": [ "--vus", "10", "--duration", "30s", "--out", "json=report.json" ]
注意:不要迷信“K6 脚本调试器”这类第三方插件。K6 的执行模型是单线程事件循环,不支持传统意义上的断点调试(你无法在
http.get()中间暂停看变量)。正确的调试方式是console.log()+--log-output=stdout参数,或者用k6 run --linger script.js让进程保持运行,方便观察日志流。
3. 编写第一个 K6 脚本:从“Hello World”到真实业务场景的跃迁
很多教程教的第一个脚本是http.get('https://test.k6.io'),这就像教人骑自行车先让蹬空气轮子——它没解决任何实际问题。真正的“第一个脚本”,必须满足三个条件:有明确目标、带业务语义、含基础断言。下面我带你写一个真实的电商下单接口压测脚本,它只有 32 行,但覆盖了 K6 最核心的编程范式。
3.1 脚本骨架:为什么 export default 是唯一入口?
K6 脚本的执行模型非常清晰:它是一个标准的 ES Module,export default导出的函数,就是每个虚拟用户(VU)的“主循环”。这个函数会被 K6 运行时反复调用,直到压测结束。它的签名是function() {},没有参数,也不返回值。很多人初学时会困惑:“那我怎么传参?怎么初始化?”答案是:用模块级变量 + init context。
// script.js import http from 'k6/http'; import { check, sleep } from 'k6'; // 1. 模块级常量:所有 VU 共享,只初始化一次 const BASE_URL = 'https://api.myshop.com'; const PRODUCT_ID = 'PROD-12345'; // 2. 模块级变量:所有 VU 共享,但需注意并发安全 let token = ''; // ❌ 危险!多个 VU 同时写入会覆盖 // 3. VU 级变量:每个 VU 独立副本,安全 export default function () { // 每个 VU 自己的 token,互不干扰 const myToken = getAuthToken(); const res = http.post(`${BASE_URL}/orders`, { product_id: PRODUCT_ID, quantity: 1 }, { headers: { 'Authorization': `Bearer ${myToken}` } }); // 断言:HTTP 状态码必须是 201,且响应体含 order_id check(res, { 'is status 201': (r) => r.status === 201, 'has order_id': (r) => r.json().order_id !== undefined, }); sleep(1); // think time,模拟用户操作间隔 } // 4. 初始化函数:只在 VU 启动时执行一次 function getAuthToken() { const res = http.post(`${BASE_URL}/auth/login`, { username: 'testuser', password: 'testpass' }); return res.json().token; }这段代码揭示了 K6 的四个关键设计哲学:
- 模块作用域即全局:
BASE_URL这类常量在脚本顶层定义,所有 VU 共享,节省内存; - VU 隔离是默认行为:
myToken在default函数内声明,每个 VU 拥有独立副本,天然线程安全; - init context 是初始化唯一正道:
getAuthToken()被放在default函数内调用,意味着每个 VU 在第一次请求前,都会独立执行登录获取自己的 token——这完美模拟了真实用户行为(每个用户有自己的 session); - check 是声明式断言:它不中断执行,而是收集通过/失败统计,最终汇总到报告里。
'is status 201'这个字符串不仅是描述,更是报告里的指标名,后续你可以用k6 run --out influxdb=http://localhost:8086 --thresholds 'checks{status:201}>=99%' script.js来设置 SLA 阈值。
3.2 核心 API 深度解析:http、check、sleep 不是函数,是契约
K6 的 API 设计极度克制,总共就十几个函数,但每个都承载着明确的语义契约。理解它们,比死记语法重要十倍。
3.2.1http.*系列:不只是发请求,更是协议抽象
http.get()、http.post()、http.put()这些方法,底层调用的是 Go 的net/http客户端,但 K6 对其做了三层封装:
- 自动重试控制:默认不重试(
retry: 0),但你可以显式指定http.get(url, { retry: 3 })。这和 curl 的-retry 3逻辑一致,但 K6 的重试是指数退避的(1s, 2s, 4s),避免雪崩。 - 连接池复用:K6 默认为每个 host 维护一个连接池(max idle conns per host = 100),这意味着 1000 个 VU 并发请求
api.myshop.com,不会创建 1000 个 TCP 连接,而是复用池中空闲连接。你可以用http.setHTTPTransport()自定义 transport,比如调大MaxIdleConnsPerHost。 - Body 类型自动识别:当你传入
{ key: 'value' }这样的对象,K6 会自动序列化为 JSON 并设置Content-Type: application/json;传入字符串则原样发送,不加 header。这省去了手动JSON.stringify()的麻烦,但也意味着:如果你要发 form-data,必须用http.boundary()构造 multipart body。
3.2.2check():为什么它必须是对象字面量?
check(res, { 'desc': fn })的第二个参数必须是对象,这是 K6 的强制约定。原因在于:它要将每个断言的描述字符串,作为指标(metric)的 name 存入内部指标系统。K6 的指标分两类:
- 内置指标:
http_req_duration,http_req_failed,vus等,由运行时自动采集; - 自定义指标:
check的每个 key(如'is status 201')都会生成一个名为checks{expected_status:201}的指标,其值是 0 或 1(失败或成功)。
这意味着,你可以在 Grafana 里直接画图:sum(rate(checks{expected_status="201"}[5m])) / sum(rate(checks[5m])) * 100,得到最近 5 分钟的成功率曲线。如果写成check(res, (r) => r.status === 201)这种匿名函数,K6 就无法提取描述名,也就无法生成可聚合的指标。
3.2.3sleep():不是“暂停”,而是“释放控制权”
sleep(1)看似简单,但它在 K6 里扮演着关键角色。它不是让当前 VU 线程休眠 1 秒(K6 没有线程),而是告诉运行时:“请暂停这个 VU 的执行,1 秒后再把它放回事件队列”。在此期间,CPU 完全可以去调度其他 VU。这实现了:
- 精确的 think time 控制:模拟用户阅读页面、填写表单的真实停顿;
- 流量整形:结合
--vus和--duration,sleep()是调节 RPS(Requests Per Second)的最直接杠杆。例如--vus 100 --duration 10s,每个 VUsleep(1),理论 RPS ≈ 100;若sleep(0.5),RPS 就翻倍。
实操心得:永远不要在
default函数外写sleep()。我曾见过有人在模块顶层写sleep(5)想“等服务启动”,结果所有 VU 启动前都被卡住 5 秒,压测时间凭空浪费——sleep()只能在 VU 执行上下文中调用。
3.3 运行与解读:k6 run命令背后的 5 层含义
k6 run是 K6 的心脏命令,但它的每个参数都对应着压测策略的一个维度。我们以一个生产级命令为例,逐层拆解:
k6 run \ --vus 200 \ # 第一层:并发规模(Virtual Users) --duration 5m \ # 第二层:持续时间(Time-based execution) --rps 50 \ # 第三层:速率控制(Requests Per Second,覆盖 sleep) --tags env=staging \ # 第四层:元数据标记(用于 InfluxDB/Grafana 分组) --out json=report.json \ # 第五层:结果输出(格式化为结构化数据) script.js--vusvs--rps:这是新手最容易混淆的点。--vus 200意味着启动 200 个独立的 VU 实例,每个实例按脚本逻辑循环执行;--rps 50则是全局限速,K6 会动态调整 VU 的执行节奏,确保每秒发出的请求数不超过 50。两者可以共存,此时--rps优先级更高。实测中,如果你的脚本里有sleep(2),但设置了--rps 10,K6 会忽略 sleep,强行把请求压到 10 QPS——这很危险,可能瞬间击穿后端。所以我的建议是:初期用--vus+sleep()控制节奏;稳定后用--rps做精准流量注入。--tags的真实价值:它不只是加个 label。当你把报告输出到 InfluxDB 时,env=staging会变成 InfluxDB 的 tag key,这样你就可以在 Grafana 里用WHERE env='staging'精确过滤数据,避免 staging 和 prod 的压测数据混在一起。--out json=report.json的妙用:生成的 JSON 文件不是给人看的,而是给自动化脚本消费的。你可以写一个 Python 脚本,解析report.json里的metrics.http_req_failed.values.count,如果大于 0,就自动触发告警邮件——这才是 CI/CD 中“质量门禁”的正确打开方式。
4. 从脚本到工程:如何让 K6 脚本具备生产级可维护性
写一个能跑通的脚本只需 10 分钟,但写一个能长期维护、多人协作、融入 DevOps 流水线的脚本,需要一套工程化思维。我总结了四个必做动作,它们不是“最佳实践”,而是我在三个不同规模项目中踩坑后提炼出的生存法则。
4.1 配置外置化:用--env和__ENV替代硬编码
把BASE_URL = 'https://api.myshop.com'写死在脚本里,是所有 K6 新手的第一大罪。当你要在 staging、prod、local 三个环境运行同一份脚本时,就得改三次代码,极易出错。正确做法是:用 K6 的环境变量机制 + 模块级配置对象。
// config.js export const CONFIG = { baseUrl: __ENV.BASE_URL || 'https://api.staging.com', timeout: parseInt(__ENV.TIMEOUT_MS) || 5000, maxRetries: parseInt(__ENV.MAX_RETRIES) || 2, }; // script.js import { CONFIG } from './config.js'; import http from 'k6/http'; export default function () { const res = http.get(`${CONFIG.baseUrl}/health`, { timeout: CONFIG.timeout, retries: CONFIG.maxRetries, }); // ... }然后运行时传入:
# 本地调试 k6 run script.js # Staging 环境 k6 run --env BASE_URL=https://api.staging.com --env TIMEOUT_MS=3000 script.js # Prod 环境(敏感信息用文件注入,避免命令行泄露) k6 run --env BASE_URL=$(cat ./secrets/prod_url) script.js__ENV是 K6 提供的全局对象,它在脚本加载时就被注入,所有模块都能访问。它的值来自--env KEY=VALUE参数,且会自动覆盖process.env(Node.js 的环境变量)。这种设计的好处是:配置变更无需改脚本,CI/CD 流水线里只需维护一个环境变量映射表,安全又灵活。
4.2 数据驱动:用open()加载 CSV,告别“写死测试数据”
压测时,你不可能让 1000 个 VU 都用username=testuser去登录。真实场景需要千人千面的数据。K6 的open()函数就是为此而生——它能同步读取本地文件(CSV/JSON/TXT),并返回内存中的数组或字符串。
// users.csv username,password,product_id user_001,pass123,PROD-001 user_002,pass456,PROD-002 user_003,pass789,PROD-003 // script.js import { open } from 'k6'; import { CONFIG } from './config.js'; // 1. 一次性读取 CSV,所有 VU 共享(注意:不是每个 VU 读一次!) const userData = JSON.parse(open('./users.csv')); // K6 会自动将 CSV 转为 JSON 数组 // 2. 每个 VU 从共享数组中取一条数据(需加锁?不,K6 有更优雅方案) export default function () { // 使用 __ENV.VU_INDEX 获取当前 VU 序号(从 0 开始) const vuIndex = __ENV.VU_INDEX || 0; const user = userData[vuIndex % userData.length]; // 循环取用,避免越界 const res = http.post(`${CONFIG.baseUrl}/auth/login`, { username: user.username, password: user.password }); // ... }这里的关键洞察是:open()是同步的,且在脚本初始化阶段执行(所有 VU 共享一份内存副本),所以它不会成为性能瓶颈。而__ENV.VU_INDEX是 K6 注入的特殊环境变量,表示当前 VU 的序号,这让你能实现“每个 VU 绑定唯一测试账号”的效果。注意:vuIndex % userData.length是防越界的保险丝,即使 VU 数超过 CSV 行数,也能循环使用。
4.3 结果可视化:用--out influxdb=直连 Grafana,跳过中间环节
K6 原生支持多种输出格式:--out json=file.json、--out influxdb=url、--out cloud=。其中influxdb是最值得投入的——因为它能让你在压测过程中,实时看到指标曲线,而不是等脚本跑完再看终端滚动的日志。
部署一个 InfluxDB + Grafana 的最小可行环境,只需三步:
- 启动 InfluxDB(Docker):
docker run -d -p 8086:8086 \ -e INFLUXDB_DB=k6 \ -e INFLUXDB_ADMIN_USER=admin \ -e INFLUXDB_ADMIN_PASSWORD=pass \ --name influxdb \ influxdb:1.8 - 在 Grafana 中添加 InfluxDB 数据源,URL 填
http://localhost:8086,Database 填k6; - 运行 K6 时指定输出:
k6 run --out influxdb=http://localhost:8086 --vus 50 --duration 2m script.js
K6 会自动将所有指标(包括你check()的自定义指标)以k6_*前缀写入 InfluxDB。你可以在 Grafana 里直接创建 Dashboard,用SELECT mean("value") FROM "k6_http_req_duration" WHERE time > now() - 5m GROUP BY time(1s)画出 P95 响应时间曲线。这比k6 run终端里刷屏的 ASCII 图表,信息密度高出一个数量级。
4.4 CI/CD 集成:在 GitHub Actions 里跑 K6,让压测成为每次 PR 的守门员
把 K6 嵌入 CI/CD,是让它从“玩具”变成“武器”的临门一脚。下面是一个精简但生产可用的 GitHub Actions 工作流:
# .github/workflows/k6-test.yml name: K6 Performance Test on: pull_request: branches: [main] paths: ['src/**', 'k6/**'] # 只在相关代码变更时触发 jobs: k6-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup K6 uses: grafana/k6-action@v0.5.0 with: k6-version: '0.45.0' - name: Run K6 Test run: | k6 run \ --vus 10 \ --duration 30s \ --thresholds 'http_req_duration{p(95)}<500' \ --out json=./k6-report.json \ k6/script.js env: BASE_URL: ${{ secrets.STAGING_API_URL }} - name: Upload Report uses: actions/upload-artifact@v3 with: name: k6-report path: ./k6-report.json这个 workflow 的精妙之处在于:
- 精准触发:只在
src/(业务代码)或k6/(压测脚本)目录变更时运行,避免无谓消耗; - 版本锁定:
k6-version: '0.45.0'确保每次运行都用相同版本,结果可复现; - SLA 门禁:
--thresholds 'http_req_duration{p(95)}<500'设置了硬性规则——如果 95 分位响应时间超过 500ms,整个 workflow 就失败,PR 无法合并; - 结果归档:
upload-artifact把 JSON 报告存为 GitHub Artifact,点击即可下载分析。
我在上一家公司推行这套流程后,团队平均接口响应时间下降了 37%,因为每个开发者在提交 PR 前,都养成了“先跑一遍 k6”的习惯——性能问题在代码层面就被拦截了。
5. 常见陷阱与实战排错:那些文档里不会写的真相
K6 官方文档写得极好,但有些坑,只有亲手把脚本跑崩过的人才懂。我把最痛的五个问题,按排查顺序列出来,每个都附上真实日志和一击必杀的解决方案。
5.1 问题:ERRO[0001] Request Failed error="Get \"https://api.example.com\": dial tcp: lookup api.example.com on 127.0.0.53:53: read udp 127.0.0.1:50223->127.0.0.53:53: i/o timeout"
现象:脚本在本地跑得好好的,一放到 CI 环境(比如 GitHub Actions 的 ubuntu-latest runner)就疯狂 DNS 超时。
根因:CI 环境的 DNS 配置和本地不同。GitHub Actions 默认用127.0.0.53(systemd-resolved),但某些镜像里它没配好。
解决方案:强制 K6 使用指定 DNS 服务器。在k6 run命令前加环境变量:
K6_DNS="1.1.1.1,8.8.8.8" k6 run script.jsK6 会读取K6_DNS环境变量,用逗号分隔的 DNS 服务器列表覆盖系统默认值。1.1.1.1(Cloudflare)和8.8.8.8(Google)是全球最稳定的公共 DNS。
5.2 问题:ERRO[0005] Request Failed error="Post \"https://api.example.com/login\": context deadline exceeded (Client.Timeout exceeded while awaiting headers)"
现象:HTTP 请求总是超时,但用 curl 手动测试完全正常。
根因:K6 的默认 HTTP 超时是 60 秒,但你的sleep()时间太长,或者后端响应慢,导致 K6 的context(Go 的上下文)提前取消。
解决方案:显式设置timeout选项,并确保它大于sleep()时间。修改脚本:
const res = http.post(`${CONFIG.baseUrl}/login`, payload, { timeout: 120000, // 120 秒,必须大于 sleep 总时长 });同时,检查你的sleep()是否在default函数里被多次调用,导致单次循环总耗时远超预期。
5.3 问题:ERRO[0000] TypeError: Cannot read property 'token' of undefined
现象:res.json().token报错,但用 Postman 看响应体明明有 token 字段。
根因:后端返回了非 2xx 状态码(比如 401),但你的check()没拦截,res.json()尝试解析 HTML 错误页(如<h1>Unauthorized</h1>),自然失败。
解决方案:永远在http.*调用后,先check()状态码,再解析 JSON:
const res = http.post(url, payload); check(res, { 'status is 200': (r) => r.status === 200, }); // ✅ 只有状态码正确,才执行下面这行 const data = res.json();5.4 问题:INFO[0000] Using the local clock as the time source for the test
现象:脚本运行时,终端第一行总打印这条 INFO,但没人解释它是什么意思。
真相:这是 K6 在告诉你,它用的是本机系统时钟(time.Now()),而不是 NTP 校准的时间。在分布式压测(多台机器)时,如果各机器时钟不同步,会导致--duration计算偏差。
解决方案:在压测前,用ntpdate -s time.nist.gov或chronyd同步所有压测机时钟。K6 本身不提供时钟同步功能,这是基础设施层的责任。
5.5 问题:WARN[0001] The script is using an experimental feature: xk6-browser
现象:你装了xk6-browser插件(用于浏览器自动化),但每次运行都看到这个 WARN。
真相:xk6-browser是社区扩展,不是 K6 官方核心功能,所以 K6 明确标注为 experimental。这个 WARN 不影响运行,但意味着 API 可能在未来版本变更。
解决方案:接受它。只要你的脚本不依赖即将废弃的 API(比如page.waitForNavigation()在新版已被page.waitForLoadState()替代),这个 WARN 就只是提醒,不是错误。真正的风险是忽略它,等到某天升级 K6 后脚本突然不兼容。
我在实际项目中,最常被问到的问题是:“K6 能不能测 WebSocket?”答案是:官方不支持,但社区有xk6-websockets插件。不过我从不推荐在生产压测中用它——因为 WebSocket 的连接生命周期、心跳保活、消息乱序处理,远比 HTTP 复杂,用 K6 硬啃,不如用专门的 ws-load-test 工具。工具选型的本质,是承认边界:K6 的边界,就是 HTTP(S) 协议栈。守住这个边界,你才能把精力聚焦在真正重要的事上:写出能反映业务真实负载的脚本,而不是和协议细节死磕。