业务背景

触屏版在线客服使用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。

jmeter性能调优

  • 压测机硬件配置:24核,128G内存
  • jdk版本
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)
  • JVM参数优化:
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对测试的干扰

java websocket脚本

代码已上传至码云,https://gitee.com/bestkobe/websocket-test

websocket客户端

如上图所示,根据实际的业务场景,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;//初始化通道以及SockJsClientTransport webSocketTransport = new WebSocketTransport( new StandardWebSocketClient() );List<Transport> 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-ad9967a11254HttpHeaders 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<StompSession> 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.embed</groupId><artifactId>tomcat-embed-core</artifactId><version>9.0.1</version>
</dependency>
<dependency><groupId>org.apache.tomcat.embed</groupId><artifactId>tomcat-embed-websocket</artifactId><version>9.0.1</version>
</dependency>
<dependency><groupId>org.apache.tomcat.embed</groupId><artifactId>tomcat-embed-logging-log4j</artifactId><version>9.0.0.M6</version>
</dependency>

jmeter脚本

编写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()方法前会调用此方法,可放一些初始化代码*/@Overridepublic 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测试用例入口*/@Overridepublic 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界面中可手工输入的参数*/@Overridepublic 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;}/*** 线程测试结束后会调用此方法.*/@Overridepublic 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.<init>(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)

根据线程stack定位到自己的代码:

在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.<init>(MappingJackson2HttpMessageConverter.java:57)
org.springframework.web.client.RestTemplate.<init>(RestTemplate.java:174)
org.springframework.web.socket.sockjs.client.RestTemplateXhrTransport.<init>(RestTemplateXhrTransport.java:61)
org.springframework.web.socket.sockjs.client.SockJsClient.initInfoReceiver(SockJsClient.java:117)
org.springframework.web.socket.sockjs.client.SockJsClient.<init>(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.javaprivate 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.javapublic 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发送消息,验证整体的性能

jmeter操作

设置jmeter.properties

在压测脚本中,为了获取异步响应时间,需要将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日志读取。

spring websocket性能测试相关推荐

  1. spring websocket源码分析续Handler的使用

    1. handler的定义 spring websocket支持的消息有以下几种: 对消息的处理就使用了Handler模式,抽象handler类AbstractWebSocketHandler.jav ...

  2. Netty与Spring WebSocket

    刚开始的时候,我尝试着用netty实现了websocket服务端的搭建.在netty里面,并没有websocket session这样的概念,与其类似的是channel,每一个客户端连接都代表一个ch ...

  3. spring WebSocket详解

    场景 websocket是Html5新增加特性之一,目的是浏览器与服务端建立全双工的通信方式, 解决http请求-响应带来过多的资源消耗,同时对特殊场景应用提供了全新的实现方式, 比如聊天.股票交易. ...

  4. Spring websocket 使用@Autowired 出现null

    问题 在spring websocket 中使用@Autowired 出现空指针异常 原因 spring管理的都是单例(singleton),和 websocket (多对象)相冲突.websocke ...

  5. spring+websocket综合(springMVC+spring+MyBatis这是SSM框架和websocket集成技术)

    java-websocket该建筑是easy.儿童无用的框架可以在这里下载主线和个人教学好java-websocket计划: Apach Tomcat 8.0.3+MyEclipse+maven+JD ...

  6. Spring Websocket 使用笔记

    前言 现在主流的web容器基本均已支持websocket,但各容器的websocket接口都不尽相同.为了统一websocket实现,便于今后在不同web容器间的移植,这里使用spring webso ...

  7. 基于spring websocket+sockjs实现的长连接请求

    1.前言 页面端通常有需求想要准实时知道后台数据的一个变化情况,比如扫码登录场景,或者跳转到网银支付场景,在旧有的短轮训实现下,通常造成大量的不必要请求和查询,这里基于spring websocket ...

  8. Spring websocket+Stomp+SockJS 实现实时通信 详解

    Spring websocket+Stomp+SockJS 实时通信详解 一.三者之间的关系 Http连接为一次请求(request)一次响应(response),必须为同步调用方式.WebSocke ...

  9. spring websocket性能调优

    由于之前的排版较混乱,现重新整理发布 TProfiler工具 TProfiler是一个可以在生产环境长期使用的性能分析工具.它同时支持剖析和采样两种方式,记录方法执行的时间和次数,生成方法热点.对象创 ...

最新文章

  1. 中国首款L4级Robovan发布!文远知行商用落地两条腿走路
  2. nodejs之connect
  3. 算法题001 剑指Offer 面试题三:二维数组中的查找
  4. 电气期刊论文实现:二进制遗传算法求解考虑输电损耗的负荷最优分配【经济调度,有代码】
  5. 3.3.10 动态SQL
  6. logger 参数列表过长_[源码级解析] 巧妙解决并深度分析Linux下rm命令提示参数列表过长的问题...
  7. mongo快速翻页方法(转载)
  8. 没想到,我们的分布式缓存竟这样把注册中心搞垮!
  9. fatal error LNK1104: cannot open file 'libboost_regex-vc100-mt-gd-1_48.lib'
  10. None of the following candidates is applicable because of a receiver type mismatch
  11. 什么叫冷备用状态_线路和设备冷备用和热备用的状态分别是什么意思?
  12. Matlab求分段函数的积分
  13. C#版OPOS打印(基于北洋OPOS SDK二次开发包,支持EPSON和北洋、佳博、商祺等支持标准ESC/POS指令的POS打印机)
  14. 软考之下午题答题技巧
  15. window10快速关机小技巧(超级简单)
  16. 创 nginx v1.4.6 部署ThinkPHP 页面访问404 -- 酱油小君搬砖记
  17. MySQLStudy——Mac 下 Navicat Premium 12.1破解教程
  18. Arduino温度报警
  19. Jetpack学习之Paging
  20. 魔域单机版打不开mysql_《魔域单机版》安装教程 mysql没有运行 游戏启动不了问题解决...

热门文章

  1. 简单实现Java定时器
  2. 情人节程序员用HTML网页表白【76-谢谢你的爱】 HTML5七夕情人节表白网页源码 HTML+CSS+JavaScript
  3. 域名icp备案用什么工具查询?
  4. 工业相机和普通相机的区别详解_工业相机与普通相机区别
  5. 分布式和集群的区别是什么???
  6. 【微信小程序 四】二维码生成/扫描二维码
  7. beeline软件_Beeline软件
  8. 2020年四川省土地利用数据(矢量)
  9. Scrum 项目投资分析 - 如何计算投资回收期
  10. Linux入门基础及常见命令