日常跑性能测试时,很多人都会遇到这样一个怪现象:压测工具本身明明没干多少事,可只要并发一高、时间一长,它自己先垮了。明明被测系统还稳稳的,k6 却开始卡顿、内存飙升,甚至直接被杀掉。这时候很多人第一反应是服务器资源不够,或者脚本写得有问题,但真正藏在背后的,往往是 JavaScript 引擎的垃圾回收机制和内存管理方式在“搞事情”。今天咱们就抛开那些晦涩的术语,用大白话把这件事聊透,重点说说在大批量并发请求下,k6 的垃圾回收和内存管理是怎么影响长时间测试稳定性的,以及我们可以怎么应对。

一、先搞清楚 k6 到底是个什么东西

1.1 k6 不是普通的小工具

k6 是一个开源的压力测试工具,它最大的特点是把测试脚本用 JavaScript 来写,但执行引擎却是用 Go 语言实现的。这个设计听起来很美好:既能享受 JavaScript 的灵活,又能获得 Go 的高并发能力。但这里头有个关键点,k6 并不是直接运行浏览器里的那种 JavaScript,而是通过一个嵌进去的 JavaScript 引擎来执行脚本。在 k6 里,这个引擎主要负责处理测试逻辑,比如发起请求、处理响应、控制循环等等。

1.2 垃圾回收是什么?用生活打个比方

想象一下你有一个储物间,每次测试的时候,你往里面放东西(比如变量、对象、临时数据),用完了之后如果不及时清理,储物间就会越来越满。垃圾回收机制就是一个自动清理的管家,它会定期看看哪些东西你已经不需要了,顺手帮你扔掉,腾出空间放新东西。听起来很不错吧?但在大批量并发场景下,这个管家反而可能成为麻烦制造者。

因为垃圾回收并不是一直不停地干活,它会在某个时间点突然启动,然后一顿猛扫。这个“突然”本身就是不稳定因素。你想想,如果测试正在关键时刻,管家突然冲进来翻箱倒柜,把正在用的东西挪来挪去,必然会影响你的工作节奏。放在 k6 里,表现就是响应时间出现莫名尖刺,或者请求速率突然下降。

二、大批量并发下,k6 的内存压力来源

2.1 每一个虚拟用户都在“存东西”

你用 k6 模拟大批量用户时,每个虚拟用户其实都在跑你写的脚本。脚本里只要写了 let res = http.get(...),那这个 res 对象就会被保存起来。如果后面对 res 做各种处理,比如 parse JSON、提取字段、断言判断,都会产生额外的临时对象。这些对象在短时间内大量产生,就像在储物间里堆了一堆一次性餐盒,用完了也不扔,就等管家来收拾。

2.2 全局变量和闭包是“内存钉子户”

有些脚本写起来为了方便,喜欢把数据存到全局变量里。比如:

// 技术栈:JavaScript(k6 脚本)
let users = [];  // 全局数组,用来存一些用户信息

export default function () {
  let user = { id: __VU, name: "user" + __VU };
  users.push(user); // 注意:这里每次迭代都会往全局数组里加东西
  // 后面的请求逻辑
}

这段代码有个隐蔽的坑:users 是全局数组,每个虚拟用户每跑一次迭代,就往里面塞一个新对象。测试跑上十万次迭代,这个数组就有十万个对象。而且因为它是全局的,垃圾回收器可能认为它还在被引用,就一直不清理。这就是典型的内存泄漏场景。K6 脚本里要特别小心这种“只增不减”的数据结构。

2.3 响应体的“大个头”和“高频分配”

接口返回的数据如果很大,比如一个几百 KB 的 JSON,加上高并发下每秒几百上千次请求,瞬间就是几百 MB 的内存压力。k6 在内部要处理这些响应体,解析成 JavaScript 对象让你能操作。每一次解析都是分配新内存的过程。如果循环往复,就会形成“分配—使用—等待回收—再分配”的节奏。

