☰
JMeter调用Dubbo服务:泛化调用与Groovy脚本实践指南
2026/10/10 6:00:50 网站建设 项目流程

做接口测试这些年,HTTP接口用JMeter测那是顺理成章——新建线程组,加HTTP请求采样器,URL、参数、Header一填,监听器挂上,一条链路就出来了。但真到了Dubbo服务这里,JMeter却拿它没什么办法,至少默认功能没什么办法。Dubbo走的是RPC协议,默认端口20880,报文是Hessian2序列化后的二进制,调用之前还得先去注册中心发现服务地址、做负载均衡。你没法像HTTP那样直接给JMeter一个可编辑的请求体让它发出去。

这篇文章不打算绕开问题去教你怎么把Dubbo包一层HTTP来"曲线救国",而是直接在JMeter里原生调用Dubbo服务,通过泛化调用(GenericService)把任意Dubbo接口变成可配置、可参数化、可断言的JMeter采样器。整个过程会覆盖环境选型、JAR打包、Groovy脚本编写、数据驱动、常见报错排查,以及最终从单接口验证升级到并发压测时要注意的事。适合正在做服务端接口测试、或者被要求给Dubbo服务补一轮回归和压测的人参考。

1. Dubbo接口让JMeter犯难的根本原因:RPC与HTTP的测试差异

1.1 Dubbo调用链路里藏着的三个测试难点

先看一段正常的Dubbo调用是长什么样的。服务提供方(Provider)启动后把自己注册到注册中心(ZooKeeper、Nacos等),同时暴露一个Dubbo端口(默认20880);服务消费方(Consumer)启动时去注册中心订阅服务列表,拿到Provider地址后,通过dubbo协议与Provider建立长连接进行RPC调用。从测试视角看,这里面有三个点让JMeter的入门用户犯难:

  • JMeter没有内置Dubbo协议Sampler。JMeter原生支持HTTP、FTP、JDBC、JMS、SMTP等,就是没有dubbo。你把一个dubbo://地址填进HTTP请求里是没有任何意义的,因为协议、序列化、报文结构完全对不上。
  • 服务地址是动态的。HTTP测试时URL是写死的,Dubbo在多数生产环境里走的是注册中心动态发现,测试脚本需要有能力从注册中心拿到Provider列表,然后再完成负载均衡,这比写一个URL复杂得多。
  • 接口签名和序列化规则需要被正确复现。Dubbo方法调用不光要方法名,还要参数类型列表(方法重载时尤其关键),复杂对象参数要能被Hessian2等序列化器识别,返回值也可能是嵌套对象。用抓包改报文的方式基本不可行,必须走代码调用。

1.2 市面上常见的三种测试路线与选型

知道难点之后,很多团队的常规做法有三条路。我把它整理成一张表,供选型参考:

路线具体做法优点缺点
开发包一层HTTP桥接在服务端增加HTTP接口,把Dubbo调用封装成HTTP测试人员零门槛,用已有HTTP测试体系需要改生产代码,增加链路,测试结果没法真实覆盖Dubbo协议层面的问题
安装第三方Dubbo Helper插件网上有开源的JMeter Dubbo Sampler插件装完就能用,界面有配置项插件对Dubbo版本、JMeter版本、注册中心版本非常敏感,踩坑成本高,不少插件已停止维护
GenericService泛化调用+JSR223脚本自己写一段Groovy脚本,通过泛化调用发起RPC灵活、可控、不用改服务端代码,能直接跑通直连和注册中心两种模式需要自己打包依赖JAR、写脚本,初始门槛稍高

我自己一直用的是第三种。原因不复杂:测试场景变化快,今天明天可能分别要测不同服务、不同接口、不同版本,泛化调用这种方式只要维护一份脚本模板就够了;而第二种方案看着省事,实际上每次升级Dubbo或者换注册中心都会让人头疼。第一种方案能不用就不用,它掩盖了真实调用链路上可能出现的序列化、超时、线程池等问题。像Postman、Apifox这类工具,对付HTTP接口确实顺手,但拿到Dubbo场景下一样无能为力,因为它们根本不理解RPC协议的服务发现和序列化规则。

2. 环境准备:JDK版本、JMeter版本与JAR包的打包陷阱

