很多做后端压测的同学,在用k6测gRPC服务的时候,常搞混一元调用和流式调用,尤其是遇到莫名其妙的阻塞时,不知道是调用类型的问题还是压测脚本的问题。今天就把这两个点拆碎了讲,从概念到压测差异,再到常见的阻塞怎么排查,全是实打实的实操内容。

一、k6测gRPC的两种调用:一元和流式,到底是什么

1.1 大白话的概念(不用复杂术语)

一元调用就像你点外卖:说一句“我要一份宫保鸡丁”,商家只给你一份,一来一回的交互一次完成,中间没有多余的对话;流式调用就像你点了一份“无限续的下午茶”,你可以慢慢说(多次发请求包),商家也可以慢慢给(多次发响应包),是持续的双向或单向交互。 在k6里,两种调用的写法完全不同:一元用固定的invoke方法,流式需要手动建立流对象并控制读写流程。

1.2 完整示例:统一用k6+JavaScript技术栈

以下两个示例都是可直接运行的压测脚本,注释清晰,无混合技术栈。

// 技术栈:k6 + JavaScript,测试gRPC服务的一元调用场景
import grpc from 'k6/net/grpc';
import { check, sleep } from 'k6';

// 初始化gRPC客户端,连接本地测试的gRPC服务
const client = new grpc.Client();
// 加载服务的.proto定义文件(必须和服务端完全一致,否则会调用失败)
client.load(['./proto'], 'user.proto');

// 压测配置:10个虚拟用户,持续压测30秒
export const options = {
  vus: 10,
  duration: '30s',
};

export default function () {
  // 核心:一元调用,客户端发1次请求,等待服务端返回1次响应
  const resp = client.invoke('user.UserService/GetUser', { userId: 123 });
  
  // 压测检查:统计请求成功率和返回数据的正确性
  check(resp, {
    '请求状态为成功': (r) => r.status === grpc.StatusOK,
    '返回用户名正确': (r) => r.message.name === '张三',
  });
  
  // 模拟真实用户的操作间隔,避免压测过于剧烈
  sleep(1);
}

// 压测结束后关闭客户端,释放资源
export function teardown() {
  client.close();
}
// 技术栈:k6 + JavaScript,测试gRPC服务的双向流式调用场景(模拟大文件上传)
import grpc from 'k6/net/grpc';
import { check, sleep } from 'k6';

const client = new grpc.Client();
client.load(['./proto'], 'file.proto');

// 压测配置:5个虚拟用户,持续压测60秒(流式压测通常时间更长)
export const options = {
  vus: 5,
  duration: '60s',
};

export default function () {
  // 核心:建立双向流式调用,客户端和服务端可互发多个消息
  const stream = client.stream('file.FileService/UploadFile');
  
  // 模拟上传10个文件分块,每个分块1MB
  for (let i = 0; i < 10; i++) {
    const chunk = {
      chunkIndex: i,
      data: `第${i}个文件分块的二进制数据(简化为字符串示例)`,
      isLast: i === 9, // 标记最后一个分块,告知服务端上传完成
    };
    // 发送分块到服务端
    stream.write(chunk);
    // 等待服务端的进度响应(设置500ms超时,避免卡住)
    const resp = stream.read(500);
    check(resp, {
      '分块接收成功': (r) => r.status === grpc.StatusOK,
    });
    // 模拟分块上传的间隔
    sleep(0.5);
  }
  
  // 关闭流,告知服务端本次上传完成
  const finalResp = stream.close();
  check(finalResp, {
    '文件上传最终完成': (r) => r.message.success === true,
  });
}

// 压测结束释放资源
export function teardown() {
  client.close();
}

二、两种调用在压测中的差异,以及阻塞的核心原因

2.1 应用场景的本质区别

一元调用适合“短平快”的业务,比如用户查询个人信息、提交简单表单,请求耗时通常在几十毫秒内,压测时每个请求独立,资源消耗小;流式调用适合长连接、持续交互的业务,比如大文件上传下载、实时聊天、视频流,需要维持长连接,压测时每个虚拟用户(VU)对应一个流,资源消耗更大。 举个例子:如果要测用户上传1G文件的场景,用一元调用需要一次性发送全部1G数据,压测时会导致单次请求太大,网络和服务端压力骤增,结果完全失真;用流式调用分块上传,才是贴合真实业务的压测方式。

2.2 技术优缺点对比

一元调用的优点:写法简单,压测容易控制,资源消耗低,阻塞概率低;缺点:不适合长交互场景,压测结果和真实业务脱节。 流式调用的优点:贴近真实业务,能测试长连接的稳定性;缺点:脚本逻辑复杂,需要处理流的建立、读写、关闭,很容易因为操作不当导致阻塞,比如没关闭流导致连接泄漏,或并发数超过服务端限制。

2.3 压测中阻塞的核心触发点

压测时的阻塞大多和资源限制或脚本错误有关:比如服务端配置的最大连接数不够,流式压测的VU超过连接上限,新的流无法建立;或k6脚本中对流的读写顺序错误,比如只写不读,导致服务端一直等待,VU卡住;或流没被正常关闭,导致连接被占满,后续请求无法建立。

三、压测中常见的阻塞问题,一步步排查

3.1 常见的阻塞表现

第一种:k6输出大量“deadline exceeded”错误,一元调用大概率是服务端处理太慢或网络延迟,流式调用大概率是流卡住在read/write操作; 第二种:压测过程中VU数量突然下降,或很多VU处于“running”状态但没有请求发送,说明流被阻塞,脚本卡在某一步; 第三种:服务端metrics显示连接数接近最大值,或有大量TIME_WAIT状态的连接,说明连接没被回收,导致资源耗尽。

3.2 排查的具体步骤(按顺序来,不绕弯路)

第一步:看k6的错误日志,找具体错误类型,比如“stream ended unexpectedly”说明流的一端提前关闭,“connection reset”说明连接被服务端强制断开; 第二步:检查压测脚本,尤其是流式调用的流操作:比如有没有每次写之后都读,有没有关闭流,有没有设置合理的超时时间(避免流卡住); 第三步:检查服务端配置,比如gRPC服务端的最大连接数、最大流数、线程池大小,比如如果服务端最大流数是100,压测VU超过100就会阻塞,需要调整配置; 第四步:用k6的调试模式,比如运行k6 run --debug test.js,能看到每个VU的执行步骤和耗时,精准定位卡住的位置。

3.3 实操排查示例

上周帮同事排查了一个流式压测的阻塞问题:用k6测文件上传,压测到20秒时,很多VU卡住,输出“timeout waiting for stream response”。首先看k6日志,发现错误都发生在stream.read()这一行;然后看脚本,同事没给stream.read()设超时,服务端在高并发下偶尔会延迟响应,导致stream.read()一直等待;最后给stream.read()加了500ms超时,调整服务端线程池从100到200,压测成功率从70%升到98%。

四、总结

k6测gRPC的一元和流式调用,核心差异是交互方式:前者是短平快的单次请求,后者是长连接的持续交互。压测时一定要贴合真实业务选调用方式,不能图方便用一元测流式业务,否则结果会失真。常见的阻塞问题,大多和脚本的流操作不当、服务端资源限制、连接回收不及时有关,排查时先看k6日志,再查脚本和服务端配置,一步步就能解决。