三、垃圾回收如何影响长时间测试稳定性

3.1 垃圾回收的“世界暂停”效应

大多数 JavaScript 引擎在回收垃圾时,会进入一种“暂停一切”的状态,专业上叫 Stop The World。虽然 k6 底层是 Go,但内嵌的 JavaScript 引擎也有类似的暂停行为。当内存中对象特别多时,一次垃圾回收可能要暂停几十毫秒甚至几百毫秒。在长时间测试中,这种暂停会周期性出现,表现出一种“锯齿形”的响应时间曲线。

设想一下,你压测一个接口,正常时平均响应时间 50ms,但每过几十秒,突然一次请求响应时间变成 500ms,然后又恢复正常。如果被测系统本身没问题,那么这种周期性尖刺很可能就是垃圾回收造成的。对于短时间测试,这种尖刺或许可以忽略;但对于几小时甚至几天的长时间验证,它会严重污染测试数据,让你误判系统的稳定性。

3.2 内存增长与“假死”现象

如果内存分配速度快于垃圾回收速度,那么内存占用就会稳步上升。测试过程中你会看到 k6 进程的 RSS(常驻内存)不断上涨。最终可能触发操作系统的内存保护机制,直接把 k6 进程杀掉,测试戛然而止。这就叫“假死”或者“OOM(Out Of Memory)”。

更隐蔽的情况是:内存虽然没有爆炸,但垃圾回收器越来越频繁地工作,因为可用空间太少,每次回收只能腾出一丁点空间,然后马上又满了。结果就是 CPU 大量浪费在垃圾回收上,真正发请求的吞吐量反而下降。这可能表现为:明明并发没变,但 QPS 越来越低,错误率开始出现。你以为是被测系统扛不住了,其实压测工具自己在“内耗”。

四、用一个完整示例演示内存问题

4.1 一段“问题满满”的 k6 脚本

我们写一个简单的压测脚本,模拟高频并发请求,同时在每个虚拟用户里保存一些历史数据。注意看注释,这些地方就是问题所在。

// 技术栈:JavaScript(k6 脚本)
import http from 'k6/http';
import { check, sleep } from 'k6';

// 全局缓存,模拟某些业务需要记录所有用户的历史操作
const history = [];

// 配置测试场景:50 并发,持续 2 分钟
export const options = {
  vus: 50,
  duration: '2m',
};

export default function () {
  // 每次迭代都发起一个 GET 请求
  const res = http.get('https://test-api.example.com/data');

  // 将响应体解析成 JSON,这里会产生一个临时对象
  const body = res.json();

  // 把这个对象里的某个字段存到全局数组里
  // 注意:这个数组永远不会清空,每跑一次迭代就多一个元素
  history.push({
    time: new Date().toISOString(),
    value: body.value,
  });

  // 一个有意义的业务断言
  check(res, {
    '状态码是 200': (r) => r.status === 200,
    '返回结果不为空': (r) => r.body && r.body.length > 0,
  });

  // 暂停一下,模拟用户思考时间
  sleep(1);
}

这段脚本看起来平平无奇,但它有一个致命问题:history 数组只增不减。50 个虚拟用户,每秒钟每个人跑一次迭代,一分钟就产生 3000 个对象,两分钟就 6000 个。每个对象包含时间字符串和可能很大的 value 字段。这些对象全部被全局数组引用着,垃圾回收器永远无法回收它们。内存占用会持续增长,直到 k6 崩溃。

4.2 改进后的脚本,让内存回到健康状态

我们应该避免无限增长的全局数据结构。如果真的需要统计某些信息,可以把它们限制在固定大小,或者直接用 k6 的指标系统来统计,而不是自己手动存对象。下面的示例展示了一个更稳健的写法:

// 技术栈:JavaScript(k6 脚本)
import http from 'k6/http';
import { check, sleep } from 'k6';

