一、奇怪的测试数据,让你怀疑人生
最近我负责给一个下单接口做性能测试,用的是 k6。脚本写得很简单,就是一个 HTTP 请求,加上几个检查。可是跑出来的结果让我特别困惑:同样的并发数,同样的时长,第一次跑 P95 是 80 毫秒,第二次跑就成了 800 毫秒。我还以为服务端出问题了,让后端同事翻了好半天的监控,结果人家告诉我接口的平均响应时间一直很稳定。那我这 k6 的结果怎么这么飘?
后来我把脚本里的日志打开,一条一条看。发现同一个虚拟用户,第一次迭代的时候走了“登录”分支,第二次迭代走了“下单”分支,后面有的走登录,有的走下单,完全乱了套。这时候我才反应过来,问题不在接口,而在我的脚本里:一个在脚本顶部定义的全局变量,像一瓶开了盖的可乐,到处乱喷。
像这类问题,在 k6 场景里特别常见。因为 k6 的脚本是 JavaScript,JavaScript 对全局变量的宽容度非常高。你随手写一个 let count = 0,它就真的会在整个脚本级别“活”下来。而 k6 又是以迭代为核心来跑测试的,迭代之间共享同一种环境,这时候全局变量就像一堆没擦干净的粉笔印,把每一次迭代的“初始状态”都染上了之前的颜色。
二、全局变量是怎么把事情搞砸的
2.1 同一个虚拟用户,迭代之间不重置
先看一个最简单的例子。我把一个计数器放在脚本顶部,然后在每次迭代里加一。
技术栈:JavaScript(k6 脚本)
// 错误示例:用全局变量做计数器
let requestCount = 0; // 全局变量,每个虚拟用户各有一份
export default function () {
requestCount++; // 每次迭代都加一
console.log(`当前迭代请求次数累计值: ${requestCount}`);
// 假设这里有一个真实的 HTTP 请求
}
在 k6 里,每个虚拟用户都会加载这份脚本,并执行 export default 里的函数。requestCount 这个变量定义在函数外面,相当于一个“房间门口的白板”。第一次迭代进来,在白板上写了 1;第二次迭代进来,看到的是 1,然后改成 2。所以日志里会出现 1, 2, 3, 4...。你本来想统计“每次迭代发了多少次请求”,结果变成了“至今一共发了多少次请求”。这个数值跟着迭代次数一直变大,如果你拿它去做断言或者设置阈值,测试结果当然不稳定。
2.2 循环里越攒越多
更隐蔽的坑是,在循环里用了全局变量做累加。比如下面这个例子:
技术栈:JavaScript(k6 脚本)
// 错误示例:在循环里累加全局变量
let totalTime = 0; // 全局变量,用来统计总耗时
export default function () {
for (let i = 0; i < 5; i++) {
// 模拟一次耗时操作,比如发送请求
totalTime += 3; // 假设每次固定耗时3毫秒
}
console.log(`本次迭代后总耗时: ${totalTime}`);
// 你会发现第一次是15,第二次是30,第三次是45
}
第一次迭代,totalTime 从 0 加到 15;第二次迭代,它不是从 0 开始,而是从 15 开始,一直加到 30。这样你看到的结果就会越来越大。如果你用这个值去断言“平均耗时不能超过 50 毫秒”,前几次可能侥幸通过,跑得越久越容易失败。这个跟服务端性能毫无关系,纯粹是脚本状态污染。
2.3 闭包带来的意外惊喜
还有一类同学,喜欢用闭包来“缓存”一些计算结果。比如:
技术栈:JavaScript(k6 脚本)
// 错误示例:闭包捕获了全局变量
let counter = 0; // 全局变量被闭包引用
function increment() {
counter++; // 修改的是全局变量
return counter;
}
export default function () {
const value = increment();
console.log(`得到的值: ${value}`);
}
这里 increment 函数虽然看起来是个小工具,但它引用的 counter 是全局变量。k6 里每次迭代都会调用 increment,于是 counter 也一路涨。闭包本身不是坏东西,但是当你把闭包和全局变量搅在一起,测试脚本的“状态”就变得不可预测了。特别是当你有多个函数共享同一个全局变量的时候,你自己都记不清谁在什么时机改过它。
三、作用域和全局变量:一段老生常谈
3.1 全局变量的本来面目
为什么 JavaScript 这么容易产生全局变量?因为 JavaScript 的作用域链在函数嵌套时是逐层向外找的。如果你在函数内部写了一个没有声明的变量,它就会自动成为全局变量。而在 k6 脚本里,你写在最外层的 let 或 var,自然就成了整个模块的“公共区域”。
听起来好像“公共区域”很危险?其实危险的不是公共区域本身,而是你忽略了它会在同一虚拟用户的多次迭代之间一直存活。就好比你住酒店,每次退房之后,酒店都会把房间打扫干净。但如果酒店不打扫,你上次留下的垃圾就会堆到这次,时间久了房间里全是味儿。
3.2 局部变量又不是不能用
解决这个问题的第一反应很简单:把变量放进 export default 函数里面。这样每次迭代执行到函数的时候,变量都是新创建的,完毕之后就被弹出了,下一次迭代又从头开始。这不仅让你每个月少掉很多头发,也让你更容易推理脚本的行为。
技术栈:JavaScript(k6 脚本)
// 正确示例:把临时变量放到迭代内部
export default function () {
let requestCount = 0; // 每次迭代都会重新初始化为0
requestCount++;
console.log(`本次迭代请求次数: ${requestCount}`);
// 业务逻辑...
}
当然,这个变量的生命周期不超过本次迭代。如果你想在迭代之间分享一些只读的数据,比如测试数据文件、配置信息,那可以用 k6 提供的 SharedArray 或者 __ENV 来存储,而不是直接写一个到处被修改的全局变量。
四、彻底解决:把状态“关进笼子”
4.1 把临时数据放进迭代内部
最典型的场景是:每轮迭代需要独立的账号、独立的随机数、独立的请求体。这些都应该在 export default 内部创建。
技术栈:JavaScript(k6 脚本)
// 正确示例:每个迭代生成独立的数据
export default function () {
// 每次迭代都新生成一个订单号
const orderId = `order_${__ITER}_${Date.now()}`;
// 每次迭代都使用自己的随机数
const randomAmount = Math.floor(Math.random() * 100) + 1;
// 构造请求体,注意这里没有引用任何外部可变变量
const payload = JSON.stringify({
orderId: orderId,
amount: randomAmount
});
console.log(`发送订单: ${payload}`);
// 发送请求...
}
这样写出来的脚本,每次迭代之间互不干扰。不管跑一分钟还是跑一小时,每次迭代的行为都保持一致,测试结果才有可比性。
4.2 利用好 k6 自带的 __ITER
有些时候,你确实需要在一个虚拟用户的第一次迭代做点初始化,比如登录。这时候不要自己维护一个“是否为第一次”的全局变量,直接用 k6 提供给每个虚拟用户的迭代序号 __ITER。
技术栈:JavaScript(k6 脚本)
export default function () {
// __ITER 从 0 开始,每个虚拟用户都有自己的计数
if (__ITER === 0) {
// 只有第一次迭代会执行这里
console.log('执行登录操作');
// 这里可以登录并把 token 保存到局部变量
let token = 'fake_token_123';
// 注意:token 是局部变量,在这个函数结束后就没了
}
// 后续迭代直接执行业务
console.log('执行核心业务');
}
这里有个问题:如果你在 if 块里声明的局部变量 token,在 if 之外是访问不到的。所以如果你真的需要在后续迭代复用登录凭据,应该把凭据保存在一个“每次迭代都会更新、但要在迭代之间保留”的容器里。那怎么办?请看下面这颗“后悔药”。
4.3 定时重置全局状态(如果你非要用)
既然说全局变量在迭代之间会残留,那我们可以“手动”重置它。比如在每个迭代结束时,把它恢复原样。这虽然不符合“避免全局变量”的洁癖,但在某些复杂场景下是有效的手段。要注意的是,重置动作必须放在迭代末尾,而不是开头,否则你可能又会陷入混乱。
技术栈:JavaScript(k6 脚本)
// 谨慎示例:使用全局变量,但每次迭代结束时重置
let loginToken = 'anonymous'; // 全局变量
export default function (data) {
// 业务逻辑
console.log(`当前使用的token: ${loginToken}`);
// 每次迭代结尾重置为默认值
loginToken = 'anonymous';
}
如果你要保存登录状态,需要把 token 保存在能够跨迭代访问的地方。k6 本身没有提供专门的“VU 级别的状态存储”,但你可以用全局变量并且保证它不会影响断言。比如:
技术栈:JavaScript(k6 脚本)
// 示例:在迭代之间缓存登录token,但注意不要影响测试结果
let cachedToken = ''; // 全局变量,用于缓存
export default function () {
// 如果还没有token,就登录
if (!cachedToken) {
// 模拟登录
cachedToken = 'token_' + __ITER;
console.log(`获取到新token: ${cachedToken}`);
}
// 每次迭代都使用同一个token,在真实场景中这正是我们需要的
console.log(`使用token: ${cachedToken}`);
// 测试逻辑...
}
这个例子展示了全局变量并不是万恶不赦的。关键在于你要明确它的生命周期,并且确保它不会导致测试行为发生不可控的变化。
五、顺带聊聊 k6 的并发模型
理解 k6 的并发模型,有助于你从根上理解为什么全局变量会引发这类问题。k6 使用 Go 语言编写,每个虚拟用户(VU)都是一个独立的 JavaScript 运行时。它们之间默认不共享任何变量。也就是说,你在脚本顶部写一个全局变量,它其实会被每个 VU 分别复制一份。不同 VU 之间的同一个全局变量互不干扰。
但是,再挖深一层:同一个 VU 的多次迭代是在同一个运行时里顺序执行的。这个运行时就像一个“长期租房”的房间,你每次退房,它并不打扫,所以你留下的东西(全局变量)就会一直待在房间里,直到进程结束。如果你的脚本逻辑依赖这些“残留物”,那迭代之间就会出现前后耦合。这也是为什么测试结果会随着时间的变化而漂移。
如果遇到多 VU 场景,你可能会想:我能不能用一个全局变量让所有 VU 共享计数?答案是不能,至少在默认的 JavaScript 运行时里不能。不同 VU 的 JavaScript 上下文是隔离的。如果你真的需要所有 VU 共享一个计数器,可以使用 k6 内置的 SharedArray 或通过外部数据源来实现。但这些属于进阶话题,平时的普通接口测试中,你更应该关注的是“同一个 VU 的迭代状态”。
顺带提一句:k6 的 default 函数在每次迭代中都是同步执行的,除非你使用异步 API。这意味着在同一个 VU 内,迭代不会同时跑,而是排队执行。所以只要你不使用异步 API,就不会出现“两个迭代同时改全局变量”的竞态问题。但“迭代之间残留”的问题依然存在。
六、应用场景和注意事项
6.1 适合用全局变量的场景
- 缓存只读的配置信息,比如从
__ENV中读取的环境变量,在脚本初始化阶段解析一次,然后后续迭代只读。 - 使用
SharedArray加载测试数据文件,所有 VU 共享只读数据。 - 合理地在迭代之间保持登录态,且不影响断言。
6.2 不适合用全局变量的场景
- 作为计数器、累计值、开关标记,并且这些值会影响每次迭代的行为。
- 作为 “上一次请求的结果” 来给下一次请求做输入。除非你对生命周期有很强的掌控,否则很容易引入顺序依赖。
- 在闭包里隐式引用并且被多个函数修改的变量。
6.3 写脚本时的注意事项
- 优先使用
const和let,避免隐式全局变量。 - 刻意检查:这个变量是否被多个函数修改?它的初始值从哪里来?每次迭代开始时它应该是什么?
- 多使用函数参数和返回值来传递数据,不要依赖共享状态。比如把请求函数写成接收参数、返回结果的形式,而不是内部读取全局变量。
- 在迭代结束前,如果需要清理资源,注意放的位置。
- 做回归测试:改动脚本后,先跑一次短时测试,观察日志里是否有不符合预期的累计值。
另外,有一种常见的错误:把多个不同功能的测试放在同一个脚本里,并且用全局变量来切换。比如用一个 mode 变量控制当前是走 A 接口还是 B 接口,然后在迭代里根据 mode 走分支。这样会导致测试结果无法区分是哪个接口的问题。正确做法是拆分成多个独立脚本,或者使用 k6 的 scenarios 配置。
七、总结
k6 测试结果飘忽不定,很多时候不是被测服务的问题,而是脚本自身状态没有“清零”。全局变量在 k6 的 JavaScript 运行中会跨越同一个虚拟用户的多次迭代而存活,如果脚本逻辑依赖了这些变量,就等于在每轮迭代之间悄悄塞了一个“记忆”,让结果变得不可复现。解决这个问题最核心的思路就是:让每次迭代变成一个“独立的小世界”,只通过函数参数、返回值和 k6 提供的机制来传递必要的信息。
你可以把全局变量用在正确的地方,比如缓存只读配置、维护会话凭据,但一定要清楚它的生命周期,并且避免让它左右测试结果。如果你发现自己的测试结果时好时坏、跟时间相关、或者跟迭代次数相关,不妨回头看看脚本里有没有这样“在背后偷偷记账”的全局变量。
把状态关进笼子,让每次迭代都在干净的环境里跑,你的 k6 测试结果才能真实地反映接口的性能。
Comments