2.1 先确认基础环境

这一步看似基础,但凡在这上面出问题的真不少。

  • JDK:JMeter 5.x官方要求Java 8及以上。我个人的建议是:本地测试机用JDK 8比较稳,如果机器整体是新的,也可以直接上JDK 11或17。需要提醒的是,如果你要测的Dubbo服务本身依赖了某些Java版本特性,尽量让JMeter运行的JDK版本和Consumer端接近,省得后面遇到奇怪的兼容问题。
  • JMeter:5.4之后的版本都可用,但新版并不总是更好。如果你已经装了5.x,直接用;如果还没装,去Apache官网下载binaries压缩包,Windows和Linux都是解压即用。千万不要用那些来路不明的"中文版"、整合版,环境一旦脏了,后面排查依赖问题会非常痛苦。
  • 环境变量:确认JAVA_HOME已经指向可用的JDK,并且java -version能正常执行。Windows老机器上把JAVA_HOME配好后,重新打开命令窗口再去启动jmeter.bat。

提示:Linux环境可以直接用sudo apt install jmeter,但它安装的可能是发行版仓库里的旧版本。如果生产环境用的Dubbo版本比较新,建议还是手动解压官方最新二进制包,避免因为JMeter版本太旧导致某些Groovy语法不支持。

2.2 Maven工程打包dubbo-client.jar

核心思路是:把测试Dubbo所需的Java依赖打进一个JAR,丢到JMeter的lib/ext目录。这样在JSR223脚本里,import org.apache.dubbo相关的类就不会报ClassNotFoundException。

我建议单独建一个Maven工程,把依赖按需引入。以Dubbo 2.7.x + Nacos注册中心为例,pom里至少要有这几项:

<dependency> <groupId>org.apache.dubbo</groupId> <artifactId>dubbo</artifactId> <version>2.7.15</version> </dependency> <dependency> <groupId>com.alibaba.nacos</groupId> <artifactId>nacos-client</artifactId> <version>2.1.2</version> </dependency>

如果你的注册中心不是Nacos而是ZooKeeper,上面这些Nacos依赖就不需要了,改成引入Curator相关的依赖。Curator是ZooKeeper官方推荐的Java客户端,Dubbo 2.7.x里与之配合得比较顺的是4.x版本。pom里这样加:

<dependency> <groupId>org.apache.curator</groupId> <artifactId>curator-framework</artifactId> <version>4.3.0</version> </dependency> <dependency> <groupId>org.apache.curator</groupId> <artifactId>curator-recipes</artifactId> <version>4.3.0</version> </dependency>

注意版本兼容:dubbo 2.6.x及以前是com.alibaba包名,2.7.x开始才是org.apache.dubbo,3.x同样沿用org.apache.dubbo。如果你们的服务还在用com.alibaba.dubbo,打包时就要引入com.alibaba:dubbo的老版本,脚本里的import也要跟着改。这是最容易踩的第一个坑。

2.3 把JAR放进lib/ext的正确姿势

打包完成之后,把生成的JAR连同传递依赖一起放到$JMETER_HOME/lib/ext下。需要注意的是:

  • 不要把dubbo的依赖跟JMeter自带的lib目录里的内容混在一起。JMeter的lib是它自己的核心依赖,lib/ext才是放扩展类的地方。粗心的做法是把所有jar一股脑丢进lib,短期看不出问题,后面极容易遇到类被错误加载的诡异现象。
  • 如果你的dubbo依赖特别多,建议用maven-shade-plugin把依赖打包成"胖jar",这样部署到其他JMeter机器时只需要复制一个文件。示例如下:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.2.4</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> </execution> </executions> </plugin>

丢进去之后,重启JMeter,在JSR223脚本里先写一行log.info(org.apache.dubbo.common.Version.getVersion())跑一次,能打印出dubbo版本号,说明类加载成功。这一步5分钟就能验证完,千万别直接跳到写脚本。

3. 核心实现:用Groovy脚本把GenericService泛化调用装进JMeter

3.1 为什么选JSR223+Groovy而不是BeanShell

