在正式上线之前,组里要求进行一次性能测试,希望能尽早发现问题,提前解决问题
要求使用jmeter进行最短路径测试,直接请求单个服务,将服务器性能压满
看看瓶颈在哪里
1.jmeter简介
Apache JMeter是Apache组织开发的基于Java的压力测试工具。用于对软件做压力测试,它最初被设计用于Web应用测试,但后来扩展到其他测试领域。 它可以用于测试静态和动态资源,例如静态文件、Java 小服务程序、CGI 脚本、Java 对象、数据库、FTP 服务器, 等等。JMeter 可以用于对服务器、网络或对象模拟巨大的负载,来自不同压力类别下测试它们的强度和分析整体性能。另外,JMeter能够对应用程序做功能/回归测试,通过创建带有断言的脚本来验证你的程序返回了你期望的结果。为了最大限度的灵活性,JMeter允许使用正则表达式创建断言。
Apache jmeter 可以用于对静态的和动态的资源(文件,Servlet,Perl脚本,java 对象,数据库和查询,FTP服务器等等)的性能进行测试。它可以用于对服务器、网络或对象模拟繁重的负载来测试它们的强度或分析不同压力类型下的整体性能。你可以使用它做性能的图形分析或在大并发负载测试你的服务器/脚本/对象。
——摘自jmeter百科
因为其简单易用的gui界面,免费的特性,故本次选择使用jmeter来进行性能测试。运行环境为Windows 10系统,JDK版本为1.8.0,JMeter版本为3.3。
2.jmeter的安装部署和使用
从apache jmeter官网选择合适的版本,下载安装包解压即可用
Jmeter依赖jdk,所以在运行前需要先配置好java的环境变量,打开cmd执行java -version,看到输出版本号证明环境变量配置成功
用nodepad++打开jmeter.bat脚本,可以看到依赖的jkd版本最低为1.8.0
windows下打开解压后的文件夹,运行jmeter.bat脚本文件即可打开gui界面,用于编辑测试脚本
新建一个空的测试计划,上传到linux服务器
配置linux执行jmeter的环境变量
配置完成后执行jmeter -v出现版本提示说明配置环境变量成功
执行jmeter –help
启动脚本命令可以参考help提示执行
启动脚本jmeter -n -t test.jmx -l result.jtl 可以看到如下结果
至此windows和linux下的jmeter部署成功
为了后续测试方便,定义了很多的变量,用于从启动脚本接收参数,避免每次更换测试接口都要重新改写脚本
**注意${__P(参数名,默认值)},大括号后是连续两个下划线**
测试时有时候需要一些前置操作,比如登陆,或者从某个接口获取参数传递给后面使用时,可以配置多个线程组,然后通过正则提取变量,设置自定义参数
当前因为需要获取用户token,配置了一个单独的登陆线程组,添加仅一次控制器用于获取token
在发送http请求的时候注意,如果参数格式是json,一定要加上信息头管理器,指定信息格式为json
登陆成功后可以看到已经获取到返回的token,此时使用正则表达式提取器提取token,设置到用户自定义变量中
同设置用户自定义变量时一样,脚本${__setProperty(token,${token},)}花括号后是连续两个下划线
添加一个空的http请求,在参数中将token添加进去,检验一下token是否提取设置成功
接下来配置实际压力测试的线程组,参数都是用预先定义的变量,这些变量的值都从启动脚本中获取
在头管理器中设置token ,定义好请求之后添加结果树等监控组件,保存即可
将脚本上传至服务器,执行启动脚本
jmeter -n -t test_args.jmx -l result.jtl -Jthreadnum=1000 -Jkeeptime=60 -Jusername=**** -Jpassword=**** -Jurl=/server/****/****/list -Jport=****
-J开头的都是向脚本传递的参数,如果想要更改并发数,或者请求的url,简单的更改启动命令即可,无需再编辑脚本文件
1. tps和qps
一个系统的吞度量(承压能力)与request对CPU的消耗、外部接口、IO等等紧密关联。单个reqeust 对CPU消耗越高,外部系统接口、IO影响速度越慢,系统吞吐能力越低,反之越高。系统吞吐量几个重要参数:QPS(TPS)、并发数、响应时间
**QPS(TPS)**:每秒钟request/事务 数量
**并发数**: 系统同时处理的request/事务数
**响应时间**: 一般取平均响应时间
(很多人经常会把并发数和TPS理解混淆)
理解了上面三个要素的意义之后,就能推算出它们之间的关系:
QPS(TPS)= 并发数/平均响应时间
一个系统吞吐量通常由QPS(TPS)、并发数两个因素决定,每套系统这两个值都有一个相对极限值,在应用场景访问压力下,只要某一项达到系统最高值,系统的吞吐量就上不去了,如果压力继续增大,系统的吞吐量反而会下降,原因是系统超负荷工作,上下文切换、内存等等其它消耗导致系统性能下降。
更加细致的介绍参见系统吞吐量(TPS)、用户并发量、性能测试概念和公式
2. Springcloud的hystrix熔断机制
生活中举个例子,如电力过载保护器,当电流过大的的时候,出问题,过载器会自动断开,从而保护电器不受烧坏。因此Hystrix请求熔断的机制跟电力过载保护器的原理很类似。
比如:订单系统请求库存系统,结果一个请求过去,因为各种原因,网络超时,在规定几秒内没反应,或者服务本身就挂了,这时候更多的请求来了,不断的请求库存服务,不断的创建线程,因为没有返回,资源也就没有释放
这也导致了系统资源被耗尽,你的服务奔溃了,这订单系统好好的,你访问了一个可能有问题的库存系统,结果导致你的订单系统也奔溃了,你再继续调用更多的依赖服务,可会会导致更多的系统奔溃,这时候Hystrix可以实现快速失败,
如果它在一段时间内侦测到许多类似的错误,会强迫其以后的多个调用快速失败,不再访问远程服务器,从而防止应用程序不断地尝试执行可能会失败的操作进而导致资源耗尽。这时候Hystrix进行FallBack操作来服务降级,
Fallback相当于是降级操作. 对于查询操作, 我们可以实现一个fallback方法, 当请求后端服务出现异常的时候, 可以使用fallback方法返回的值. fallback方法的返回值一般是设置的默认值或者来自缓存.通知后面的请求告知这服务暂时不可用了。
使得应用程序继续执行而不用等待修正错误,或者浪费CPU时间去等到长时间的超时产生。Hystrix熔断器也可以使应用程序能够诊断错误是否已经修正,如果已经修正,应用程序会再次尝试调用操作。
在性能测试的第一步,直接跑1000并发的脚本就遇到了熔断的问题。
Spring cloud中的feign默认开启了hystrix熔断,错误日志中打印了大量的fallback异常。
更多详细介绍参见Hystrix请求熔断与服务降级
3.jmeter的线程数(并发数)
jmeter的线程数:当前线程数量,可以简单的理解为jmeter模拟的并发用户数量。
• Ramp-up Period (in seconds):达到上面指定线程数所花费的时间,单位为秒。举个栗子:假设线程数为100个,花费时间20s,那么每秒启动的线程数 = 线程数/时间,即100/20 = 5。换句话说,就是1秒启动5个线程。
• 循环次数:勾选“永远”选项,则线程组一直循环。否则,以后面所填数量为准。
• Delay Thread creation until needed:当线程需要执行的时候,才会被创建。如果不勾选此选项,所有线程在开始时就全部被创建。
• 调度器:勾选此选项,才可修改下面的调度器配置。
4.服务器资源占用
当服务器启动应用后,在处理请求的过程中会消耗大量的资源。这里的资源一般意义上都指向服务器的cpu占用和内存占用,当cpu或内存占用接近100%时则服务器性能达到瓶颈,无法继续提供更多的服务。
所以通常在进行性能测试时会特别注意监控cpu的状态,占用大量cpu资源的进程以及该进程下的工作线程。
5.查看高cpu占用进程
测试时发现服务占用cpu资源过高,或者服务器状态异常时通常会使用top命令观察cpu使用率和内存的使用状况,
以此来监控服务器是否达到性能瓶颈。
6.查看高cpu占用线程
一个服务会启动一个进程,每个进程在linux中都会以pid的方式显示在进程列表中,当监控到某个进程占用大量cpu资源后,
可以通过top -H -ppid命令查看该进程下线程的资源使用情况,方便后续排查问题。
7.jvm
JVM 是 Java Virtual Machine(Java虚拟机)的缩写,它是一种规范,HotSpot VM是其最主流的实现(其他实现),通常我们讨论JVM如果没有特意说明是何种实现,便指的是HotSpot VM。JVM也并非仅支持Java语言,任何可编译为字节码的编程语言都可以运行在JVM上,例如前不久谷歌在 I/O 2017宣布将作为 Android 开发 First-Class 语言的 Kotlin。
如果你的机器上配置了java 环境变量,输入java -version即可看到最下面一行,该java版本使用了什么类型的VM虚拟机。
8.Jvm gc
GC即Garbage Collection,翻译为垃圾回收,通常简写做GC。 GC的行为有两种,YGC和FULLGC,一般不翻译。
YGC则是young gc的缩写,full gc一般不做缩写。
不管是YGC还是Full GC
GC过程中都会占用cpu资源,正确的选择合适的GC策略,调整JVM、GC的参数,可以显著减少由于GC工作,而导致的程序性能问题,进而提高应用的工作效率。
两种gc的策略和机制极大的关系到jvm的运行状态和效率,特别是full gc,会引起应用停顿,即常说的stop the world事件。所以通常调整jvm参数都是为了降低full gc的频次和持续时间,次数越低越好,频次越少越好。
若观察到短时间进行了大量的full gc就说明程序代码有问题,通常都是发生了内存泄漏事件,有大量的对象无法回收,被移动到了老年代,当老年代空间满了之后就会执行full gc,导致应用停顿,无法响应外界请求。
更多参数设置见JVM常用内存参数配置
更多详细GC及原理介绍见JVM原理和GC机制详解
9.tomcat的线程数和连接数
在NIoEndpoint处理请求的过程中,无论是Acceptor接收socket,还是线程处理请求,使用的仍然是阻塞方式;但在“读取socket并交给Worker中的线程”的这个过程中,使用非阻塞的NIO实现,这是NIO模式与BIO模式的最主要区别(其他区别对性能影响较小,暂时略去不提)。而这个区别,在并发量较大的情形下可以带来Tomcat效率的显著提升:
目前大多数HTTP请求使用的是长连接(HTTP/1.1默认keep-alive为true),而长连接意味着,一个TCP的socket在当前请求结束后,如果没有新的请求到来,socket不会立马释放,而是等timeout后再释放。如果使用BIO,“读取socket并交给Worker中的线程”这个过程是阻塞的,也就意味着在socket等待下一个请求或等待释放的过程中,处理这个socket的工作线程会一直被占用,无法释放;因此Tomcat可以同时处理的socket数目不能超过最大线程数,性能受到了极大限制。而使用NIO,“读取socket并交给Worker中的线程”这个过程是非阻塞的,当socket在等待下一个请求或等待释放时,并不会占用工作线程,因此Tomcat可以同时处理的socket数目远大于最大线程数,并发性能大大提高。
这也是后面可以配置2000线程4000连接数的原因。
10.mysql的连接数
连接数即允许同时连接DB的客户端的最大线程数。 如果客户端的连接数超过了max_connections,应用就会收到“too many
connections”的错误。
数据源框架会维护客户端的连接池,工作线程在发送sql请求时会尝试从连接池获取一个可用连接,如果获取不到就会等待,获取到了执行sql 得到返回结果将会马上归还连接,所以通常在框架中的配置配置的线程池大小不会太大。
1.加大熔断阈值
在最初进行性能测试的时候配置的jmeter线程数是100,没有遇到什么问题,但是tps不高。于是不断加大线程数到2000,希望测试到服务器能支撑的最大并发量。
此时观察到日志出现大量的hystrix和fallback异常。
添加上hystrix配置后,错误率明显下降,观察日志不再看到hystrix异常
2. 增加并发线程数
观察tps依然不高,尝试修改tomcat配置,加大线程数到2000,连接数到4000,tps有一定的提升,
cpu占用从600%左右提升到740%。
3. 加大redis连接池至越过redis访问接口
认为是可能是redis的问题,在等待获取连接。更改配置,加大redis的连接数,跳过redis访问,没有明显改善。
4. 加大jvm内存,使用jstat
测试过程中发现在运行初期tps比较高,但是运行一段时间之后会发生性能的急剧下降,cpu占用一直高位。
使用jdk自带的jvm监控软件jstat进行gc状态监控,发现运行过程中jvm老年代内存异常增长,几乎是几十兆上百兆的增长。
猜测是启动服务时给定的内存不够,于是调大jvm的启动内存到4g。
此时整个老年代内存达到2.8G,年轻代幸存区1和2分别160M,伊甸园大约1.3G。内存增长依然异常
5. 调整jvm年轻代老年代比例
内存给到4g无论如何是够用的,由于该服务是一个简单的查询接口,属于高通量服务。
于是调整年轻代老年代比例至1:1,此时老年代大小为2G,伊甸园1.7G。
这时观察到一个很奇怪的现象:无论怎么调整内存,老年代总是很快被占满。并且无法通过fullgc回收
1. 使用top,jstack等命令观察线程工作状态
再次启动测试,使用top -H -ppid列出进程下的线程状态,找到cpu占用异常飙高的线程。
使用jstack导出线程状态到文件,通过16进制线程id找到该线程
观察到占用异常的线程都是在进行fullgc
此时tps异常降低的原因初步定位到,但是究竟是什么原因导致的老年代无法释放,进而线程一直fullgc还未查明。
2.对于这种现象,我们重新写了一个什么都不干的空接口进行对比
不只是空接口,这个方法的请求参数同时还跳过了redis,网关等步骤。结果发现tps可以达到一万八左右,并且老年代根本不增长~
重新测试list接口,tps依然是600左右,问题依旧。
对此我们对原list方法进行了改造,把数据库请求也去掉,只保留一个空壳子。
测试发现问题依旧
再次进行改造,把请求参数都去掉,反正它也没什么参数。结果让人很惊讶,问题解决了:tps上去了,jvm的gc也正常了
**1.使用jmap导出jvm dump文件,用MAT进行分析**
Fullgc执行的条件是老年代被占满,新对象无法晋级移动至老年代。
而一个正常的应用fullgc次数应该是极少的,一天最多进行一次。
结合前面的实践结果,老年代增长太过迅速,fullgc却无法回收成功这些内存,导致老年代内存无法释放。
看起来像极了内存泄漏,于是在一次新的压力测试中,在进行到老年代占满,却无法回收,进而fullgc次数暴涨的时候,使用jmap导出dump文件,在linux压缩后下载到windows本机,使用MAT打开进行分析。
看看究竟是什么东西占用了如此巨大的内存资源,并且还无法释放。
通过MAT可以看到,com.alibaba.fastjson.parser.ParserConfig这个对象占用了50%的内存空间。
左键单击饼图占用最大的深色区块,选择with outgoing references
一路将占用最大的树展开
可以看到buckets里面存了有8000多个map
1.生成这么多的map,key必然不一样
2.展开key可以看到只有typeArguments这个地址不一样。它导致了map的key不一样
3.继续展开typeArguments,发现里面的内容完全一致
虽然我们还不知道这个类到底干嘛用的,但是可以得出一个初步结论:
fastjson在生成parseConfig类的时候会创建一个自定义map,里面仅仅是key不一样。而这个key不一样其实我们可以认为是一样的,因为其对象内容都是一致的。
接下来打开fastjson源码中parseConfig类,进行代码跟踪
原来这个map是用来存放反序列化对象的,往下查看哪里初始化了这个对象
跟进put方法查看
这里针对key做了一下hashCode操作。我们知道原生hashCode方法是根据地址值进行计算的
但是因为一些原因,某些对象虽然是相同的值,因为是new出来的,地址值不一样
可以推断,就是这里的问题导致了前面map中存在8000多个对象挤爆内存的问题。
根据dump文件的map结构,猜测这个地方的调用最相似
继续查看该方法的引用:
这就是我们常用的parseObject类了,看来入口没找错,回到上一级,梳理整个过程
Json.ParseObject–parseConfig.getDeserializer–deserializers.get–class.hashCode–createJavaBeanDeserializer-- deserializers.put
整个fastJson解析的过程大概是这样的,那么结合我们自己的代码,在spring中开启了fastjson解析器,并且在泛型中使用了通配符,再加上dump文件分析的结果,猜测是因为fastjson在获取通配符的时候匹配不正确,导致每一次的typeArguments对象都是new出来的
然后又没有重写其hashCode方法,在get的时候永远认为该对象不存在,于是不断的往parseConfig的map中添加新的反序列化解析器对象
同时parseConfig是一个全局变量,并不会被销毁,导致了前文测试的结果。
这种情况在访问量不高的情况下并不显著,但是当压力测试,成千上万的请求压过来时,老年代很快就被撑爆,并且始终无法回收,造成了内存泄漏的现象。
重新启动测试,虽然tps没有显著提升,内存增长依然很快,但是奇怪的内存泄漏问题至此解决