spring websocket性能测试

业务背景

触屏版在线客服使用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对测试的干扰

jmeter GC活动

java websocket脚本

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

websocket客户端

spring websocket性能测试_第1张图片

如上图所示,根据实际的业务场景,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脚本

编写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)

根据线程stack定位到自己的代码:
spring websocket性能测试_第2张图片

在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 jdk7Module = (Class)
                    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发送消息,验证整体的性能

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,如下图所示:

spring websocket性能测试_第3张图片

可以用以下maven命令导出项目需要的jar包,其中-DoutputDirectory指定导出的目录

mvn dependency:copy-dependencies -DoutputDirectory=lib  -DincludeScope=compile

右键测试计划,添加——Threads——线程组,然后在线程组上添加Java请求,右键添加——Sampler——Java请求,在左侧打开Java请求,选择类名称,修改请求参数,如下图所示,其中SendMessageTest是websocket发送消息的Java脚本:

spring websocket性能测试_第4张图片

接下来,再添加聚合报告即可完成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(条/秒) 平均异步响应时间(秒)

spring websocket性能测试_第5张图片

调用dubbo服务的消息发送场景:
测试业务 并发数 固定时间(毫秒) TPS(条/秒) 平均异步响应时间(秒)

spring websocket性能测试_第6张图片

说明:TPS数据由jmeter聚合报告给出,平均异步响应时间由压测脚本计算,在MsgTimeLogging.log日志读取。

你可能感兴趣的:(spring)