很多JMeter老用户习惯用BeanShell Sampler写自定义逻辑,因为它是默认内置的,写起来也直观。但在Dubbo测试这种场景,我强烈建议用JSR223 Sampler,语言选Groovy。

理由有二。第一是性能。BeanShell解析脚本的效率不高,每次调用都需要解释执行,在压测场景下这会在客户端侧凭空消耗很多CPU,干扰你对服务端吞吐量的判断。Groovy脚本在JSR223引擎里有编译缓存,消耗小一个量级。第二是语法能力。泛化调用要处理Map、List、类型转换这些集合操作,Groovy写起来比BeanShell舒服太多,而且在脚本里直接用log.info、vars.put等JMeter内置对象的方式完全一致。

3.2 直连模式:先用一个接口把链路跑通

直连模式不借助注册中心,直接把地址写到配置里,适合联调环境或者单Provider场景。以最简单的一个sayHello(String)接口为例,在JMeter里添加一个JSR223 Sampler,语言选择Groovy,脚本大致如下:

import org.apache.dubbo.config.ApplicationConfig import org.apache.dubbo.config.ReferenceConfig import org.apache.dubbo.rpc.service.GenericService def app = new ApplicationConfig() app.setName("jmeter-dubbo-client") def ref = new ReferenceConfig() ref.setApplication(app) ref.setInterface("com.example.demo.DemoService") ref.setGeneric(true) ref.setUrl("dubbo://127.0.0.1:20880") ref.setTimeout(15000) ref.setRetries(0) def genericService = ref.get() def result = genericService.$invoke( "sayHello", new String[]{"java.lang.String"}, new Object[]{"world"} ) log.info("Dubbo result: " + result) vars.put("dubboResult", String.valueOf(result))

这段脚本的关键点:

  • ref.setGeneric(true),告诉Dubbo走泛化调用,这样一来不需要引入服务方的API接口JAR包,只需要接口全限定名。
  • setTimeout(15000),把单次调用超时设置得宽一点。Dubbo默认超时时间往往是1000ms,如果你直连的服务稍微慢一点,就会看到一直报超时,这会干扰判断。
  • setRetries(0),测试场景里一定把重试次数设为0。默认是2次,意味着一个失败请求会被重发两次,错误率会被放大三倍,对断言和压测结果都是灾难。
  • $invoke是GenericService接口里约定的方法名,参数依次是方法名、参数类型数组、参数值数组。参数类型必须是全限定类名,不能只写String。

加一个"查看结果树"监听器,跑一遍,如果输出里能看到Dubbo result: Hello world,说明链路已经通了一半。

3.3 接入Nacos注册中心:从单地址变成动态服务发现

直连模式跑通后,就要切到真正的生产形态了。大多数线上环境服务地址是动态的,Provider可能有多台,还按版本和分组做了隔离。这时候要用注册中心模式。Nacos配置如下:

import org.apache.dubbo.config.ApplicationConfig import org.apache.dubbo.config.ReferenceConfig import org.apache.dubbo.config.RegistryConfig import org.apache.dubbo.rpc.service.GenericService def app = new ApplicationConfig() app.setName("jmeter-dubbo-consumer") def ref = new ReferenceConfig() ref.setApplication(app) ref.setInterface("com.example.demo.OrderService") ref.setGeneric(true) def registry = new RegistryConfig() registry.setAddress("nacos://127.0.0.1:8848") ref.setRegistry(registry) // 如果服务配置了分组和版本,这里一定要对应填上 ref.setGroup("test") ref.setVersion("1.0.0") ref.setTimeout(15000) ref.setRetries(0) def genericService = ref.get() def result = genericService.$invoke( "getOrderById", new String[]{"java.lang.Long"}, new Object[]{1001L} ) log.info("Dubbo result: " + result) def map = (Map) result vars.put("orderId", String.valueOf(map.get("id"))) vars.put("orderStatus", String.valueOf(map.get("status")))

这里有一个非常容易踩的细节:如果你的服务在Provider端配置了group或version,消费端不填就找不到Provider,报错信息通常是没有provider。Java接口的@DubboService(group=...)这类注解配置和生产环境常见的多机房分组,都需要在脚本里对得上。

