一、问题背景:用k6压测时遇到的“诡异”现象

做接口压测的同学,应该不少人用过k6这个工具——它是专门做性能测试的,轻量、好用,还能写脚本模拟真实用户的操作。但最近我用它压测一个需要长期保持连接的接口时,遇到了特别诡异的情况:明明我在脚本里设置了100个虚拟用户(VUs),每个用户应该是独立的,结果跑出来的结果完全不对——有的用户拿到了别人的返回数据,甚至还有用户根本连不上接口,报错“连接被拒绝”。

一开始我以为是接口本身有问题,排查了服务器、网络、防火墙一圈,都没发现问题。后来才发现,问题出在k6对长连接的处理逻辑上:k6的长连接复用,居然和HTTP请求里的Host头强绑定,而且虚拟用户的连接池没有完全隔离,导致不同用户的连接混在一起,互相“污染”。

二、核心原理:先搞懂k6的连接池和Host头的关系

要理解这个问题,得先把两个基础概念掰明白:一个是k6的连接池怎么工作,另一个是Host头在HTTP里的作用。

2.1 k6的连接池逻辑

我们平时发HTTP请求,不是每次都重新建连接的——比如你打开一个网站,浏览器会先和服务器建一个TCP连接,之后再发请求就复用这个连接,省掉建连接的时间,这就是“连接复用”,也叫“长连接”。

k6作为压测工具,也会复用连接,而且它会给每个虚拟用户(VUs)单独开一个连接池——意思是,每个VU的连接是自己用的,不会和别的VU共享,这样才能模拟真实的多用户场景。比如你开100个VUs,就有100个独立的连接池,每个池子里存着这个VU之前用过的连接。

2.2 Host头的作用

HTTP请求里的Host头,是告诉服务器“你要处理哪个域名的请求”。比如你访问https://api.example.com/v1/user,这个请求的Host头就是api.example.com。服务器可能会同时托管多个域名,靠Host头区分该给哪个域名返回数据。

那k6的连接复用和Host头有啥关系?重点来了:k6在决定能不能复用一个连接时,核心判断条件是「这个连接的目标地址」和「当前请求的Host头」是不是完全匹配。如果匹配,就复用这个连接;如果不匹配,就重新建连接。

三、问题复现:写个脚本就能看到坑

光说原理太抽象,我们写个k6脚本复现这个问题。先明确技术栈:所有示例都用k6(版本0.45.0,这是当前稳定版),没有其他工具。

3.1 准备测试接口

为了方便复现,我们写一个简单的Node.js服务,用来区分不同Host头的请求,同时保持长连接:

// 测试服务:区分Host头,保持长连接
const http = require('http');
const server = http.createServer((req, res) => {
  // 打印请求的Host头,方便看日志
  console.log(`收到请求,Host头:${req.headers.host}`);
  // 模拟长连接:延迟3秒返回,让连接保持活跃
  setTimeout(() => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end(`你访问的是Host:${req.headers.host}`);
  }, 3000);
});
// 服务器开在8080端口,同时支持两个Host头:api1.example.com、api2.example.com
server.listen(8080, '0.0.0.0', () => {
  console.log('测试服务运行在http://localhost:8080');
  console.log('支持的Host头:api1.example.com、api2.example.com');
});

这个服务很简单:只要收到请求,就打印Host头,3秒后返回对应Host的内容。注意它没有限制Host头,只要请求里带Host,就按Host处理。

3.2 写k6压测脚本

接下来写k6脚本,模拟两个虚拟用户,分别访问两个不同的Host头,同时开启长连接复用:

// k6脚本:模拟两个VUs,分别用不同Host头访问
import http from 'k6/http';
import { check, sleep } from 'k6';

// 配置压测参数:2个VUs,持续10秒
export const options = {
  vus: 2,
  duration: '10s',
};

// 每个VU的执行逻辑
export default function () {
  // 给每个VU分配不同的Host头:VU1用api1.example.com,VU2用api2.example.com
  const host = __VU === 1 ? 'api1.example.com' : 'api2.example.com';
  
  // 构造请求:目标地址是localhost:8080,同时带上指定的Host头
  const res = http.get('http://localhost:8080', {
    headers: {
      'Host': host, // 手动指定Host头
    },
    // 开启长连接复用:keepalive: true(k6默认就是true,这里明确写出来)
    keepalive: true,
  });

  // 检查返回结果是不是和Host头匹配
  check(res, {
    '返回Host正确': (r) => r.body.includes(host),
  });

  // 每个VU休息1秒,再发下一个请求
  sleep(1);
}

这个脚本的逻辑很清晰:2个虚拟用户,VU1用Host头api1.example.com,VU2用api2.example.com,每个VU每1秒发一次请求,持续10秒。