// 使用固定大小的环形数组来保存最近 100 条记录
// 这样既能有历史参考,又不会无限增长内存
const history = [];
const MAX_HISTORY = 100;

// 配置测试场景:和之前相同,但这次内存会稳定下来
export const options = {
  vus: 50,
  duration: '2m',
};

export default function () {
  const res = http.get('https://test-api.example.com/data');
  const body = res.json();

  // 如果数组满了,就从头部删除最老的一条记录
  if (history.length >= MAX_HISTORY) {
    history.shift(); // 删除第一个元素,腾出空间
  }
  history.push({
    time: new Date().toISOString(),
    value: body.value,
  });

  // 这里不再保存 body,让局部变量可以被垃圾回收
  // 由于 body 只在这个函数里使用,函数执行完后它就没有引用了
  check(res, {
    '状态码是 200': (r) => r.status === 200,
    '返回结果不为空': (r) => r.body && r.body.length > 0,
  });

  sleep(1);
}

改动的核心就是给历史记录加上上限。这样内存占用就被限制在一个可以预测的范围内,垃圾回收压力小了很多。当然,这只是一个简单的示意,实际场景中可能还需要考虑更多。

4.3 更省内存的写法:用 k6 自定义指标代替数组

如果只是想统计成功次数、响应时间分布等,完全没必要自己存数组。k6 自带指标系统,直接用 Trend 或者 Counter 就行。下面的示例展示了如何用自定义指标来收集数据,而完全不占用额外内存:

// 技术栈:JavaScript(k6 脚本)
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend, Counter } from 'k6/metrics';

// 自定义一个趋势指标,用来记录响应时长
const responseTimeTrend = new Trend('response_time_ms');
// 自定义一个计数器,记录成功次数
const successCounter = new Counter('success_request_count');

export const options = {
  vus: 50,
  duration: '2m',
};

export default function () {
  const start = Date.now();
  const res = http.get('https://test-api.example.com/data');

  // 计算响应时长并记录到指标里
  const duration = Date.now() - start;
  responseTimeTrend.add(duration);

  const body = res.json();
  const ok = body.value !== undefined;

  if (ok) {
    successCounter.add(1);
  }

  check(res, {
    '状态码是 200': (r) => r.status === 200,
    '响应体有效': () => ok,
  });

  sleep(1);
}

这种写法根本不需要手动存储历史对象,指标系统内部会按时间窗口聚合数据,内存占用非常稳定。而且还能直接看到测试报告里的趋势图,一举两得。

4.4 碎片化对象的释放:用 gc() 手动触发(仅实验用)

有些场景下,你确实希望手动干预垃圾回收。k6 提供了一个实验性的 gc() 函数,可以在脚本里手动触发一次垃圾回收。但需要注意的是,这只适合用来做内存诊断,不适合在正常测试中频繁调用,因为它会暂停执行。下面是一个简单的用法示例:

// 技术栈:JavaScript(k6 脚本)
import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
  vus: 10,
  duration: '30s',
};

export default function () {
  http.get('https://test-api.example.com/data');
  sleep(1);
}

// 这是 setup 阶段,只在测试开始前运行一次
export function setup() {
  // 在真正测试前,先强制执行一次垃圾回收,清理环境
  if (global.gc) {
    global.gc();
    console.log('手动触发了垃圾回收');
  }
  return {};
}

注意 global.gc 需要 Node.js 的 --expose-gc 参数才能使用,k6 中是否支持取决于版本。这里只是举例说明有“手动触发”这个可能性,实战中尽量避免依赖它。

五、k6 内存相关配置与运行参数

5.1 限制内存使用:--max-resource-usage 之类的选项

k6 有一些运行参数可以帮你限制资源使用。比较常用的是 --max-http-requests--max-http-connections,它们能限制同时存在的 HTTP 请求连接数量,从而间接控制内存中的待处理响应对象数量。比如:

# 技术栈:Shell(k6 命令行)
# 限制最大并发连接数为 200,避免创建过多 socket 对象
k6 run --max-http-connections 200 load-test.js

# 限制最大 HTTP 请求数为 50000,防止无限循环发送请求
k6 run --max-http-requests 50000 load-test.js

合理地控制连接数,其实是变相地控制了内存中“正在处理的请求”数量,对稳定性很有帮助。

5.2 使用 --out 输出到外部数据库,释放本地内存

k6 默认会把所有测试指标都保存在内存里,等测试结束后再生成报告。如果测试时间超长,比如几个小时,这些指标数据本身就会占用大量内存。一种解决办法是把指标实时推送到外部数据库,比如 InfluxDB、Prometheus,这样本地内存只保留一个很小的缓冲。示例命令如下:

# 技术栈:Shell(k6 命令行)
# 将测试数据实时发送到本机的 InfluxDB
k6 run --out influxdb=http://localhost:8086/k6 load-test.js

# 将数据以 JSON 格式输出到文件,减少内存堆积
k6 run --out json=./results.json load-test.js

这样 k6 进程本身的内存在长时间测试中会非常稳定,因为指标数据是边产生边发送出去的。

5.3 环境变量 K6_WEB_DASHBOARDK6_GRACEFUL_STOP 与内存关系

还有一些其他参数,比如 --graceful-stop,控制的是测试结束时等待当前迭代完成的时间。这个时间越长,内存中滞留的未完成任务就越多。如果测试结束时大量请求还没处理完,它们持有的内存就无法被释放。所以建议把优雅停止时间设置得合理一些,不要给超长的等待机会。示例:

# 技术栈:Shell(k6 命令行)
# 设置优雅停止时间为 5 秒,超过 5 秒则强制终止
k6 run --graceful-stop 5s load-test.js

这个参数能避免因为测试收尾过慢导致的内存暴涨。

六、应用场景与优缺点分析

6.1 短时间压测 vs 长时间稳定性测试

如果你只是跑一个 30 秒的突击测试,那么垃圾回收造成的微小尖刺根本看不出来,内存即使泄漏一点也无所谓,因为测试很快就结束了。但如果是 8 小时甚至 24 小时稳定性测试,垃圾回收的暂停效应会被放大,内存泄漏的累积效应也会逐渐显现。这时候 k6 自身的稳定性就变得和被测系统一样重要。这也是为什么很多大型项目做长期测试时,会选择用专门的压力机跑 k6,同时设置监控,一旦 k6 进程内存超过警戒线就自动重启。

6.2 优点:k6 的并发模型在正常情况下非常省内存

k6 底层是 Go,协程模型在管理大量“虚拟连接”时内存开销远小于传统的“线程每用户”模型。在脚本写得干净的前提下,一个 50 并发、持续几小时的测试,k6 的内存占用可以轻松控制在 200MB 以内。相比之下,Jmeter 这种基于 JVM 的工具,动不动就占几个 GB。所以 k6 的长时压测优势原本是很大的。

6.3 缺点:JavaScript 执行引擎是内存瓶颈

k6 的短板就在于它运行 JavaScript 的那一层,垃圾回收无法被完全控制,而且分配对象的速度极快。一旦你在脚本里不小心保留了大量对象或者频繁创建了大量临时变量,垃圾回收器就会成为瓶颈。属于“工具优势大,但用不好就砸脚”的类型。

6.4 常见问题总结

  • 全局数组、全局 Map 只增不减,导致内存泄漏。
  • 大响应体反复解析成对象,来不及回收。
  • 循环中创建闭包,意外保留外部变量。
  • 使用 Array.frommap 等产生新数组的操作,在热点路径上大量分配内存。
  • 单个迭代产生过多的字符串拼接,比如反复 + 拼接超大字符串,导致旧字符串无法释放。

下面用一个小示例说明闭包造成内存保留的情况:

// 技术栈:JavaScript(k6 脚本)
// 问题代码:闭包意外保留了大的临时对象
export default function () {
  let largeData = new Array(10000).fill('x'); // 一个大数组
  let handler = function () {
    return largeData.length; // 闭包引用了 largeData
  };
  // 即使 largeData 不再使用,但 handler 还留在作用域里
  // 在循环中可能导致大量大对象无法回收
  console.log(handler());
}

改进方式很简单,把闭包定义放在外层,或者明确让 largeData = null 解除引用。实际测试中要注意这类细节。

七、实战中的注意事项与调优技巧

7.1 优先清理响应体

处理完响应体之后,如果不再需要原始 JSON,可以及时把 body 置空,或者直接避免调用 res.json()。比如你需要的只是一个字段,可以用正则或者简单的字符串方法从 res.body 中提取,但那样做可能更消耗 CPU。更好的办法是使用 k6 的 res.json('field') 里的小型提取器,只解析需要的部分。示例:

// 技术栈:JavaScript(k6 脚本)
import http from 'k6/http';

export default function () {
  const res = http.get('https://test-api.example.com/data');

  // 只提取 value 字段,不会生成整个响应体的对象树
  const value = res.json('value');
  // 使用 value 做后续处理
  console.log(value);
}

7.2 控制并发数和迭代速率

不要在脚本里搞“无脑死循环”,尽量使用 k6 的 ramping-vus 来自动调节并发。高并发本身不会直接导致垃圾回收暂停变长,前提是内存占用稳定。关键还是看你脚本里每个迭代创建对象的速度。如果确实需要极高并发,让每个迭代里的工作尽可能小,比如只发请求,不做额外解析。

7.3 监控 k6 自身的内存

压测时同时监控 k6 进程的内存占用,可以用系统工具如 htopps。如果发现内存持续上涨,即使没崩掉,也应该停下来检查脚本。下面是一个简单的命令行监控方式,每隔 2 秒打印一次内存占用:

# 技术栈:Shell(使用 bash 和 ps 命令)
# 每 2 秒查看 k6 进程的内存占用,单位是 MB
while true; do
  ps -o rss= -C k6 | awk '{printf "RSS: %.1f MB\n", $1/1024}'
  sleep 2
done

如果内存曲线一直往上走,说明脚本或配置里一定有地方在持续累积对象。

7.4 分批压测代替一次超长压测

如果业务允许,把长时间的稳定性测试拆分为多个中短时间的任务,比如 60 分钟一个阶段,阶段之间留 1~2 分钟让垃圾回收器清干净。因为每次测试结束后,进程退出会释放全部内存。但这并不是一个完美方案,因为有些稳定性问题需要连续运行才会出现。更好的办法是结合外部输出到数据库,让 k6 进程本身保持瘦小。

7.5 使用 k6 的 cloud 能力或分布式执行

对于内存实在不足或者需要超长测试的场景,可以考虑 k6 的分布式执行。每个实例只分担一部分虚拟用户,每个实例的内存压力自然就降下来了。不过这会引入更多运维复杂度,适合专业团队使用。

八、总结

k6 在大量并发请求下的垃圾回收和内存管理,本质上是一个“平衡问题”:既希望脚本灵活便于断言和处理响应,又希望内存占用稳定、回收暂停不影响数据准确性。理解了这个平衡点,你就能写出更适合长时间运行的测试脚本。

记住几个关键原则:第一,不要在脚本里保留无限增长的数据结构;第二,尽量使用 k6 的指标系统来统计数据;第三,让响应体对象尽快脱离作用域;第四,合理利用外部输出和监控工具。做到这些,你会发现 k6 完全可以胜任长时间高负载稳定性测试,而且比很多工具更轻量、更环保。

垃圾回收并不可怕,可怕的是我们对它一无所知。只要摸清它的脾气,预先规避那些“内存钉子户”,k6 还是会稳稳地陪你把测试跑到底。