触屏版在线客服使用WebSocket技术替代传统的 Ajax 轮询方案,为了验证触屏版在线客服架构优化,预估架构优化后的性能是否可实现预期效果,避免及预防风险,因此对触屏版进行压力测试至关重要。
项目中使用了Spring websocket + SockJs + Stomp技术,虽然是基于websocket协议,但是对其进行了封装,数据传输格式有一定的差异,因此需要额外编写脚本来完成压测工作。
jmeter自身不支持websocket,需要使用websocket插件,loadrunner需要12+版本才支持websocket。
考虑到客户端数据传输格式的特殊性,需要通过编写java压测脚本来完成压力测试,由于jmeter天生对java的支持,以及简单易用性,因此选择了jmeter3.1作为本次压测工具。但是,jmeter使用java语言编写,GC的压力也是个大问题,因此还需要对jmeter进行性能调优。此外,使用GUI模式运行jmeter,经常会出现卡顿现象,因此在压测过程需要使用命令行方式运行jmeter。
java version "1.8.0_60" Java(TM) SE Runtime Environment (build 1.8.0_60-b27) Java HotSpot(TM) 64-Bit Server VM (build 25.60-b23, mixed mode)
VM_ARGS=-server -Xms6g -Xmx6g -Xmn5g -Xss128k %PERM% -XX:SurvivorRatio=8 -XX:TargetSurvivorRatio=80 -XX:ParallelGCThreads=24 -XX:MaxTenuringThreshold=15 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
对jmeter进行优化之后,可以在压测过程中抓取GC数据,判断GC活动的影响,如下所示,其中5000代表每隔5s钟抓取一次结果,10代表一共抓取10次:
jstat –gcutil PID 5000 10
下图是疲劳测试过程中(12小时),GC的统计数据,几乎可以忽略GC对测试的干扰
代码已上传至码云,https://gitee.com/bestkobe/websocket-test
如上图所示,根据实际的业务场景,ChatWebsocketClient中有index、clientPull、connect等主要方法,其中index、clientPull是http请求,通过org.apache.http.client.CookieStore保留cookie,后续的http请求将会携带cookie至服务端,相当于模拟浏览器的请求,具体的业务场景可根据项目需求额外编写,不是websocket压测的重点。接下来就是与服务端建立websocket连接,核心代码如下所示,完整代码请参考:https://gitee.com/bestkobe/websocket-test/blob/master/src/main/java/net/dwade/livechat/websocket/client/ChatWebsocketClient.java
public ChatWebsocketClient(String userAgent, String indexUrl, String pullUrl,
String chatUrl, String domain, String httpSessionId) {
this.userAgent = userAgent;
this.indexUrl = indexUrl;
this.pullUrl = pullUrl;
this.chatUrl = chatUrl;
this.domain = domain;
this.httpSessionId = httpSessionId;
//初始化通道以及SockJsClient
Transport webSocketTransport = new WebSocketTransport( new StandardWebSocketClient() );
List transports = Collections.singletonList( webSocketTransport );
this.sockJsClient = new SockJsClient( transports );
sockJsClient.setMessageCodec( MESSAGE_CODEC );
this.postConstruct();
}
protected void standardWebsocket() throws Exception {
//主要目的是设置Cookie请求头,注意格式,Cookie: SESSION=bbc43bd3-b38c-40d0-bf53-ad9967a11254
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.set( HttpHeaders.COOKIE, "SESSION=" + this.getHttpSessionId() );
WebSocketHttpHeaders wsHeaders = new WebSocketHttpHeaders( httpHeaders );
logger.info( "WebSocketHttpHeaders:{}", wsHeaders );
this.stompClient = new WebSocketStompClient( sockJsClient );
stompClient.setTaskScheduler( taskScheduler );
ListenableFuture future = stompClient.connect( chatUrl, wsHeaders, new SimpleStompSessionHandler() );
//阻塞连接
StompSession session = future.get();
subscribe( session );
this.stompSession = session;
afterConnected( session );
}
由于在测试websocket发送消息的时候,需要记录服务端异步响应的时间,因此扩展了ChatWebsocketClient类,重写了beforeSendMessage、subscribeCallback,这样便可以在消息发送前、接收到服务端异步响应时记录时间,从而得到每条消息的异步响应时间。此外,在运行的时候,还需要websocket容器的支持,因此引用了tomcat的jar包。
<dependency>
<groupId>org.apache.tomcat.embedgroupId>
<artifactId>tomcat-embed-coreartifactId>
<version>9.0.1version>
dependency>
<dependency>
<groupId>org.apache.tomcat.embedgroupId>
<artifactId>tomcat-embed-websocketartifactId>
<version>9.0.1version>
dependency>
<dependency>
<groupId>org.apache.tomcat.embedgroupId>
<artifactId>tomcat-embed-logging-log4jartifactId>
<version>9.0.0.M6version>
dependency>
编写jmeter脚本,继承org.apache.jmeter.protocol.java.sampler.AbstractJavaSamplerClient。在setupTest方法中,初始化Websocket客户端,并发出index、clientPull请求与客服建立对话(可根据实际的业务需求编写脚本),最后建立websocket连接。
/**
* 发送消息并发测试类,创建好Websocket连接后,并发发送消息,不需要等待服务端响应即认为一个事务结束
* @author huangxf
* @date 2016年12月27日
*/
public class SendMessageTest extends AbstractJavaSamplerClient {
private static Logger logger = LoggerFactory.getLogger( SubscritionConnectionTest.class );
private static String label = "WebsocketSendMessageTest";
private ChatClient client;
private String messageText;
/**
* 执行runTest()方法前会调用此方法,可放一些初始化代码
*/
@Override
public void setupTest(JavaSamplerContext context) {
//初始化参数
String userAgent = context.getParameter( "USER_AGENT" );
String indexUrl = context.getParameter( "URL_INDEX" );
String pullUrl = context.getParameter( "URL_PULL" );
String chatUrl = context.getParameter( "URL_CHAT" );
String domain = context.getParameter( "DOMAIN" );
String httpSessionId = context.getParameter( "HTTP_SESSION_ID" );
this.messageText = context.getParameter( "MESSAGE_TEXT" );
//创建Websocket客户端
client = new TimeLoggingChatWebsocketClient( userAgent, indexUrl, pullUrl, chatUrl, domain, httpSessionId );
logger.info( "Creat websocket client:{}", client.toString() );
// 与客服建立会话连接
client.index();
client.clientPull();
client.connect();
logger.info( "Websocket连接已创建" );
}
/**
* JMeter测试用例入口
*/
@Override
public SampleResult runTest( JavaSamplerContext context ) {
SampleResult sr = new SampleResult();
sr.setSampleLabel( label );
sr.sampleStart();
try {
client.sendMessage( messageText );
sr.setSamplerData( "Success" );
sr.setSuccessful( true );
} catch (Throwable e) {
logger.error( "Websocket消息发送失败!", e );
sr.setSamplerData( e.getMessage() );
//用于设置运行结果的成功或失败,如果是"false"则表示结果失败,否则则表示成功
sr.setSuccessful( false );
} finally {
sr.sampleEnd();
}
return sr;
}
/**
* 指定JMeter界面中可手工输入的参数
*/
@Override
public Arguments getDefaultParameters() {
Arguments args = new Arguments();
args.addArgument( "USER_AGENT", "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Mobile Safari/537.36" );
args.addArgument( "URL_INDEX", "http://10.255.201.166:18800/ac/test/livechat-touch-client/index?tenantId=1000055&code=BS&channelType=1021" );
args.addArgument( "URL_PULL", "http://10.255.201.166:18800/ac/test/livechat-touch-client/clientPull" );
args.addArgument( "URL_CHAT", "ws://10.255.201.166:18800/ac/test/livechat-touch-client/chat" );
args.addArgument( "DOMAIN", "10.255.201.166:18800" );
args.addArgument( "HTTP_SESSION_ID", null );
args.addArgument( "MESSAGE_TEXT", "Hello world." );
return args;
}
/**
* 线程测试结束后会调用此方法.
*/
@Override
public void teardownTest( JavaSamplerContext context ) {
//client.disconnect();
logger.debug( "After test." );
}
}
其中,getDefaultParameters方法是指定jmeter的可输入参数,runTest方法是jmeter压测时循环调用的方法,这里也就是发送文本消息。
org.springframework.util.ClassUtils.forName导致线程Blocked
在使用jmeter压测java脚本的时候,并发50线程,tps只有100,通过jstack发现好多线程Blocked,部分信息如下:
Thread 70 线程组 1-24 BLOCKED Fri Dec 30 15:38:22 CST 2016
java.lang.ClassLoader.loadClass(Unknown Source)
java.lang.ClassLoader.loadClass(Unknown Source)
org.springframework.util.ClassUtils.forName(ClassUtils.java:250)
org.springframework.util.ClassUtils.isPresent(ClassUtils.java:327)
org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.registerWellKnownModulesIfAvailable(Jackson2ObjectMapperBuilder.java:736)
org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.configure(Jackson2ObjectMapperBuilder.java:607)
org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.build(Jackson2ObjectMapperBuilder.java:590)
org.springframework.web.socket.sockjs.frame.Jackson2SockJsMessageCodec.(Jackson2SockJsMessageCodec.java:51)
net.dwade.livechat.websocket.jmeter.ChatWebsocketClient.standardWebsocket(ChatWebsocketClient.java:275)
net.dwade.livechat.websocket.jmeter.ChatWebsocketClient.connect(ChatWebsocketClient.java:213)
net.dwade.livechat.websocket.jmeter.NoSubscritionConnectionTest.runTest(NoSubscritionConnectionTest.java:58)
org.apache.jmeter.protocol.java.sampler.JavaSampler.sample(JavaSampler.java:196)
在Jackson2SockJsMessageCodec中的构造方法中,会调用Jackson2ObjectMapperBuilder的build方法,最终会用到ClassUtils的isPresent和forName方法,由于类加载器是阻塞加载类的,最终导致线程Blocked,影响程序性能。另外,看源码可知,在Jackson2SockJsMessageCodec中起作用的是com.fasterxml.jackson.databind.ObjectMapper,并且是线程安全的,因此可以共用一个Jackson2SockJsMessageCodec实例,避免类加载导致的Blocked。
优化之后,仍然发现有大量的Blocked,是在SockJsClient构造方法里面调用某个方法的时候出现的,由stack可知,这里面是在初始化json转换器的时候阻塞的:
Thread 49 线程组 1-1 BLOCKED Fri Dec 30 17:23:08 CST 2016
java.lang.ClassLoader.loadClass(Unknown Source)
java.lang.ClassLoader.loadClass(Unknown Source)
org.springframework.util.ClassUtils.forName(ClassUtils.java:250)
org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.registerWellKnownModulesIfAvailable(Jackson2ObjectMapperBuilder.java:727)
org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.configure(Jackson2ObjectMapperBuilder.java:607)
org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.build(Jackson2ObjectMapperBuilder.java:590)
org.springframework.http.converter.json.MappingJackson2HttpMessageConverter.(MappingJackson2HttpMessageConverter.java:57)
org.springframework.web.client.RestTemplate.(RestTemplate.java:174)
org.springframework.web.socket.sockjs.client.RestTemplateXhrTransport.(RestTemplateXhrTransport.java:61)
org.springframework.web.socket.sockjs.client.SockJsClient.initInfoReceiver(SockJsClient.java:117)
org.springframework.web.socket.sockjs.client.SockJsClient.(SockJsClient.java:105)
net.dwade.livechat.websocket.ChatWebsocketClient.standardWebsocket(ChatWebsocketClient.java:284)
net.dwade.livechat.websocket.ChatWebsocketClient.connect(ChatWebsocketClient.java:223)
net.dwade.livechat.websocket.jmeter.NoSubscritionConnectionTest.runTest(NoSubscritionConnectionTest.java:60)
org.apache.jmeter.protocol.java.sampler.JavaSampler.sample(JavaSampler.java:196)
org.apache.jmeter.threads.JMeterThread.executeSamplePackage(JMeterThread.java:475)
org.apache.jmeter.threads.JMeterThread.processSampler(JMeterThread.java:418)
org.apache.jmeter.threads.JMeterThread.run(JMeterThread.java:249)
java.lang.Thread.run(Unknown Source)
对应的代码如下所示:
org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.java
private void registerWellKnownModulesIfAvailable(ObjectMapper objectMapper) {
// Java 7 java.nio.file.Path class present?
if (ClassUtils.isPresent("java.nio.file.Path", this.moduleClassLoader)) {
try {
Class extends Module> jdk7Module = (Class extends Module>)
ClassUtils.forName("com.fasterxml.jackson.datatype.jdk7.Jdk7Module", this.moduleClassLoader);
objectMapper.registerModule(BeanUtils.instantiateClass(jdk7Module));
}
catch (ClassNotFoundException ex) {
// jackson-datatype-jdk7 not available
}
}
// other code......
}
org.springframework.util.ClassUtils.java
public static boolean isPresent(String className, ClassLoader classLoader) {
try {
forName(className, classLoader);
return true;
}
catch (Throwable ex) {
// Class or one of its dependencies is not present...
return false;
}
}
有些代码是在阻塞在727行,有些是阻塞在724行,org.springframework.util.ClassUtils.isPresent()中也是调用了ClassUtils.forName方法,这个forName方法主要逻辑就是调用ClassLoader的loadClass(),个人猜测在jvm中相同ClassLoader的loadClass()是阻塞的,当然这也和具体的实现有关。
传统的http协议必须等待服务器做出响应,才算完成一次请求,而websocket不同,并且由客户端发往服务端的速度非常快,如果不进行控制,服务端肯定是处理不了的,因此在压测过程中需要在jmeter中控制TPS域值,或者延迟时间。
主要分为以下场景:
- 并发连接,websocket客户端并发创建websocket连接(因为端口资源有限,因此未做大量并发压测);
- 不调用dubbo发送消息,验证spring websocket技术框架的性能;
- 调用dubbo发送消息,验证整体的性能
在压测脚本中,为了获取异步响应时间,需要将net.dwade.livechat.websocket.TimeLoggingChatWebsocketClient的日志单独输出至一个文件中,但是修改%jmeter_home%/bin/log4j.conf是不起作用的,需要修改%jmeter_home%/bin/jmeter.properties:
log_format=%{time:yyyy/MM/dd-HH:mm:ss.SSS} %{message} %{throwable}
log_level.net.dwade.livechat.websocket.TimeLoggingChatWebsocketClient=INFO
log_file.net.dwade.livechat.websocket.TimeLoggingChatWebsocketClient=MsgTimeLogging.log
其中log_format是修改jmeter的日志输出格式,log_file是指定TimeLoggingChatWebsocketClient的日志输出文件。
首先,将websocket脚本打成jar包,放至%jmeter_home%/lib/ext目录下面
然后,打开jmeter,在测试计划中添加websocket脚本需要依赖的jar包,包括spring websocket相关的jar,如下图所示:
可以用以下maven命令导出项目需要的jar包,其中-DoutputDirectory指定导出的目录
mvn dependency:copy-dependencies -DoutputDirectory=lib -DincludeScope=compile
右键测试计划,添加——Threads——线程组,然后在线程组上添加Java请求,右键添加——Sampler——Java请求,在左侧打开Java请求,选择类名称,修改请求参数,如下图所示,其中SendMessageTest是websocket发送消息的Java脚本:
接下来,再添加聚合报告即可完成jmeter的大体设置。最后,测试jmeter能否正常工作,可以用小的并发数在图形化界面上进行测试,测试OK之后再设置实际压测的线程组参数,比如并发数、持续时间等。
Ctrl+shift+s将测试计划另存为文件,便于后续在非GUI界面上使用。
使用命令行执行以下脚本:jmeter.bat -n -t D:\Test01\send.jmx -l D:\Test01\report.jtl,其中,-n是运行非GUI模式,-t是指定测试计划文件,-l是指定输出报告,注意:输出报告文件必须是预先创建的。
在压测场景的章节,也提到了websocket发送消息是异步的,因此需要控制消息发送的流量。有2种方法:
1. 使用固定定时器,控制每次发送消息的间隔时间,右键线程组——添加——定时器——固定定时器;
2. 使用TPS控制器,控制TPS域值,右键线程组——添加——定时器——Constant Throughput timer
websocket并发连接测试场景,由于客户端侧的TCP端口无法被及时释放,该压测场景取消了。
不调用dubbo的消息发送场景:
测试业务 并发数 固定时间(毫秒) TPS(条/秒) 平均异步响应时间(秒)
调用dubbo服务的消息发送场景:
测试业务 并发数 固定时间(毫秒) TPS(条/秒) 平均异步响应时间(秒)
说明:TPS数据由jmeter聚合报告给出,平均异步响应时间由压测脚本计算,在MsgTimeLogging.log日志读取。