3.3 运行脚本看结果

先启动测试服务:

node test-server.js

再开一个终端运行k6脚本:

k6 run test-script.js

运行完之后,你会看到两个结果: 第一,测试服务的日志里,会出现很多Host头的混乱——比如本来应该是VU1的请求,却打印了api2.example.com; 第二,k6的检查结果会报错,很多“返回Host正确”的检查不通过。

这就是我们要复现的“连接池污染”:VU1的连接池里,混进了VU2用过的连接,导致VU1用了VU2的连接发请求,结果拿到了VU2的返回。

四、问题根源:连接池的匹配逻辑和隔离缺陷

为什么会出现这个问题?我们得拆成两部分说:

4.1 连接复用的匹配逻辑太“松”

k6判断能不能复用连接的逻辑,核心是「连接的目标地址」和「当前请求的Host头」是否匹配。但它的“目标地址”是怎么定义的?

k6里的目标地址,是由「协议+IP+端口」组成的。比如我们的测试脚本里,两个VU的请求目标地址都是http://localhost:8080(协议HTTP,IP127.0.0.1,端口8080),只有Host头不同。

那k6的匹配逻辑就变成了:只要两个请求的「协议+IP+端口」相同,不管Host头是什么,都能复用连接。这就出问题了——比如VU2先建了一个连接,这个连接的目标地址是http://localhost:8080,Host头是api2.example.com;之后VU1发请求,目标地址也是http://localhost:8080,k6一看目标地址匹配,就直接复用了VU2的连接,根本不管Host头是不是api1.example.com

4.2 连接池的隔离不彻底

k6本来给每个VU单独开连接池,是为了隔离不同用户的连接。但这个隔离只针对「目标地址」,不针对「Host头」。也就是说,同一个VU的连接池里,只要目标地址相同,不管Host头是什么,都会混在一起。

比如VU1在脚本里,有时候用api1.example.com,有时候用api2.example.com,那它的连接池里,两个Host头的连接会混在一起,下次发请求时,可能随便复用一个,导致Host头不匹配。

更严重的是,当VU数量变化时,k6的连接池会被复用。比如你先开1个VU跑,再把VU加到2个,k6可能会把第一个VU的连接池分给第二个VU用,导致不同VU的连接混在一起。

五、解决方案:怎么避免连接池污染

知道了问题根源,解决方案就很明确了:要么让连接的目标地址不同,要么让k6的连接池匹配逻辑严格绑定Host头。

5.1 方案一:修改目标地址,让连接的“唯一标识”不同

既然k6用「协议+IP+端口」作为连接的唯一标识,那我们只要让两个请求的目标地址不同,就能让k6把它们当成不同的连接,不会复用。

怎么改?很简单,给每个Host头分配一个不同的端口。比如:

  • Host头api1.example.com用端口8081;
  • Host头api2.example.com用端口8082。

这样两个请求的目标地址就变成了http://localhost:8081http://localhost:8082,k6就不会复用连接了。

修改后的测试服务:

// 测试服务:给不同Host头分配不同端口
const http = require('http');

// 端口8081对应Host头api1.example.com
const server1 = http.createServer((req, res) => {
  console.log(`端口8081收到请求,Host头:${req.headers.host}`);
  setTimeout(() => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end(`你访问的是Host:${req.headers.host}`);
  }, 3000);
});
server1.listen(8081, '0.0.0.0', () => {
  console.log('服务1运行在http://localhost:8081,对应Host:api1.example.com');
});

// 端口8082对应Host头api2.example.com
const server2 = http.createServer((req, res) => {
  console.log(`端口8082收到请求,Host头:${req.headers.host}`);
  setTimeout(() => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end(`你访问的是Host:${req.headers.host}`);
  }, 3000);
});
server2.listen(8082, '0.0.0.0', () => {
  console.log('服务2运行在http://localhost:8082,对应Host:api2.example.com');
});

修改后的k6脚本:

// k6脚本:不同Host头对应不同端口
import http from 'k6/http';
import { check, sleep } from 'k6';

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

export default function () {
  // VU1用端口8081,Host头api1.example.com
  // VU2用端口8082,Host头api2.example.com
  const port = __VU === 1 ? 8081 : 8082;
  const host = __VU === 1 ? 'api1.example.com' : 'api2.example.com';
  
  const res = http.get(`http://localhost:${port}`, {
    headers: {
      'Host': host,
    },
    keepalive: true,
  });

  check(res, {
    '返回Host正确': (r) => r.body.includes(host),
  });

  sleep(1);
}

再运行一遍,你会发现检查结果全部通过,测试服务的日志也不会出现Host头混乱的情况。

