做接口压测久了就会发现一个规律:HTTP 接口大家都会测,但一碰到 Dubbo RPC 接口,团队里能真正上手的人就少了一大半。JMeter 原生不认识 Dubbo 协议,也没有官方插件;网上搜到的方案要么停留在 2.6/2.7 时代,要么是拿第三方插件修修补补,遇到 Dubbo 3.x 的服务发现和 Triple 协议,直接就抓瞎。
这篇文章我基于自己的实操经验,给出一套从零到一的完整落地方案:用 JMeter 的 Java Sampler 自研压测客户端,覆盖 Dubbo 3.x 的接口调用、参数传递、结果断言、依赖冲突处理,以及整个压测过程中最容易踩的四个深坑。无论你是专职测试、后端开发还是运维,只要能看懂 Java 和 Maven,这套方案可以直接复制到项目里用,不需要等插件作者去适配新版本。
1. Dubbo 3.x 压测的难点在哪里:协议、服务发现与老方案的失灵
1.1 为什么 JMeter 不能像压 HTTP 接口一样直接压 Dubbo
JMeter 的 HTTP Sampler 基于 java.net/httpclient 实现,天然支持 GET/POST 等 HTTP 方法,所以开箱即用。但 Dubbo 走的是自定义 RPC 协议,默认的 dubbo 协议基于 TCP,协议头有魔数、序列化 ID、请求 ID、消息体长度等自定义字段,消息体还要经过 hessian2 / fastjson2 等序列化才能还原成 Java 对象。说白了,JMeter 根本不认识这种协议格式,必须有人替它完成“协议编解码 + 远程调用 + 结果反序列化”这一整套动作。
Dubbo 3.x 还多了一个变量:Triple 协议。Triple 基于 HTTP/2,兼容 gRPC,传输层和序列化方式和老版 Dubbo 协议完全不同。如果你要压测的服务用的是 Triple 协议,再用老的 dubbo:// 端口去直连,大概率直接报 protocol mismatch 异常。这也是很多团队升级到 3.x 之后,旧压测脚本一夜之间全部失效的根本原因。
1.2 2.x 时代的常用方案在 3.x 下为何失灵
我用过的老方案有三种,逐一说说它们在 3.x 下的结局。
第一种是直接用 JMeter 的 TCP Sampler 手写 Dubbo 协议报文。这个方案原理上可行,但需要自己拼请求头、管理序列化,代码量巨大,而且 Dubbo 3.x 的协议头在某些版本有调整,硬编码报文极度脆弱,我见过有人折腾一个星期最后放弃的,完全不推荐。
第二种是找现成的 Dubbo 插件。GitHub 上有一些 JMeter Dubbo 插件,但大多停留在几年前,依赖的是 Dubbo 2.7 的 API。把插件 jar 丢进 Dubbo 3.x 项目后,经常出现 NoClassDefFoundError 或者方法签名不一致的问题。插件作者不维护了,你只能自己改源码重新编译,等于把维护成本转嫁给了自己。
第三种是通过注册中心做服务发现之后再调用。这是过去比较主流的做法,在 JMeter 侧配一个 Zookeeper / Nacos 地址,运行时动态找 provider。但 Dubbo 3.x 默认开启了应用级服务发现,接口名到实例列表的映射关系变了,老版本的客户端往往拿不到正确的 provider 列表,导致“服务明明在线,但压测脚本找不到目标地址”。这已经超出了插件能修复的范畴。
1.3 解决思路总览:自定义 Java Sampler 是最可控的路线
试了一圈之后,我的结论很直接:用 JMeter 的 Java Sampler + Dubbo 官方客户端包,自己写一个压测 Sampler。Java Sampler 是 JMeter 官方预留的扩展点,继承 AbstractJavaSamplerClient 后,JMeter 会像调用原生请求一样调用你的代码,线程组、并发、监听器全部复用,不需要额外插件。
这个方案的收益有三个:一是直接使用 Dubbo 官方 API,版本和业务侧完全对齐,天然适配 3.x;二是代码量可控,一个类就能搞定泛化调用;三是出了问题你能看源码排查,而不是等插件作者更新。缺点是要求团队有一定 Java 基础,但考虑到 Dubbo 项目本身必然有 Java 开发,这个门槛并不算高。
2. 动手前必须定下的压测策略:直连还是走注册中心
2.1 直连模式的优势与适用场景
直连模式就是在 ReferenceConfig 里写死 provider 的地址,比如dubbo://192.168.1.10:20880,绕过注册中心直接发起调用。压测场景下我强烈建议优先考虑直连,原因有三个。
第一,压测本身会给注册中心带来额外压力。每初始化一个 ReferenceConfig,客户端都会去注册中心订阅配置和服务列表,如果压测机有几十个线程,相当于瞬间涌入大量订阅请求,干扰的其实是整个注册中心集群的健康状态,也会拖慢压测客户端的启动速度。
第二,直连模式精确可控。你可以明确指定压测流量只打到某台 provider 上,或者通过指定多地址来做目标节点范围内的负载均衡,方便单独评估某台机器的容量。走注册中心时流量会被负载均衡分散到整个集群,反而不好定位瓶颈。
第三,压测环境往往和注册中心网络隔离。我在实际项目里遇到过测试环境 Nacos 不稳定导致压测中断的情况,直连方式则完全没有这个顾虑,只要目标机器能通就行。
适用直连的场景:单机容量验证、指定节点压测、注册中心不稳定、压测环境网络隔离。如果你的目标是验证整个集群在注册中心环境下的整体承载能力,再考虑走注册中心方式。
2.2 注册中心模式什么时候必须用
如果你压测的目的是验证“生产/预发环境的真实调用链路”,那必须走注册中心。因为真实流量进来时,consumer 也是通过注册中心去找 provider 的,你只有复现这个过程,才能暴露服务发现层面的问题,比如应用级服务发现配置错误、Nacos 健康检查异常、provider 上下线不及时等。这种情况下 ReferenceConfig 里配置 RegistryConfig 指向 Nacos/GZooKeeper 即可。
这里要专门提一个 Dubbo 3.x 的细节:如果 Provider 配置了应用级服务发现(默认开启),ReferenceConfig 侧也要确保拿到的是正确的应用名映射。如果发现服务列表始终为空,优先检查 provider 的application配置和注册中心内的临时节点是否正常。
2.3 泛化调用 vs API 调用:怎么选
确定了直连还是走注册中心之后,还有一个选型问题:压测 Sampler 怎么调用业务接口。
第一种是 API 方式,也就是在 Maven 依赖里引入业务方提供的 api jar,比如order-api.jar,然后在代码里强类型调用OrderService#queryOrderById(String)。这种方式的优势是调用方式最接近真实 Consumer,性能也最好;缺点是压测工程必须依赖业务 jar,而且每次业务接口变更你都得重新打包压测客户端。
第二种是泛化调用(GenericService),ReferenceConfig 里设置setGeneric(true),然后通过GenericService.$invoke("queryOrderById", new String[]{"java.lang.String"}, new Object[]{"1001"})发起调用。这种方式不需要引入业务 api jar,只需要知道接口全类名、方法名和参数类型,非常适合压测团队和业务研发团队分离的场景。
我自己的经验是:压测前如果业务方能提供 api jar,优先用 API 方式,数据更接近真实;如果拿不到 jar,或者只是临时做容量评估,泛化调用完全够用。泛化调用的性能损失实测大概在 5% 到 10% 之间,主要是反射和参数封装的开销,对于压测结论的影响在可接受范围内。为了叙述方便,下面代码示例我用的是泛化调用版,因为它能用一个 Sampler 适配任意接口,扩展性最好。
3. 从零写一个可用的 Dubbo 3.x Sampler
3.1 Maven 工程与依赖版本组合
先搭 Maven 工程,Java 版本建议 8 以上,JMeter 5.x 最低要求 Java 8,Dubbo 3.x 也要求 Java 8。关键依赖如下:
<properties> <dubbo.version>3.2.0</dubbo.version> <jmeter.version>5.6.3</jmeter.version> <nacos.version>2.2.1</nacos.version> </properties> <dependencies> <dependency> <groupId>org.apache.dubbo</groupId> <artifactId>dubbo</artifactId> <version>${dubbo.version}</version> </dependency> <!-- 如果用注册中心模式,需要引入对应客户端 --> <dependency> <groupId>com.alibaba.nacos</groupId> <artifactId>nacos-client</artifactId> <version>${nacos.version}</version> </dependency> <!-- JMeter 接口,scope 必须为 provided,避免打进去造成冲突 --> <dependency> <groupId>org.apache.jmeter</groupId> <artifactId>ApacheJMeter_core</artifactId> <version>${jmeter.version}</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.apache.jmeter</groupId> <artifactId>ApacheJMeter_java</artifactId> <version>${jmeter.version}</version> <scope>provided</scope> </dependency> </dependencies>版本组合这块是有讲究的。Dubbo 3.2.0 是目前比较稳定的版本,对 Nacos 2.x 客户端兼容性很好,不要贪新用到 3.3 的 alpha 版,压测工具还是求稳。JMeter 用 5.6.3,它和 Java 8 兼容,太长尾的老版本 JMeter 对 Java Request 的支持其实不太一样,建议统一到 5.x 较新的版本。
如果走直连模式,理论上不需要 nacos-client,但 Dubbo 3.x 有些内部组件会引用注册中心抽象层,保留 Nacos 客户端依赖也不会有副作用,只是打包后体积变大。我一般直接留着,省得压测时临时想切注册中心模式还要改依赖重新打包。
3.2 核心代码:泛化调用版 Sampler
直接上完整代码,这个类就是整个压测方案的核心:
package com.example.jmeter.dubbo; 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; import org.apache.jmeter.protocol.java.sampler.AbstractJavaSamplerClient; import org.apache.jmeter.protocol.java.sampler.JavaSamplerContext; import org.apache.jmeter.samplers.SampleResult; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class DubboGenericSampler extends AbstractJavaSamplerClient { private static final Map<String, GenericService> SERVICE_CACHE = new ConcurrentHashMap<>(); private String interfaceName; private String methodName; private String paramTypes; private String params; private String directUrl; private String registryUrl; @Override public Arguments getDefaultParameters() { Arguments arguments = new Arguments(); arguments.addArgument("interfaceName", "com.example.api.OrderService"); arguments.addArgument("methodName", "queryOrderById"); arguments.addArgument("paramTypes", "java.lang.String"); arguments.addArgument("params", "1001"); arguments.addArgument("directUrl", "dubbo://192.168.1.10:20880"); arguments.addArgument("registryUrl", ""); return arguments; } @Override public void setupTest(JavaSamplerContext context) { interfaceName = context.getParameter("interfaceName"); methodName = context.getParameter("methodName"); paramTypes = context.getParameter("paramTypes"); params = context.getParameter("params"); directUrl = context.getParameter("directUrl"); registryUrl = context.getParameter("registryUrl"); } @Override public SampleResult runTest(JavaSamplerContext context) { SampleResult result = new SampleResult(); result.setSampleLabel(interfaceName + "." + methodName); result.sampleStart(); try { GenericService genericService = getGenericService(); // 简单场景:参数是字符串类型,多个参数用逗号分隔 Object[] args = params.split(","); String[] types = paramTypes.split(","); Object response = genericService.$invoke(methodName, types, args); result.setResponseData(String.valueOf(response), "UTF-8"); result.setSuccessful(true); } catch (Throwable e) { result.setSuccessful(false); result.setResponseMessage(e.getClass().getName() + ":" + e.getMessage()); } finally { result.sampleEnd(); } return result; } @Override public void teardownTest(JavaSamplerContext context) { // 由 JMeter 进程生命周期统一管理,无需额外处理 } private GenericService getGenericService() { String cacheKey = interfaceName + "|" + directUrl + "|" + registryUrl; return SERVICE_CACHE.computeIfAbsent(cacheKey, k -> createGenericService()); } private GenericService createGenericService() { ReferenceConfig<GenericService> reference = new ReferenceConfig<>(); reference.setApplication(new ApplicationConfig("jmeter-consumer")); if (directUrl != null && !directUrl.isEmpty()) { // 直连模式:绕过注册中心 reference.setUrl(directUrl); } if (registryUrl != null && !registryUrl.isEmpty()) { // 注册中心模式 reference.setRegistry(new RegistryConfig(registryUrl)); } reference.setInterface(interfaceName); reference.setGeneric("true"); reference.setTimeout(3000); reference.setRetries(0); reference.setCheck(false); return reference.get(); } }这段代码有几个关键设计点,我说一下为什么这样写。
首先是Retries=0。Dubbo consumer 默认调用失败会重试 2 次,压测时你看到的失败数会被重试机制掩盖,而且一旦服务端出现波动,重试流量会成倍放大,直接造成雪崩式误判。压测阶段强制关闭重试,这是铁律。
然后是SERVICE_CACHE缓存。每个线程只需要一个 GenericService 实例,它内部管理着连接池和负载均衡器。如果每执行一次请求都重新创建 ReferenceConfig,不仅性能极差,还会让 provider 侧看到大量重复的连接建立和销毁,严重污染压测数据。这里用 ConcurrentHashMap 做线程共享是安全的,ReferenceConfig#get 返回的代理是线程安全的,可以给多个线程并发调用。
还有Timeout=3000。这个要根据被测接口的实际耗时来调,如果接口平均耗时超过 3 秒,压测会被大量超时中断。我建议先用 JMeteter 单线程跑一小段,确认接口 P99 耗时之后,再把超时设置成 P99 的 2 到 3 倍。
3.3 关键参数解析:线程隔离、超时、重试
上面的代码我用了一个全局缓存来共享 GenericService,这意味着一个 JVM 内的所有线程共享同一个 Dubbo consumer。这在 Dubbo 中是允许的,因为 ReferenceConfig 创建的代理对象本身是线程安全的,内部的调用会走 Netty 连接池。但有一点必须注意:如果你在 setupTest 或者 runTest 里修改了 RpcContext,会影响所有线程的隐式传参。RpcContext 是 ThreadLocal 实现的,单个线程内设置是安全的,但不要企图用静态变量往 RpcContext 里塞全局数据。
超时和重试之间还有一个联动关系:重试次数 retries 的语义是“额外重试次数”,比如 retries=2 表示总共会执行 3 次。压测时如果服务端处理很慢但没抛异常,超时之后会自动重试,客户端看到的响应时间会被拉长,TPS 也会被重复请求稀释。所以我在代码里强制setRetries(0),这一点再强调也不为过。
setCheck(false)也很重要。默认情况下 ReferenceConfig.get() 会检查 provider 是否可用,如果压测开始时 provider 还没完全就绪,直接抛异常导致 Sampler 初始化失败。关闭检查后,调用时会懒加载,即使刚开始连不上,后续 provider 恢复了也能自动恢复。
3.4 打包:依赖冲突的终极处理手段
代码写完之后,打包这一步就是修罗场。JMeter 自己的 lib 目录和 lib/ext 目录里有大量 jar,你的 Dubbo Sampler 一旦引入重复的公共库,最常见的就是 NoSuchMethodError 和 ClassNotFoundException。
我的经验是用 Maven Shade 插件做重定向(relocation),把容易冲突的包挪到自己定义的命名空间下。这样即使 JMeter 里也有相同类名的库,两个版本也不会相互覆盖。
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.4.1</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> <configuration> <createDependencyReducedPom>false</createDependencyReducedPom> <filters> <filter> <artifact>*:*</artifact> <excludes> <exclude>META-INF/*.SF</exclude> <exclude>META-INF/*.DSA</exclude> <exclude>META-INF/*.RSA</exclude> </excludes> </filter> </filters> <relocations> <relocation> <pattern>com.alibaba.fastjson2</pattern> <shadedPattern>shaded.dubbojmeter.fastjson2</shadedPattern> </relocation> <relocation> <pattern>io.netty</pattern> <shadedPattern>shaded.dubbojmeter.netty</shadedPattern> </relocation> </relocations> </configuration> </execution> </executions> </plugin> </plugins> </build>Shade 插件会把所有非 provided 的依赖打进一个 fat jar。去掉 META-INF 下的签名文件是必须的,否则 JVM 会因为 jar 签名校验失败而拒绝加载类。重定向 netty 和 fastjson2 是我在多个项目实测下来最有效的两个目标,因为这两个库在 JMeter 生态里太常见了,版本差异也最大。
打包完成后,把target/xxx.jar复制到 JMeter 安装目录的lib/ext下,重启 JMeter,才能被正确加载。
4. JMeter 侧配置:线程模型、参数化与真实压测
4.1 线程组与 Ramp-Up:梯次加压才是正确姿势
代码就位之后,打开 JMeter,添加一个线程组,添加 Java Request,Sampler 类名填com.example.jmeter.dubbo.DubboGenericSampler。你会看到参数面板自动列出了代码中 getDefaultParameters 定义的五个参数,填入对应的接口名、方法名、参数即可。
但真正决定压测效果的不是这些参数,而是线程组的设计。很多人一上来就把线程数拉到 1000,Ramp-Up 设为 0,结果系统瞬间被打挂,连瓶颈在哪都没看出来。正确做法是梯次加压:先用 50 并发跑 5 分钟,观察 TPS 和响应时间;再逐步加到 100、200、500,每档运行 5-10 分钟,记录每个梯度的 TPS、P99、错误率。这样做的好处是你能清晰看到系统在哪个并发点开始进入饱和区,而不是只拿到一个“全挂了”的数据。
关于 Ramp-Up 的计算,有一个经验公式:线程数 / Ramp-Up 秒数要控制在 10 到 20 之间,也就是每秒增加 10-20 个线程。如果想让 200 个线程在 15 秒内全部启动,Ramp-Up 设置成 10 秒左右比较合适。太快了会瞬间打满线程池,太慢了压测前期数据没有参考价值。
4.2 参数化:CSV 数据集的正确设置
Dubbo 接口压测比 HTTP 压测更容易被忽略的一点就是参数化。如果所有线程都用“1001”这个订单号,服务端一旦有缓存,你压测的结果就全是缓存命中的假象,跟真实用户流量完全不符。
最常用的方式就是 CSV Data Set Config。准备一个 csv 文件,里面放一列或者多列测试数据:
1001,alice 1002,bob 1003,carol在线程组下面添加 CSV Data Set Config,文件名指向你的 csv,变量名称填orderId,userName,然后 Java Request 的 params 参数里写${orderId},${userName}。CSV Data Set Config 默认按行读取,每个线程读取一行,迭代一次后自动取下一条,循环结束会重新从头开始,这个特性刚好满足压测需求。
如果你的测试数据需要保证全局唯一,比如订单号不能重复,那就要用 UUID 动态生成。在 Java Request 之前加一个 JSR223 PreProcessor,用 Groovy 生成动态参数,然后放到 vars 变量里:
import java.util.UUID; String orderId = UUID.randomUUID().toString().replace("-", ""); vars.put("dynamicOrderId", orderId);Groovy 脚本的性能开销很小,每线程每次请求执行一次,相比 Dubbo 调用的耗时几乎可以忽略。
4.3 断言与结果处理:在 Sampler 内断言还是在 JMeter 层断言
Dubbo 泛化调用的返回值是 Object,可能是 String、Map、List,也可能是 null。如果返回的是 Map,你直接String.valueOf(response)得到的是{orderId=1001, status=SUCCESS},这不是标准 JSON,用 JSON 断言去解析会直接报错。
所以我的建议是:断言逻辑尽可能放在 Sampler 代码内部,而不是依赖 JMeter 的断言组件。比如泛化调用结束后,检查响应里是否包含期望的业务字段,直接在 runTest 里做判断,把结果直接标记为成功或失败。这样 JMeter 的聚合报告里统计的失败数就是真实业务失败数,而不是抛异常次数。
如果你确实需要把完整的响应内容展示到查看结果树里,可以在 Sampler 里对 Map 做一次 JSON 序列化再写入 responseData。有条件的话,直接让业务研发提供一个简单的结果埋点格式,压测数据的可信度会高很多。
4.4 监听器选型与 P99 指标怎么看
聚合报告(Aggregate Report)是压测标配,重点看 Samples、Average、Throughput、Error%、90% Line 和 99% Line。我通常还加一个 jp@gc - Response Times Over Time 和 jp@gc - Transactions per Second,这两个插件能从时间维度看到 TPS 和响应时间的趋势变化,定位波动点比聚合报告更直观。
这里有个容易误判的点:Dubbo 接口的平均耗时一般都很低,几十毫秒级别,但 P99 可能已经到了一两秒。如果 P99 是平均值的十倍以上,说明存在严重的长尾延迟,常见原因是服务端线程池排队、GC 停顿、跨机房网络抖动或者数据库偶尔慢查询。只盯着 Average 是看不出来这些问题的。
监听器和 Sampler 都配置好之后,跑一轮小并发冒烟测试,确认结果树里能正确返回业务数据,再放大线程数做正式压测。
5. 实测中那些坑:排查链路与根因分析
5.1 坑一:NoSuchMethodError / ClassNotFoundException 的排查思路
这个问题几乎每个把自定义 Dubbo Sampler 部署到 JMeter 的人都会遇到。启动 JMeter 后点击执行,结果树里直接抛 NoSuchMethodError 或者 NoClassDefFoundError,系列名是org.apache.dubbo.common.extension.ExtensionLoader之类。
排查链路要这么走:第一步确认你的 fat jar 是否完整包含了 Dubbo 依赖,用jar tf xxx.jar | grep ExtensionLoader看看类在不在;第二步检查 jar 是否被 JMeter 自身已经加载的重复类影响,用java -verbose:class启动 JMeter 或者直接看启动日志,定位类到底是从哪个 jar 加载的;第三步回到 Maven Shade 的 relocation 配置,把冲突更明显的包做重定向。
还有一种隐蔽情况:JMeter 的 classpath 里如果有旧的 Dubbo 2.x jar,优先级可能高于 lib/ext 里的 fat jar,导致加载到老版本类。这种情况我建议先清理 JMeter 目录里的旧 Dubbo 相关 jar,再检查环境变量 CLASSPATH 里是否混入了其他 Java 工程的依赖。
5.2 坑二:provider 返回 connection refused 但服务明明在线
直连模式下最容易踩这个坑。你确认了服务端 20880 端口是听着的,netstat 也看到了 TCP 连接,但压测请求就报 Connection refused。
根本原因十有八九是直连地址写错了。Dubbo 3.x 下 provider 可能同时暴露了 dubbo 协议端口和 triple 协议端口,如果你用dubbo://10.0.0.5:20881去连一个 triple 协议端口,或者反过来,都会出现协议不匹配。先用telnet 10.0.0.5 20880确认端口能通,再检查 provider 配置中的protocol类型,确保 ReferenceConfig 的 URL 协议头与实际端口协议一致。
另一个常见问题是防火墙或者安全组只放行了注册中心端口,没有放行 RPC 端口。压测机和 provider 之间跨机房时尤其常见。我用过一个快速排查套路:在压测机上启动一段最简单的 Java Socket 代码去连对应端口,如果连接失败,问题一定在网络层,与 Dubbo 无关。
5.3 坑三:TPS 上不去、CPU 爆表的定位链路
压测时发现服务端 CPU 已经跑到 90% 以上,但 TPS 一直没有明显增长,这时候要先判断瓶颈在客户端还是服务端。
客户端侧排查:先看 JMeter 所在机器的 CPU 和网络连接数。Dubbo consumer 默认每个地址一个长连接,如果压测机有上百个线程但连接数很少,可能是连接池配置不足或者代码里重复创建了 ReferenceConfig。再确认ulimit -n是否足够大,文件描述符不够会导致连接被拒绝,现象也是请求失败。
服务端侧排查:CPU 高但 TPS 低,大概率是线程池打满。Dubbo provider 默认的dubbo.protocol.threads是 200,如果并发超过 200,请求就会排队,排队时间反映在响应时间的 P99 上。可以用jstack抓一下线程快照,看看有多少线程卡在ThreadPoolExecutor的等待队列里。如果是业务代码有锁竞争或者远程调用嵌套,还要结合 Arthas 定位具体热点方法。
我还遇到过一个奇怪的现象:TPS 上不去是因为服务端的业务代码里有一个Thread.sleep(500),这是典型的为了模拟耗时留下来没有移除的测试代码。压测前先让开发告知接口内部是否有明显的 sleep 或死循环,能省下大量排查时间。
5.4 坑四:压测数据污染了业务环境
这一点不是技术问题,但比技术问题更致命。Dubbo 接口压测时,你传的订单号是真实存在的,那么压测流量就会修改真实订单状态,发短信、扣库存、打流水,全部真实发生。我见过有人用生产环境的 Dubbo 配置做压测,结果把线上订单全部取消了,最后靠 DBA 回滚数据库才恢复。
所以压测之前必须确认三件事:压测环境是否与生产网络隔离;压测的 provider 地址是否指向测试集群;接口是否有幂等保护。这三个问题任何一个没有得到确认,都不要启动压测。面对不确定的情况,使用 UUID 等随机数据做参数化,也能降低数据碰撞的污染风险。
6. 压测结果怎么判读:指标、瓶颈定位与后续调优
6.1 核心指标对照表
压测报告里我通常会整理成一张这样的表,判断系统是否健康:
| 指标 | 健康范围 | 注意点 |
|---|---|---|
| TPS | 随目标而定 | 观察趋势是否平稳,波动大说明瓶颈在抖动 |
| Average RT | 小于目标值 | 受偶发请求影响较小 |
| P99 RT | 小于目标值的 3-5 倍 | 长尾请求的真实反映 |
| Error% | 小于 0.1% | 压测时重试必须关掉,否则错误数失真 |
| CPU | 小于 80% | 达到 90% 以上通常意味着达到扩容线 |
| LoadAverage | 小于核数 | 超过核数需要关注线程阻塞 |
| Full GC 次数 | 近 5 分钟为 0 | 频繁 Full GC 会导致 RT 雪崩 |
有些团队只看 TPS 有没有达标,完全忽略 P99 和 GC。实际上系统在压测中后期往往会出现“TPS 勉强达标但 P99 已经不可用”的场景,比如平均值 80ms,P99 却到了 3 秒,这种系统上线后体验一定崩。所以性能是否达标,要看 P99 和 Error% 两项硬指标,而不是平均值。
6.2 从聚合报告的一列看懂系统瓶颈
聚合报告里有一个被低估的列:Received KB/sec。如果这个数值出现断崖式下跌,但 TPS 还在涨,说明响应报文在变短,很可能是服务端开始返回降级结果或者部分字段为空。我在一次压测中看到 Received 从 800KB/sec 降到 200KB/sec,排查后确认是服务端触发了熔断,把核心数据给拦截了,只返回错误码。只看 TPS 根本发现不了这个问题。
还有一个经验:如果 Error% 在某个并发梯度突然从 0 跳到 10% 以上,不要立刻把锅甩给服务端,先看看是不是 Dubbo 默认的超时时间到了。比如你设置了 Timeout=3000,服务端 P99 耗时正好在 2.8 秒附近,并发一上来,超过 3 秒的请求就会批量超时。这个在压测报告里的直观表现就是 Error% 和 P99 同时飙升。
6.3 调优粒度:客户端还是服务端
发现瓶颈后,调优要分层进行,不要一上来就动 JVM 参数或扩机器。
第一层看客户端:压测机的连接数是否够、参数化是否合理、Sampler 代码里有没有明显的序列化浪费。泛化调用比 API 调用慢 5%-10%,如果这个损耗影响了结果精度,可以换成直接调用业务 API。客户端优化是最容易忽略的一层,因为大家总想着服务端才是瓶颈。
第二层看服务端:Dubbo provider 的线程池大小、IO 线程是否被打满、业务代码里有没有慢 SQL、缓存穿透、锁竞争。用 Arthas 的dashboard命令能快速看到各线程的 CPU 占用,用trace命令定位具体方法的耗时分布。
第三层看基础设施:网络带宽、跨机房延迟、磁盘 IO、GC 日志。我记得有一次压测 TPS 始终到不了目标,最后定位到是压测机和 provider 在同一个宿主机上,超卖导致 CPU 竞争,把压测机迁移到独立物理机后数据马上正常了。
调优最忌讳的是同时改多个变量。一次只改一个配置,跑一轮压测,对比指标,再改下一个,这样才能找到真正起作用的因素。我一般把每次调整的内容和结果记录成一张参数调整表,方便复盘。
整套方案落地之后,后续如果还想扩展,可以考虑把 Sampler 接入持续集成流水线,每次发版前自动跑一轮小流量压测,让性能回归成为常规动作。用 JMeter 压 Dubbo 3.x 的核心就是:自己掌控客户端、按场景选直连或注册中心、重视参数化和断言、学会从数据中定位瓶颈。这套路一旦跑通,以后换任何 Dubbo 版本,你只需要改版本号和协议配置,沉没成本非常低。