用代码写压测:k6如何颠覆传统性能测试工具
先聊点题外话。我在软件测试这行摸爬滚打也有十年了早年间的压测生涯基本是被 JMeter 和 LoadRunner 统治的。后来接触了 k6第一感觉是“这玩意儿怎么连个 GUI 都没有”但真正跑完一轮场景、看完那份 HTML 报告之后我承认自己有点“真香”了。如果你正在为性能测试工具选型发愁或者被 JMeter 那套线程组和监听器的组合搞得有点腻了这篇文章应该能给你一个完全不一样的视角。k6 是 Grafana Labs 开源的新一代性能测试工具它的核心卖点其实浓缩在四个字里用代码写压测。测试脚本是一个 JS 文件压测过程是命令行里敲一行指令结果输出是一堆标准化的指标和一份可交互的 HTML 报告。它能做负载测试、压力测试、稳定性测试也能做容量验证。更关键的是它可以非常自然地嵌入 CI/CD 流水线每次提交代码都自动跑一遍“轻量级压测”把性能问题扼杀在发布之前。这篇东西适合所有正在做性能测试的人无论是刚入行的功能测试同学想扩展技能树还是资深测试开发想找一个更顺手的压测工具都应该能从里面捞到点干货。我不打算给你把官方文档抄一遍而是把我从项目踩坑里总结出来的经验、选型思路、实操步骤以及那些文档里不会明说的细节全部摊开讲一遍。1. 为什么是 k6从 LoadRunner 到 k6 的性能测试工具演变1.1 传统性能测试工具到底“重”在哪在聊 k6 之前得先说说传统的性能测试工具有多让人头疼。早年间团队用 LoadRunner那是一个从安装到使用都极其“仪式感”十足的大家伙。安装 Controller、安装 Load Generator、录制脚本、关联动态参数、配置场景、跑完还要用 Analysis 出一堆让人看不懂的图。且不说 License 价格极其感人光是让新人在一周内学会“跑通一个脚本”就已经非常困难。更麻烦的是LoadRunner 的脚本录制本质上是代理抓包录出来的脚本里全是乱七八糟的协议会话数据稍微遇到点动态 token 就要手动去关联那个定位过程简直就是一场灾难。JMeter 的情况好一些毕竟开源免费图形界面也直观。但 JMeter 的问题在于它骨子里是一个“重客户端”工具。你在本地打开 JMeter 的 GUI拖拖拉拉建线程组、配采样器、加监听器每一步鼠标点击都在生成一个巨复杂的 XML 测试计划。这个 XML 文件在团队协作里就是个灾难源——你很难用 git diff 去审查测试计划的变更因为里面全是坐标位置和 UI 布局信息根本不是给人看的逻辑代码。我见过太多团队压测脚本的维护成本比被测系统的业务代码还高。还有个根本性的问题是并发模型。JMeter 的线程组模型是“一个线程模拟一个用户”当你需要模拟几千上万并发用户时线程的创建销毁、上下文切换、内存占用都会成为压测机本身的瓶颈。经常出现的结果是你还没把被测系统压垮施加压力的那台机器自己先垮了。1.2 k6 用 Go 和 JavaScript 解决了什么k6 与这些老前辈最大的不同在于它从架构层面就改变了压测的思路。整个 k6 的引擎内核是 Go 语言写的而用户的测试脚本是 JavaScript更准确地说是 ES6 语法。这个组合有点意思Go 负责高并发、低资源消耗的压测引擎JS 负责让用户拥有描述业务场景的灵活性。Go 的 goroutine 比操作系统的线程轻量得多单机可以轻松拉起成千上万个虚拟用户VU内存占用也比 Java 系的 JMeter 小几个数量级。在实战中我用一台 4C8G 的普通云主机就用 k6 单机跑出了近两万的并发连接这在 JMeter 上几乎是不可想象的。而 JavaScript 脚本的好处就更直观了你可以把压测脚本当成普通代码一样管理、审查、复用、拼接所有流程都写在 check 和 sleep 里一目了然。它天然就是一个“代码优先”的工具git diff 能看到逻辑变更拉分支改参数也不心疼测试脚本本身就是被测项目的一部分。1.3 我眼中 k6 的核心优势清单我按自己的体验给 k6 的优势排了个序不一定全面但每一条都在实际项目中给我省过时间学习曲线低只要会写一点点 JS入门基本没有压力不用懂 Java、XML、JMeter 里的那些组件模型。资源占用小压测机不用堆很高配置就能起大规模虚拟用户省钱省事。脚本即代码可以用 Git 管理、Code Review、模块化复用、集成到 CI这是从根本上改变性能测试协作方式。内置指标完备http_req_duration、http_reqs、http_req_failed、iterations 等指标开箱即用全部标准化不用自己去解析原始结果。阈值门禁机制可以在脚本里直接定义性能基线比如“95% 的请求延迟必须低于 500ms”跑完自动判定 PASS/FAIL适合做发布门禁。云原生和 CI/CD 友好命令行工具本质就是 Linux 二进制Docker 镜像官方维护K8s 里跑也毫无压力。2. 几个硬核概念先把它吃透2.1 虚拟用户 VU、迭代 iteration 与场景执行器k6 的核心执行模型围绕三个东西展开VUVirtual User虚拟用户、Iteration迭代、Executor执行器。VU 就是并发模拟的“人”每个 VU 独立执行你在脚本 default function 里定义的业务流程。一个 VU 并不是“一个 TCP 连接”它更像一个持续运行的协程不断地循环执行代码块。每一次完整地执行完 default function就算一次 iteration。比如你写了一个登录-下单-支付接口串起来的脚本跑完这三步就是一次迭代。关键在于执行器也就是控制 VU “怎么跑”的策略。k6 有多种执行器最常用的两个是 shared-iterations 和 ramping-vus。shared-iterations 适合“快速完成固定数量的任务”比如“我一共要跑 10000 次迭代用 50 个 VU 分着跑”适用于接口冒烟压测和功能回归验证。ramping-vus 则是我在真正做容量测试时最喜欢用的执行器因为它支持配置多个阶段逐步增加并发、稳定运行、逐步释放。这种“波浪式”的施压模式更贴近真实用户流量也更不容易因为瞬间高并发把系统直接打死能让性能瓶颈暴露得更平滑、更可观测。2.2 检查Checks与阈值Thresholds的区别和误用很多新手会把检查和阈值搞混其实两者的定位完全不同。check 是“请求级别的断言”比如“状态码必须是 200”“响应时间应该小于 800ms”它是用来标记单次请求成功与否的跑完汇报一个通过率。而 threshold 是“全局级别的门禁”它判断整个压测过程是否满足性能目标比如“p95 延迟小于 500ms”“错误率小于 1%”所有阈值全部通过才会在进程退出时标记为 0否则标记为 1。这个区分特别关键因为 check 的失败和 threshold 的失败代表的意义完全不一样。check 失败说明“业务逻辑有异常”threshold 失败说明“性能容量不达标”。在 CI 流水线里做门禁必须要用 threshold因为脚本 exit code 是由 threshold 决定的。如果你只在代码里用 check 断言即使所有的 check 都红了k6 的退出码照样是 0流水线直接就给你放行了那个坑我踩过一次印象非常深刻。2.3 为什么不要把参数写在代码里环境变量的使用姿势写压测脚本不是写业务代码场景是在不断变化的。用户量、持续时间、目标接口地址、压测环境这些参数如果在代码里写死每次变更都要改脚本。更好的方式是全部通过环境变量注入脚本里用 __ENV 读取。比如 const BASE_URL __ENV.BASE_URL || http://localhost:8080; 这样不管压测环境是测试服还是预发服命令行里传一个环境变量就够了。还有一点要注意环境变量的取值在脚本执行过程中是只读的在 init 阶段读取并保存在全局变量里不要在 default 函数中反复读取避免无谓的变量解析开销。3. 从零开始手写一个压测脚本并跑起来3.1 安装与最小可用脚本先说安装k6 的安装方式极其简单几乎是零依赖。我用的是 macOS直接 brew install k6一行命令搞定。Linux 环境可以用官方提供的 .deb 或 .rpm 包Windows 可以用 choco install k6或者直接下载官方 release 的 zip 包解压就能跑。不需要配置环境、不需要装 JDK、不需要启动什么 daemon 服务这就是一个纯粹的单个二进制文件。然后是第一个脚本官方文档里那个 HTTP 接口压测的例子我稍微改了一下加了些实际生产里的配置import http from k6/http; import { check, sleep } from k6; export const options { stages: [ { duration: 30s, target: 20 }, { duration: 1m, target: 50 }, { duration: 30s, target: 0 }, ], thresholds: { http_req_duration: [p(95)500], http_req_failed: [rate0.01], }, }; export default function () { const params { headers: { Content-Type: application/json, Authorization: Bearer ${__ENV.TOKEN}, }, }; const payload JSON.stringify({ userId: 12345, action: query, }); const res http.post(${__ENV.BASE_URL}/api/order/query, payload, params); check(res, { status is 200: (r) r.status 200, response time 800ms: (r) r.timings.duration 800, }); sleep(1); }然后命令行执行k6 run -e BASE_URLhttps://staging.example.com -e TOKENxxxxx script.js这里有几个设计细节值得展开讲讲。第一我把 BASE_URL 和 TOKEN 都做成环境变量这样同一个脚本可以直接压测试环境和预发环境不需要复制一份出来改。第二每个 VU 在循环里 sleep(1) 是为了模拟真实用户的思考时间避免请求率过高导致压测机本身成为瓶颈。第三thresholds 里设置的 p(95)500 和 rate0.01 就是整个压测的通过标准输出结果里会明确显示 PASS / FAIL非常直观。3.2 从接口列表生成压测脚本的套路有些场景下接口比较多一个个手写太繁琐。我通常会先准备一个 CSV 文件里面列出来接口路径、方法、请求体、权重等字段然后写一个简单的生成器脚本批量产出 k6 的测试文件。核心是利用 k6 内置的 CSV 读取模块把用例数据动态加载进来import { SharedArray } from k6/data; import papaparse from https://jslib.k6.io/papaparse/5.1.1/index.js; const csvData new SharedArray(api cases, function () { return papaparse.parse(open(./api_cases.csv), { header: true }).data; }); export default function () { const caseItem csvData[Math.floor(Math.random() * csvData.length)]; const res http.request(caseItem.method, ${__ENV.BASE_URL}${caseItem.path}, caseItem.body); check(res, { status is 2xx: (r) r.status 200 r.status 300 }); }这种方法特别适合接口数量很多、且每个用例数据相对独立的场景。要注意的一点是SharedArray 只有在 init 阶段执行一次数据在全 VU 间共享可以避免在 JS 代码里直接用全局数组导致的内存复制开销。3.3 参数计算实例怎么根据目标 QPS 反推 VU 数这是面试里常问的一个点也是实际测试中经常用到的换算。假设我们现在接收到的需求是系统需要支持 500 QPS平均响应时间 100ms问需要多少 VU 才能压到这个量级先说公式QPS 和 VU 数、平均响应时间以秒为单位之间有一个简单的换算关系QPS VU /平均响应时间 思考时间为什么因为在一个稳定的压测过程中每个 VU 的工作周期 发出请求 等待响应 思考时间。请求发出后这个 VU 就在等响应响应返回后如果没有 sleep 就会立刻发下一个请求于是单个 VU 在单位时间最多发1 / 平均响应时间个请求。如果加了 sleep就是 1 /平均响应时间 sleep时间。好回到上面的例子。平均响应时间 100ms 0.1 秒如果脚本里不加 sleep每个 VU 每秒最多发 10 个请求。要达到 500 QPS需要的 VU 500 / 10 50 个。这里有一个隐藏的坑当你实际压测时填加了负载之后平均响应时间会上升。假设在 50 并发下平均响应时间涨到了 300ms那么实际 QPS 就变成 50 / 0.3 ≈ 167根本到不了 500 QPS。这就是容量测试中常见的“并发数不等于 QPS”的陷阱。所以在实际执行时我会先以一个较低的 VU 数起步逐步增加直到 QPS 达到目标且响应时间在指标范围内为止。如果你用的是 ramping-vus 执行器可以通过 stages 分阶段加并发比如每 30 秒增加 10 个 VU同时观察 QPS 曲线直到曲线不再线性增长或响应时间开始明显恶化那一点往往就是系统的瓶颈点。3.4 结果报告怎么看用 HTML 报告和原始 JSON 做分析k6 默认跑完会在终端打印一个标准的摘要包含迭代次数、http_req_duration 的分位数、http_reqs 的每秒速率。但终端摘要的信息密度比较低真正的分析需要更多视角。我通常采用两种方式保存结果。一种是生成 HTML 报告k6 run script.js --summary-trend-statsavg,min,med,p90,p95,max跑完得到开箱即用的 HTML 报告里面有响应时延分布图、请求速率曲线、错误率统计拿来做团队汇报特别方便。另一种是导出原始 JSONk6 run script.js --out jsonfull_run.json然后把 JSON 喂给 Grafana 或者 Python 脚本做深度分析。我个人更推荐把原始数据入库。k6 支持直接输出到 InfluxDB然后通过 Grafana 实时看板监控整个压测过程。如果你所在团队已经有一套路可观测性体系这一招会让你瞬间拥有和业务监控同级的压测监控看板。4. 把 k6 接进你的测试体系与 CI/CD 流水线4.1 本地压测怎么和 GitLab CI 结合接 CI 是 k6 最爽的场景没有之一。把压测脚本放在项目仓库的 tests/perf 目录下然后在 .gitlab-ci.yml 里加一个 performance 阶段的任务这样每次合并请求都会自动执行一次小型压测。一个最小可用的 GitLab CI 配置长这样performance-test: stage: test image: grafana/k6:latest script: - k6 run tests/perf/smoke.js --tag commit$CI_COMMIT_SHORT_SHA only: - merge_requests要注意的是在 CI 里跑压测和本地跑不一样没有交互式终端无法直接看到实时输出。所以脚本里必须做好两个保障一是用 threshold 给整个测试设定 pass/fail 门槛二是把结果指标导出到外部存储做历史追踪。否则压测跑完了根本没法判断这轮到底合格没有。共享基础镜像的时候grafana/k6 这个官方镜像会在容器启动时自动执行 /scripts 目录下的脚本也可以直接用 k6 run 指定其他路径灵活度很高。4.2 阈值门禁加上“历史对比”才有意义很多团队接 CI 压测只是看了一下 threshold 红了没有这其实远远不够。性能问题很多不是“直接爆炸”而是“逐渐劣化”。昨天 p95 是 450ms今天变成了 490ms虽然都在 500ms 的阈值内但趋势明显在恶化。所以我建议在 CI 中把每次压测的原始 JSON 存到同一个存储目录然后用一个小脚本对比最近几次运行的 p95 和错误率变化一旦发现劣化趋势超过 5%就自动在 Merge Request 里添加一条提醒。这一套思路其实不需要特别复杂的工具Python 脚本或者 Node 脚本都能实现。核心是把性能测试从“一次性的验证”变成“持续性的监控”这比任何花哨的报告都更有工程价值。4.3 多云环境和 K8s 集群里的分布式压测单机 k6 的能力虽然强悍但也有上限。当你需要压测的量级已经能压满单机带宽或者连接数时就得考虑分布式。k6 官方提供了一种 Kubernetes Operator 是运行分布式压测的标准参照方案在云原生的环境里这种方式可以快速拉起多个 Pod 同时向被测系统施压结束后统一回收。另一种我实测过的方案是直接在压测机上跑多个 k6 进程每个进程用不同的分段参数比如 --vus 500 --duration 10m然后再把多份结果汇总起来分析。这种办法简单粗暴适合不想引入太多运维成本的场景。但是要注意的是分布式压测的前提是压测带宽和压测机本身的资源不再是瓶颈否则你压测结果就会失真到惨不忍睹。5. 常见问题与排查技巧实录5.1 “为什么我的并发数上不去”这是我被问得最多的一个问题。并发上不去首先要排除压测机自身的瓶颈。k6 虽然轻量但当 VU 数量超过一定量级时单机的文件描述符file descriptors不够用是最常见的坑。Linux 下默认的 ulimit -n 通常是 1024这在小并发下无所谓但当你需要几千并发的时候1024 这个限制会直接卡死你。解决办法ulimit -n 65535 # 或永久修改 /etc/security/limits.conf另外要检查的是系统网络参数。高并发压测容易触发 TCP 端口耗尽问题。CoreOS/Ubuntu 上可以调整的典型参数是 net.ipv4.ip_local_port_range 和 net.ipv4.tcp_tw_reuse。如果不调整你会看到大量 socket bind 失败或者连接超时的报错造成测试结果严重失真。5.2 压测结果忽高忽低波动很大怎么回事大概率是你的压测脚本里混入了太多随机行为。最常见的是 Math.random() 产生的随机等待时间、随机数据体导致请求分布极不均匀某些瞬间流量特别大某些瞬间特别小。k6 本身提供了一个相对稳定的执行频率但如果你在脚本里加入大量随机逻辑它就会变成不稳定因素。我一般会这样做保留业务必须的随机性比如随机从 CSV 挑选用户 ID但是把随机 sleep 改成固定 sleep同时控制请求体大小在一个稳定的范围内。这样压测结果波动会明显缩小瓶颈定位也更清晰。5.3 为什么响应时间和接口监控看到的数字对不上这个问题几乎是每个性能测试都会碰到的。原因有很多压测机与被测系统之间的网络延迟、被测系统前面的网关和负载均衡排队、CDN 缓存命中率。更隐蔽的是 k6 测试请求的 URL 和线上真实流量的 URL 不一样导致混合负载模式不一致。如果发现响应时间对不上可以试着把压测机放在和被压系统同一个内网减少网络跳数同时在脚本里启用 http_debugtrue 输出详细的请求日志对比每一个中间节点的耗时。还有一个非常值得留意的点如果你在压测环境前端挂了限流器比如 API 网关的限流策略你的测试结果其实根本不是在测系统容量而是在测网关的限流规则。5.4 一个被很多人忽略的坑脚本里的全局变量污染k6 的每个 VU 其实是一个独立的 JS 运行时VU 之间不会共享变量。但是同一个 VU 内部default function 会反复执行。如果你在 default function 外面定义了一个“普通对象”作为缓存这个对象会一直在 VU 的整个生命周期内存在。初次跑没问题但如果脚本里有类似的逻辑第二次迭代时所有 VU 共享的这些对象就会互相覆盖或者残留脏数据。解决这个问题很简单不要在模块顶层定义 mutable 的全局对象除非你用 SharedArray 这种专门为跨 VU 共享设计的结构。能用局部变量就用局部变量不要在 default 函数外做有状态操作。5.5 常见问题速查表问题现象可能原因快速排查/解决并发数上不去ulimit 文件描述符限制检查 ulimit -n临时调大或改 limits.conf大量连接超时/重置TCP 端口耗尽调整 ip_local_port_range、tcp_tw_reuse压测机 CPU/内存飙升压测机配置不足降低 VU 数、提升 sleep、使用分布式压测请求成功率异常下降被测系统触达瓶颈查看响应码分布定位是 5xx 还是 4xx结果波动极大脚本随机性过强固定 sleep 时间控制请求体随机范围阈值全部通过但线上不行测试流量模型和真实场景差异大增加思考时间、增加业务比例使用生产数据样本回放CI 里压测结果不稳定共享 CI Runner 资源竞争使用独立的专用 Runner 或独享的物理机6. 进一步扩展从压测工具到性能工程当你真的把 k6 用熟练以后你会发现它的意义远不止是“替代 JMeter”。它背后代表的是一个“性能工程”的思路把性能测试从最后上线前的玄学验证变成日常开发循环里的一环。脚本即代码让测试人员、开发人员、运维人员能在同一个仓库里协作性能基线变成 CI 门禁的一部分任何代码变更都跑不掉性能回归的检查。我还试过把 k6 和 Prometheus 集成在 Grafana 上做长期的性能趋势监控。通过 k6 的 Prometheus remote write 输出插件可以直接把每次压测的指标发送到 Prometheus然后利用 Prometheus 的 TSDB 存储能力和 Grafana 的告警机制设定“p95 延迟超过阈值持续 5 分钟”就自动告警。这个方案的好处是压测数据和生产监控数据落在同一套系统里天然就具备长期趋势分析的能力。如果你所在团队已经在用 Grafana 全家桶那么 k6 的融入几乎是顺理成章的事情。Grafana Cloud 也内置了 k6 的云压测方案可以按需生成虚拟用户不用自建压测机对有短期大流量压力测试需求的团队来说省掉了相当多基础设施的维护成本。这套东西做下来性能测试就不再是一个“项目启动前拍脑袋跑一轮”的仪式而是真正变成了软件研发周期里一条可靠的质量防线。这也是我后来逢人就推荐 k6 的根本原因它改变的不只是工具链而是测试团队的工作方式。最后分享一个小技巧如果你刚刚开始学习 k6不要急着写复杂场景先在本地起一个简单的 HTTP 服务比如用 Python 的 http.server然后用 k6 压一下它体验一下工具本身的反馈节奏。先把工具手感摸熟再上真实业务场景你会少踩很多不必要的坑。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →