在容量测试时,“控量”是非常重要的,JMeter 是根据线程数大小来控制压力强弱的,但我们制定的压测目标中的指标往往是吞吐量(QPS/TPS),这就给测试人员带来了不便之处,必须一边调整线程数,一边观察 QPS/TPS 达到什么量级了,为了解决这个问题,JMeter 提供了吞吐量定时器的插件,我们可以通过设定吞吐量上限来限制 QPS/TPS,达到控量的效果。
上面的做法能够确保将吞吐量控制在一个固定值上,但这样还远远不够,实际工作中我们希望在每次压测执行时能够随时调节吞吐量,比如,在某个压力下服务容量没有问题,我们希望在不停止压测的情况下,再加一些压力,这样的功能该如何实现呢?
一、动态吞吐量方案
提供的方案也很简单,依然是基于固定吞吐量定时器(也叫常数吞吐量定时器),基本的实现原理是将吞吐量限制值设为全局变量(如下图中的 ${__P(throughput, 99999999)},throughput 就是变量,并给出很大的默认值),利用 JMeter 的 BeanShell 功能,通过执行外部命令的方式,在运行时注入具体值,达到动态调节吞吐量的目的。具体实现如下:
首先运行Beanshell服务,并开放9000和9001端口:
使用JMeter Beanshell作为服务器,用来执行Beanshell命令。通过调用beanshell函数来动态更新之前定义的“throughput ”参数。Beanshell是一个内置于JMeter中的Java源代码解释器
在jmeter.properties上取消beanshell.server.port的注释行
#---------------------------------------------------------------------------
# BeanShell configuration
#---------------------------------------------------------------------------
# BeanShell Server properties
#
# Define the port number as non-zero to start the http server on that port
beanshell.server.port=9000
# The telnet server will be started on the next port
#
# Define the server initialisation file
beanshell.server.file=../extras/startup.bsh
重启jmeter,可以看到端口的启动日志:
或者在主目录下打开cmd查看端口运行状态:
Netstat -ano | findstr “900”
可以看到监听到9000和9001端口,端口9000将用于http访问,端口9001将用于telnet访问(在防火墙下两个端口都必须开放)。然后我们通过外部命令访问BeanShell服务就能植入我们的BeanShell脚本和全局变量:
java -jar <jmeter_path>/lib/bshclient.jar localhost 9000 update.bsh <参数>
本地测试用 localhost,具体实际应用可以用IP来替换(分布节点通过启动jmeter-server来启动这个端口,所以IP就是针对节点IP,多个节点就需要分别执行调整)。
其中自定义的吞吐量更新脚本update.bsh如下:
import org.apache.jmeter.util.JMeterUtils;
getprop(p){ // get a JMeter property
return JMeterUtils.getPropDefault(p,"");
}
setprop(p,v){ // set a JMeter property
print("Setting property '"+p+"' to '"+v+"'.");
JMeterUtils.getJMeterProperties().setProperty(p, v);
}
setprop("throughput", args[0]);
其实我们还可以解开Jmeter的BeanShellClient的源代码,这样就看明白这些参数的关系(3个必选参数,第4个及以上就是要动态改变的参数),以及9001端口(telnet)的使用,如下代码:
private static final int MINARGS = 3;
public static void main(String [] args) throws Exception{
if (args.length < MINARGS){
System.out.println("Please provide "+MINARGS+" or more arguments:");
System.out.println("serverhost serverport filename [arg1 arg2 ...]");
System.out.println("e.g. ");
System.out.println("localhost 9000 extras/remote.bsh apple blake 7");
return;
}
String host=args[0];
String portString = args[1];
String file=args[2];
int port=Integer.parseInt(portString)+1;// convert to telnet port
System.out.println("Connecting to BSH server on "+host+":"+portString);
try (Socket sock = new Socket(host,port);
InputStream is = sock.getInputStream();
OutputStream os = sock.getOutputStream()) {
SockRead sockRead = new SockRead(is);
sockRead.start();
sendLine("bsh.prompt=\"\";", os);// Prompt is unnecessary
sendLine("String [] args={", os);
for (int i = MINARGS; i < args.length; i++) {
sendLine("\"" + args[i] + "\",\n", os);
}
sendLine("};", os);
int b;
try (BufferedReader fis = Files.newBufferedReader(Paths.get(file))) {
while ((b = fis.read()) != -1) {
os.write(b);
}
}
sendLine("bsh.prompt=\"bsh % \";", os);// Reset for other users
os.flush();
sock.shutdownOutput(); // Tell server that we are done
sockRead.join(); // wait for script to finish
}
}
现在就来测试一下,测试可以模拟大吞吐量(多余的请求会被控制器抛弃,过少的请求反而看不出控制效果),我设置了2000并发线程(宁多务少),然后进入Jmeter目录下,调用修改吞吐量的命令如下:
java -jar lib/bshclient.jar localhost 9000 update.bsh 1200
可以看到吞吐量被动态限制的效果图:
我们可以反复执行以上命令任意修改吞吐量的值,会发现TPS的值跟着变化:
二、吞吐量控制换算TPS
由于上面用到的固定吞吐量定时器的单位是每分钟(per minute),用起来很不直观,我们可以转换成按每秒计算(TPS),采用如下公式 ${__jexl3(${__P(throughput, 999999999)}*60,)} :
然后我们再做实验,先动态设定TPS为600,过一会儿再设定为1200:
看结果对比如下,按每秒吞吐量(TPS)更符合性能测试的使用习惯,分析图也会更直观:
三、线程组的动态TPS应用
以上吞吐量动态调整的主要思路来源于阿里巴巴本地生活前高级专家吴骏龙的付费文章,但实际上这个功能不算新鲜,只是很多人还不知道而已。很多基于Jmeter的性能测试平台或是全链路压测平台,都利用了这种思路来实现在页面上控制每个压测实例的吞吐量,既然能够控制吞吐量定时器,我们也可以用这种思路动态控制线程组的TPS,这在压测平台的TPS控制实现中很常见。
编写beanshell脚本,命名为 tpschange.bsh:
import org.apache.jmeter.util.JMeterUtils;
setprop(p,v){// set your TPS
print("Setting link '"+p+"' tps to '"+v+"'.");
JMeterUtils.getJMeterProperties().setProperty(p, v);
}
if (Integer.parseInt(args[1]) <= Integer.parseInt(args[2])){
setprop(args[0],args[1]);}
else{
setprop(args[0],args[2]);}
准备好带有全局变量的JMX脚本(使用bzm - Arrivals Thread Group线程组):
运行线程数动态设置的命令:
java -jar %JMETER_HOME%/lib/bshclient.jar localhost 9000 %JMETER_HOME%/tpschange.bsh api1 5 500
- %JMETER_HOME%/tpschange.bsh,beanshell脚本路径,可维护一个唯一路径,内容保持不变,需要改变时应通过统一入口进行管理批量更新。
- api1为接口ID,此ID应保持全平台永久唯一,用户确认调整TPS后命令中需将此ID代入args[0]进行指定接口TPS调整。
- 数字5为期望设置的TPS值,此处应该考虑多台机器分布式测试的情况,如用户使用5台机器进行分布式测试,命令中args[1]值统一换算公式应该为:用户输入的TPS/N (N为申请的压测机的数量)。
- 数字500为限制该接口链路的TPS值,单台机器能支撑的TPS是有限的,用户可以在一定范围内自定义输入TPS值,超过限制值后应使输入的TPS等于限制TPS值,对应命令中args[2]值, 统一换算公式应该为:args[2]=(用户输入的当前线程组的TPS / 当前JMX脚本包含的所有线程组TotalTps) * 压测平台约定单机限制的TPS.
- 单机限制的TPS在压测平台中应该配置成可维护的全局配置,可结合具体情况测试和微调。
按以下参数运行效果如下:
interfaceA:5TPS>20TPS>3TPS>END
interfaceB:10TPS>30TPS>1TPS>END
结合上图左右部分图示,可以看到初始TPS后的动态调整过程,以下是在压测平台里配置TPS的效果图:
四、动态并发线程数调整
讲述了TPS动态调整过程,你是否有考虑,并发线程数怎么调整?一般我们容量测试也好,全链路压测也好,关注的更多是TPS,也就是流量压力,而传统的线程组,可能会用到并发线程数来模拟并发用户,要想动态调整也可以用同样的思路,我们用bzm - Concurrency Thread Group线程组:
这里我们可以用${__P(threads, 1)},默认值1(同时用threads来替换前面的update.bsh脚本当中的throughput),来设置动态线程数,然后我们分别动态传入线程数1,5,10来看效果:
关于动态线程数的配置,不建议使用默认线程组Thread Group,可能原有的机制就不一样(后来的JMeterPlugins - Standard插件才引入了DynamicThread动态线程技术,参照插件源码 undera/jmeter-plugins),默认线程组的Number of Threads是最大预置线程数(不是当前最大活跃线程数),在脚本跑的过程中,通过Beanshell修改这个线程数值并不能实时生效(因为它属于在内存中预先创建了所有线程,不是一种Dynamic Thread),需要stop脚本重新start脚本才能生效,这样就失去了动态配置的意义了。
另外说明一下,为什么说并发线程数(VU)只是个手段,不是我们性能强调的点,首先从这个通用公式来理解:TPS=(1/RT)*VU,RT为响应时间,1为1秒,如果RT的单位为秒,那么公式中这个数字就是1,如果RT的单位为毫秒,那么公式中的数字是1000,VU为虚拟用户数对应JMeter中并发线程数。在压测中我们关注的强指标是TPS和RT,VU只是一个加压力的手段(为了能快速达到TPS量的一个方法,比如梯度加压),TPS是我们期待某个系统应该要达到的值,我们直接动态调整该值去测试系统能否达到,同时RT也会在不同TPS值下通过JMeter的压测结果知道,上述公式中一共3个变量,我们知道了两个,那么VU值自然就知道了。因此我们说性能测试的核心指标是TPS和RT。
五、平台化的实现原理
对于基于Jmeter的压测平台来说,可以用以上的原理来实现在压测过程中(脚本跑测不中断)动态的更新和配置Jmeter脚本参数,但是在具体实现上甚至可能比以上的方式还简单,因为我们完全可以不需要用beanshellServer来建立连接,我们完全可以在平台内部用Java直接调用Jmeter的方法来实现,这样可以绕过Beanshell脚本,直接修改压测线程的全局变量即可:
import org.apache.jmeter.util.JMeterUtils;
// 设置全局变量
JMeterUtils.setProperty(p, v);
// 获取全局变量
JMeterUtils.getPropDefault(p, "");
一般Jmeter中调用全局变量的方法${__P(变量, "")},可以完全用来调用jmeter.properties配置文件的配置,所以改全局变量,和改配置是一样的原理,我们一般在平台中可以加载和修改Jmeter的配置文件,进行参数设置,如下代码:
public void setJmeterProperties() {
String jmeterHomeBin = getJmeterHomeBin();
JMeterUtils.loadJMeterProperties(jmeterHomeBin + File.separator + "jmeter.properties");
JMeterUtils.setJMeterHome(getJmeterHome());
JMeterUtils.initLocale();
Properties jmeterProps = JMeterUtils.getJMeterProperties();
// Add local JMeter properties, if the file is found
String userProp = JMeterUtils.getPropDefault("user.properties", ""); //$NON-NLS-1$
if (userProp.length() > 0) { //$NON-NLS-1$
File file = JMeterUtils.findFile(userProp);
if (file.canRead()) {
try (FileInputStream fis = new FileInputStream(file)) {
Properties tmp = new Properties();
tmp.load(fis);
jmeterProps.putAll(tmp);
} catch (IOException e) {
}
}
}
// Add local system properties, if the file is found
String sysProp = JMeterUtils.getPropDefault("system.properties", ""); //$NON-NLS-1$
if (sysProp.length() > 0) {
File file = JMeterUtils.findFile(sysProp);
if (file.canRead()) {
try (FileInputStream fis = new FileInputStream(file)) {
System.getProperties().load(fis);
} catch (IOException e) {
}
}
}
jmeterProps.put("jmeter.version", JMeterUtils.getJMeterVersion());
// 配置client.rmi.localport,如配置端口60000
if(MasterClientRmiLocalPort() >= 0) {
JMeterUtils.setProperty("client.rmi.localport", Integer.toString(MasterClientRmiLocalPort()));
}
}
所以在平台化系统中,动态配置变量和修改参数就不再有技术上的难度,如果配合Jmeter的钩子程序,在每次请求发出后都能改变参数,但是要想在压测过程中,让改变的参数被压测线程所调用并生效,还是要靠上面的beanshellServer方案,关于beanshellServer可以看看Jmeter的代码,它是在启动Jmeter程序时启动,如下所示:
上面的startOptionalServers()调用就是启动beanshellServer,记住一定是startGui或startNonGui后才调用的,顺序错了,就算启动,也无法通过beanshellServer改变参数。startOptionalServers()的代码如下:
private void startOptionalServers() {
int bshport = JMeterUtils.getPropDefault("beanshell.server.port", 0);// $NON-NLS-1$
String bshfile = JMeterUtils.getPropDefault("beanshell.server.file", "");// $NON-NLS-1$ $NON-NLS-2$
if (bshport > 0) {
log.info("Starting Beanshell server ({},{})", bshport, bshfile);
Runnable t = new BeanShellServer(bshport, bshfile);
t.run(); // NOSONAR we just evaluate some code here
}
runInitScripts();
int mirrorPort=JMeterUtils.getPropDefault("mirror.server.port", 0);// $NON-NLS-1$
if (mirrorPort > 0){
log.info("Starting Mirror server ({})", mirrorPort);
try {
Object instance = ClassTools.construct(
"org.apache.jmeter.protocol.http.control.HttpMirrorControl",// $NON-NLS-1$
mirrorPort);
ClassTools.invoke(instance,"startHttpMirror");
} catch (JMeterException e) {
log.warn("Could not start Mirror server",e);
}
}
}
其中调用BeanShellServer的代码如下:
package org.apache.jmeter.util;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Implements a BeanShell server to allow access to JMeter variables and
* methods.
*
* To enable, define the JMeter property: beanshell.server.port (see
* JMeter.java) beanshell.server.file (optional, startup file)
*
*/
public class BeanShellServer implements Runnable {
private static final Logger log = LoggerFactory.getLogger(BeanShellServer.class);
private final int serverport;
private final String serverfile;
/**
* Constructor which sets the port for this server and the path to an
* optional init file
*
* @param port
* the port for the server to use
* @param file
* the path to an init file, or an empty string, if no init file
* should be used
*/
public BeanShellServer(int port, String file) {
super();
serverfile = file;// can be the empty string
serverport = port;
}
// For use by the server script
static String getprop(String s) {
return JMeterUtils.getPropDefault(s, s);
}
// For use by the server script
static void setprop(String s, String v) {
JMeterUtils.getJMeterProperties().setProperty(s, v);
}
@Override
public void run() {
ClassLoader loader = Thread.currentThread().getContextClassLoader();
try {
Class<?> interpreter = loader.loadClass("bsh.Interpreter");//$NON-NLS-1$
Object instance = interpreter.getDeclaredConstructor().newInstance();
Class<String> string = String.class;
Class<Object> object = Object.class;
Method eval = interpreter.getMethod("eval", string);//$NON-NLS-1$
Method setObj = interpreter.getMethod("set", string, object);//$NON-NLS-1$
Method setInt = interpreter.getMethod("set", string, int.class);//$NON-NLS-1$
Method source = interpreter.getMethod("source", string);//$NON-NLS-1$
setObj.invoke(instance, "t", this );//$NON-NLS-1$
setInt.invoke(instance, "portnum", serverport);//$NON-NLS-1$
if (serverfile.length() > 0) {
try {
source.invoke(instance, serverfile);
} catch (InvocationTargetException ite) {
Throwable cause = ite.getCause();
if (log.isWarnEnabled()) {
log.warn("Could not source, {}. {}", serverfile,
(cause != null) ? cause.toString() : ite.toString());
}
if (cause instanceof Error) {
throw (Error) cause;
}
}
}
eval.invoke(instance, "setAccessibility(true);");//$NON-NLS-1$
eval.invoke(instance, "server(portnum);");//$NON-NLS-1$
} catch (ClassNotFoundException e) {
log.error("Beanshell Interpreter not found");
} catch (Exception e) {
log.error("Problem starting BeanShell server", e);
}
}
}
所以,如果真想像Jmeter那样也通过BeanShellServer的方式来修改变量,那完全也可以按以上的逻辑重写代码或是直接调用以上方法即可。
参考文档:
07 | 工具进化:如何实现一个分布式压测平台-极客时间
jmeter之最佳实践 - zhengna - 博客园