5.2 方案二:关闭长连接复用,每次重新建连接

如果你的场景不适合改端口,那可以直接关闭k6的长连接复用。关闭之后,k6每次发请求都会重新建连接,自然就不会有连接复用的问题了。

修改k6脚本,把keepalive改成false

// k6脚本:关闭长连接复用
import http from 'k6/http';
import { check, sleep } from 'k6';

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

export default function () {
  const host = __VU === 1 ? 'api1.example.com' : 'api2.example.com';
  
  const res = http.get('http://localhost:8080', {
    headers: {
      'Host': host,
    },
    keepalive: false, // 关闭长连接复用
  });

  check(res, {
    '返回Host正确': (r) => r.body.includes(host),
  });

  sleep(1);
}

这个方案的缺点是,每次都要重新建连接,会增加压测的时间成本,可能导致压测结果不准(因为真实场景下很多是长连接)。所以只有在场景简单、对压测精度要求不高的时候用。

5.3 方案三:用k6的http.Client自定义连接池

k6提供了http.Client,可以让你自定义连接池的配置,包括匹配逻辑。你可以给每个Host头单独创建一个http.Client,这样每个Client的连接池是独立的,不会互相污染。

修改k6脚本:

// k6脚本:用http.Client自定义连接池
import http from 'k6/http';
import { check, sleep } from 'k6';

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

// 每个VU的初始化逻辑:给每个Host头单独创建http.Client
export function setup() {
  const host = __VU === 1 ? 'api1.example.com' : 'api2.example.com';
  // 创建http.Client,设置连接池的匹配逻辑为Host头
  const client = new http.Client({
    keepalive: true,
    // 自定义连接的“键”,把Host头加进去
    // 这里用`${host}:${port}`作为键,确保每个Host头的连接是独立的
    http: {
      transport: {
        dial: (addr, opts) => {
          // 把Host头加到地址里,让k6认为是不同的连接
          return http.dial(`${host}:8080`, opts);
        },
      },
    },
  });
  return { client };
}

export default function (data) {
  const { client } = data;
  const host = __VU === 1 ? 'api1.example.com' : 'api2.example.com';
  
  const res = client.get('http://localhost:8080', {
    headers: {
      'Host': host,
    },
  });

  check(res, {
    '返回Host正确': (r) => r.body.includes(host),
  });

  sleep(1);
}

这个方案的优点是,不需要改端口,也能保持长连接复用,适合复杂的压测场景。缺点是需要对k6的http.Client有一定的了解,配置起来稍微麻烦一点。

六、应用场景、优缺点和注意事项

6.1 应用场景

这个问题主要出现在以下场景:

  1. 压测多域名的服务:同一个服务器托管多个域名,用Host头区分;
  2. 压测需要保持长连接的服务:比如WebSocket、长轮询、实时通信服务;
  3. 压测时需要模拟多个独立用户的场景:比如电商的用户登录、下单流程,每个用户的连接需要独立。

6.2 技术优缺点

方案 优点 缺点 适用场景
修改目标地址(改端口) 实现简单,逻辑清晰,不会影响压测精度 需要修改服务配置,增加端口管理成本 服务可以改端口的场景
关闭长连接复用 实现最简单,不需要改服务 压测结果不准,性能开销大 场景简单、对精度要求不高的场景
自定义http.Client 不需要改服务,保持长连接复用,精度高 配置复杂,需要了解k6的底层逻辑 复杂场景、对精度要求高的场景

6.3 注意事项

  1. 压测前一定要做小范围验证:先开1-2个VUs跑几分钟,检查连接是否隔离,有没有Host头混乱的情况;
  2. 避免在脚本里动态修改Host头:如果同一个VU的脚本里,需要用不同的Host头,一定要用自定义连接池的方案,否则会导致连接池污染;
  3. 注意k6的版本:不同版本的k6,连接池的逻辑可能会有变化,建议用最新的稳定版;
  4. 压测时要监控服务器的连接数:如果出现连接数过高的情况,可能是连接池没有正确复用,或者连接池污染导致的。

七、总结

k6对长连接状态复用依赖Host头,以及连接池隔离的问题,本质上是k6的连接匹配逻辑设计的问题——它把「协议+IP+端口」作为连接的唯一标识,而没有把Host头加进去,导致不同Host头的连接混在一起,进而引发连接池污染。

解决这个问题的核心思路,是让k6能区分不同Host头的连接,要么改目标地址,要么改匹配逻辑,要么关闭复用。在实际压测时,一定要根据自己的场景选择合适的方案,避免因为连接池的问题,导致压测结果不准,甚至影响服务的正常运行。