当方法返回的是一个复杂对象时,泛化调用返回的往往不是原始的DTO对象,而是一个Map,字段名和值都塞在里面。如果要取嵌套字段,就得一层层(Map)map.get("xxx")往下剥;如果返回的是List<UserDTO>,泛化调用返回的是List<Map>。这是dubbo泛化机制决定的,习惯了就好。

4. 数据驱动与断言:让脚本从"单个用例"变成"批量用例"

4.1 用CSV Data Set Config参数化输入

单个接口测一次很容易,但接口测试从来不只要测一次。把一组输入放到CSV里,让JMeter每次循环读取一行,这才是能被团队日常复用的测试资产。

在测试计划里添加CSV Data Set Config:

  • Filename:填CSV文件的绝对路径,或者你设置了JMeter属性basedir后写相对路径,避免每次换机器都要改。
  • 变量名:比如userId,expectCode,对应CSV表头。
  • 分隔符:默认逗号,如果字段里本身有逗号,要切换别的分隔符或者使用引号包裹。
  • 循环模式:一般选Never EOF,配合线程组循环,让每行数据都跑一遍。

CSV文件本身不用什么花哨格式,表头直接对应变量名,下面每行是一条用例。像我常用的样例大概长这样:

userId,expectCode 1001,0 1002,500 1003,0

然后在Groovy脚本里通过vars.get("userId")取到当前行的值,再传给$invoke。注意取出来的是字符串,如果是Long参数要Long.parseLong(...)转换,否则方法签名匹配不上,会直接报参数转换失败。

4.2 在Groovy里写断言并控制采样结果状态

很多人习惯用JMeter的"响应断言"(Response Assertion),但它在JSR223 Sampler里并不直接生效,因为响应断言主要服务于带响应数据的HTTP采样器。Dubbo场景下我最常做的,是直接在Groovy脚本里完成断言,并且把断言结果反馈到JMeter的采样结果里。

def result = genericService.$invoke("getOrderById", new String[]{"java.lang.Long"}, new Object[]{uid}) def map = (Map) result def actualCode = String.valueOf(map.get("code")) def expectCode = vars.get("expectCode") if (!expectCode.equals(actualCode)) { log.error("断言失败, uid=" + uid + ", expect=" + expectCode + ", actual=" + actualCode) sampleResult.setSuccessful(false) sampleResult.setResponseMessage("业务断言失败, expect=" + expectCode + ", actual=" + actualCode) sampleResult.setResponseData("result=" + (result == null ? "null" : result.toString()), "UTF-8") } else { log.info("断言通过, uid=" + uid + ", result=" + result) }

几点值得注意:

  • JSR223 Sampler里可以直接使用sampleResult这个变量,它就是当前采样结果对象。setSuccessful(false)后,这一条在查看结果树里会标红,聚合报告里的Error%也会跟着变。如果你只吐log不置采样结果状态,压测报告里看起来成功率100%,实际上业务全错了,这是最坑的伪通过。
  • 断言的粒度可以很业务。比如不只判断code=0,还可以判断返回列表长度大于0、某个关键字段非空等。Dubbo泛化调用返回的Map里,字段访问写map.get("xxx")即可。
  • 如果你对Beanshell比较熟,非要用也拦不住,但同样的逻辑在BeanShell里实现,压测时的客户端CPU会明显高于Groovy版本。JMeter官方从5.x开始也就一直在推荐JSR223+Groovy。

4.3 调试输出的几个实用姿势

调试Dubbo测试脚本时,经常需要把每次调用的返回值、耗时、入参打印出来。除了在查看结果树里看之外,我常用下面几种方式:

  • 直接log.info,在jmeter.log里过滤关键字,适合少量调用的排查。
  • 把结果追加写入文本文件,适合大批量跑完后做离线分析。Groovy里一行即可:
new File("/tmp/dubbo_result.txt").append("${vars.get('userId')},${result}\n")
  • 如果返回体是JSON字符串,先用JsonSlurper解析再取值,比手工用正则稳妥得多。比如返回字段在下一层时:
def json = new groovy.json.JsonSlurper().parseText(result.toString()) def status = json.data.order.status

5. 踩坑实录:类冲突、序列化异常、注册中心失联的完整排查链路

5.1 ClassNotFoundException和NoSuchMethodError:先怀疑类加载顺序

症状:JSR223脚本一启动就报java.lang.ClassNotFoundException: org.apache.dubbo.config.ReferenceConfig,或者运行到一半报NoSuchMethodError。

排查链路:

  1. 先确认JAR是否真的进了lib/ext,并且重启了JMeter。很多人改了lib目录不重启,肯定加载不到。
  2. 如果类名是com.alibaba.dubbo.*,说明你引的是2.6.x的包,而脚本写的是org.apache.dubbo.*,或者反过来。可以打开JAR确认ReferenceConfig所在的包路径。
  3. NoSuchMethodError通常是版本冲突:JMeter自己的classpath里存在了某个更老或更新的依赖,把dubbo的传递依赖覆盖了。解决办法是用maven-shade把依赖重定位或不传递依赖进JMeter核心lib,只保留在lib/ext的胖jar里。
  4. 最粗暴但是有效的手段:把dubbo相关JAR从lib目录挪走只留lib/ext,并且lib/ext里不要放两份版本不同的dubbo包。

5.2 接口一直返回null或者直接超时:注册中心与Provider版本问题

症状:脚本本身没有报错,返回结果一直是null;或者经常报Read timed out、No provider available。

排查链路:

  1. 先用直连模式打到一台确定的Provider上,如果直连能通,基本可以排除代码和序列化的问题,问题出在注册中心或服务发现。
  2. 确认注册中心地址、namespace、group、version是否配置一致。Nacos有namespace概念(默认public),Dubbo的Provider如果注册在别的namespace下,Consumer不指定namespace会直接看不到。
  3. 如果服务有多个Group,不设置setGroup会默认只在空分组里找,结果就是找不到provider。
  4. 超时问题先看Provider日志,很多情况下是Provider线程池满了,跟脚本没关系。把jmeter.log里带TimeoutException的报错时间和Provider端日志对应一下,很容易定位是不是服务处理不过来。
  5. 检查防火墙。Dubbo默认端口20880如果被防火墙拦了,Consumer拿到地址也连不上,表现就是注册中心有数据但调用全部超时。

5.3 $invoke方法签名不匹配:参数类型必须写全限定名

症状:报IllegalArgumentException、方法参数个数不匹配,或者抛出HessianException。

这类问题是泛化调用最常见的。GenericService对参数类型数组有严格要求:

  • 基本类型要写成包装类的全限定类名:long写java.lang.Long,int写java.lang.Integer,不是long.class。
  • 自定义对象要写全限定类名,并且参数值要用Map来填充字段。例如方法签名是saveUser(UserDTO user),泛化调用时写成:
def paramMap = ["id": 1001L, "name": "张三", "age": 25] def result = genericService.$invoke( "saveUser", new String[]{"com.example.dto.UserDTO"}, new Object[]{paramMap} )
  • 方法有重载时更要小心,参数类型数组必须精确匹配你要调的那个重载版本。
  • 如果Provider端方法返回的是void,泛化调用返回null是正常的,别当成失败。另外,个别Groovy版本对$invoke方法名的解析会有问题,遇到这种情况不用纠结,直接改反射调用:GenericService.class.getMethod("\$invoke", String.class, String[].class, Object[].class).invoke(genericService, ...)一样能跑。

整个排查思路是:先在IDE里用Java Consumer正常调一次,把真实的方法签名打印出来,再一格一格跟脚本里写的类型对比。

5.4 JMeter界面布局错乱:高分屏和字体的老问题

这个跟Dubbo无关,但我在准备环境时经常被问到:JMeter窗口在Windows高分屏下按钮重叠、控件撕裂。这是Java Swing在高DPI下缩放不到位导致的。我试过比较有效的办法:

  • 优先升级JDK到11或17,新版对高分屏的支持好得多。
  • 或者修改jmeter.bat里JVM参数,增加-Dsun.java2d.dpiaware=false,让Java不做DPI缩放,改由系统缩放。缺点是字体会发虚,看你能不能接受。
  • 再不行,把jmeter.properties里的jmeter.hidpi.mode相关配置翻出来调一下,不同JMeter版本参数名不一样,搜hidpi或scale关键词就行。

这问题不影响脚本执行,但会在演示汇报时显得很糟心,提前解决为好。

6. 从单接口验证到并发压测:线程组设计、TPS观察与Dubbo性能瓶颈

6.1 压测线程组与调度器基础配置

单接口脚本跑通之后,要做并发压测,核心改动在测试计划和线程组层面。

线程组里的参数虽然只有几个,但每个都直接影响压测曲线的形状。我用实际经验拆开说,主要配置三件事:

  • 线程数:代表模拟的并发用户数。从少到多逐级加,不建议一上来就200并发,那样出问题你都不知道是客户端先扛不住还是服务端先扛不住。
  • Ramp-up period:单位是秒,表示启动全部线程需要的时间。比如50个线程、Ramp-up 10秒,就是每秒大约启动5个线程。合理设置Ramp-up可以避免冷启动风暴对结果的影响。
  • 循环次数或者调度器:如果只跑一组用例,循环次数1就够了;做持续性压测,建议勾选"调度器",设置持续时间(比如300秒),让线程持续创建负载。

如果你想做更精细的流量控制,可以再加Constant Throughput Timer或者Throughput Shaping Timer,让TPS平滑上升,避免阶梯跳变带来的假高峰。

6.2 结果监听器与指标解读

压测结束后,一般加"聚合报告"(Aggregate Report)和"查看结果树"。聚合报告里重点看:

  • Samples:总请求数;
  • Average/Median/90% Line:响应时间分布;
  • Throughput:吞吐量,单位通常是/sec,这就是经常说的QPS或者TPS;
  • Error%:错误率。注意这里的错误是JMeter采样结果层面判定的失败,如果你没有在Groovy里给业务断言失败的请求打标,这里永远都是0%。

需要特别解释的是:当你在JSR223脚本里做了业务断言,聚合报告里的Error%才真正反映业务成功率。否则服务端虽然返回错误码,但在传输层看来它是一次成功的RPC调用,报告就会变得没有意义。

压测过程中建议开着"服务器性能监控"或直接看Provider端的监控面板。Apache Dubbo本身就带一些指标暴露能力,3.x版本的Metrics比老版好很多,可以让开发同学把QPS、线程池活跃数、响应时间这些指标拉出来,跟JMeter侧的数值做对照。如果JMeter侧吞吐量继续涨但Provider端CPU已经接近满载或者线程池排满队,瓶颈在服务端;如果Provider端一直很闲,而JMeter侧CPU跑满,说明瓶颈在你自己的压测客户端上,此时要减少线程数或增加JMeter负载机。

6.3 Dubbo服务端的线程池瓶颈和压测注意点

最后聊几个我在Dubbo压测中实际遇到、也比较有代表性的注意点。

第一,注意Provider线程池。dubbo协议默认的Provider端线程池大小是200,队列为0。当并发超过Provider处理能力时,请求会被快速丢弃或者等待,表现是超时率上升。压测报告里出现无形的天花板时,先去看Provider线程池是否已经打满,而不是一味加JMeter线程数。

第二,压测脚本里尽量不要做重逻辑,比如循环解析大JSON、写文件、多重嵌套的Groovy计算。这些都会拉高客户端CPU,导致JMeter成了瓶颈,测出来的TPS是假数据。把入参准备、断言逻辑控制得尽量精简。

第三,关闭不必要的重试。前面提到了setRetries(0),在压测场景里尤其重要,重试会让服务端接收到成倍的请求,错误被放大,也会污染你对真实吞吐率的判断。

第四,多负载机的扩展。单台JMeter在压RPC时通常能跑到几千TPS的量级,如果目标是要上万甚至更高TPS的压测,需要多台负载机分布式跑。JMeter本身支持分布式,把同样的dubbo-client.jar拷到每台负载机、确保JSR223脚本一致即可,没有额外需要特殊处理的点。

最后说一个自己的习惯:我一般会把被测服务的接口清单、方法签名、泛化调用脚本模板、CSV样例数据放在同一个目录里,随测试报告一起更新。因为过两个月再回来跑这轮回归时,最怕的不是JMeter不会用,而是发现自己当初调通的脚本又报了一堆版本错误。真到那一刻,你会发现当初把这些依赖和版本记录得越清楚,后面的日子越好过。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询