从负载测试看
异步非阻塞的优势
前面总是 “安利” 异步非阻塞的好处,下面我们就实实在在感受一下响应式编程在高并发环境下的性能提升。异步非阻塞的优势体现在 I/O 操作方面,无论是文件 I/O、网络 I/O,还是数据库读写,都可能存在阻塞的情况。
我们的测试内容有三:
首先分别创建基于 WebMVC 和 WebFlux 的 Web 服务,来对比观察异步非阻塞能带来多大的性能提升,我们模拟一个简单的带有延迟的场景,然后启动服务使用 gatling 进行测试,并进行分析;
由于现在微服务架构应用越来越广泛,我们基于第一步的测试项目进一步观察调用存在延迟的服务的情况下的测试数据,其实主要是针对客户端的测试:阻塞的
RestTemplate
和非阻塞的WebClient
;针对 MongoDB 的同步和异步数据库驱动进行性能测试和分析。
“说明:
”
本节进行的并非是严谨的基于性能调优的需求的,针对具体业务场景的负载测试。本节测试场景简单而直接,各位朋友 GET 到我的点即可。
此外:
由于本节主要是进行横向对比测试,因此不需要特定的硬件资源配置,不过还是建议在 Linux 环境下进行测试,我最初是在 Win10 上跑的,当用户数上来之后出现了不少请求失败的情况,下边的测试数据是在一台系统为 Deepin Linux(Debian 系)的笔记本上跑出来的。
那么我们就开始搭建这套简单粗暴的测试环境吧~
带有延迟的负载测试分析
1)搭建待测试项目
我们分别基于 WebMVC 和 WebFlux 创建两个项目:mvc-with-latency
和WebFlux-with-latency
。
为了模拟阻塞,我们分别在两个项目中各创建一个带有延迟的/hello/{latency}
的 API。比如/hello/100
的响应会延迟 100ms。
mvc-with-latency
中创建HelloController.java
:
@RestController
public class HelloController {
@GetMapping("/hello/{latency}")
public String hello(@PathVariable long latency) {
try {
TimeUnit.MILLISECONDS.sleep(latency); // 1
} catch (InterruptedException e) {
return "Error during thread sleep";
}
return "Welcome to reactive world ~";
}
}
利用 sleep 来模拟业务场景中发生阻塞的情况。
WebFlux-with-latency
中创建HelloController.java
:
@RestController
public class HelloController {
@GetMapping("/hello/{latency}")
public Mono<String> hello(@PathVariable int latency) {
return Mono.just("Welcome to reactive world ~")
.delayElement(Duration.ofMillis(latency)); // 1
}
}
使用
delayElement
操作符来实现延迟。
然后各自在application.properties
中配置端口号 8091 和 8092:
server.port=8091
启动应用。
2)编写负载测试脚本
本节我们采用 gatling 来进行测试。创建测试项目gatling-scripts
。
POM 中添加 gatling 依赖和插件(目前 gradle 暂时还没有这个插件,所以只能是 maven 项目):
<dependencies>
<dependency>
<groupId>io.gatling.highcharts</groupId>
<artifactId>gatling-charts-highcharts</artifactId>
<version>2.3.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>io.gatling</groupId>
<artifactId>gatling-maven-plugin</artifactId>
<version>2.2.4</version>
</plugin>
</plugins>
</build>
在src/test
下创建测试类,gatling 使用 scala 语言编写测试类:
import io.gatling.core.scenario.Simulation
import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._
class LoadSimulation extends Simulation {
// 从系统变量读取 baseUrl、path和模拟的用户数
val baseUrl = System.getProperty("base.url")
val testPath = System.getProperty("test.path")
val sim_users = System.getProperty("sim.users").toInt
val httpConf = http.baseURL(baseUrl)
// 定义模拟的请求,重复30次
val helloRequest = repeat(30) {
// 自定义测试名称
exec(http("hello-with-latency")
// 执行get请求
.get(testPath))
// 模拟用户思考时间,随机1~2秒钟
.pause(1 second, 2 seconds)
}
// 定义模拟的场景
val scn = scenario("hello")
// 该场景执行上边定义的请求
.exec(helloRequest)
// 配置并发用户的数量在30秒内均匀提高至sim_users指定的数量
setUp(scn.inject(rampUsers(sim_users).over(30 seconds)).protocols(httpConf))
}
如上,这个测试的场景是:
指定的用户量是在 30 秒时间内匀速增加上来的;
每个用户重复请求 30 次指定的 URL,中间会随机间隔 1~2 秒的思考时间。
其中 URL 和用户量通过base.url
、test.path
、sim.users
变量传入,借助 maven 插件,通过如下命令启动测试:
mvn gatling:test -Dgatling.simulationClass=test.load.sims.LoadSimulation -Dbase.url=http://localhost:8091/ -Dtest.path=hello/100 -Dsim.users=300
就表示用户量为 300 的对http://localhost:8091/hello/100
的测试。
3)观察线程数量
测试之前,我们打开 jconsole 观察应用(连接 MVCWithLatencyApplication)的线程变化情况:
如图(分辨率问题显示不太好)是刚启动无任何请求进来的时候,默认执行线程有 10 个,总的线程数 31-33 个。
比如,当进行用户数为 2500 个的测试时,执行线程增加到了 200 个,总的线程数峰值为 223 个,就是增加的这 190 个执行线程。如下:
由于在负载过去之后,执行线程数量会随机减少回 10 个,因此看最大线程编号估算线程个数的话并不靠谱,我们可以用 “峰值线程数 - 23” 得到测试过程中的执行线程个数。
4)负载测试
首先我们测试mvc-with-latency
:
-Dbase.url=http://localhost:8091/;
-Dtest.path=hello/100(延迟 100ms);
-Dsim.users=1000/2000/3000/…/10000。
测试数据如下(Tomcat 最大线程数 200,延迟 100ms):
由以上数据可知:
用户量在接近 3000 的时候,线程数达到默认的最大值 200;
线程数达到 200 前,95% 的请求响应时长是正常的(比 100ms 多一点点),之后呈直线上升的态势;
线程数达到 200 后,吞吐量增幅逐渐放缓。
这里我们不难得出原因,那就是当所有可用线程都在阻塞状态的话,后续再进入的请求只能排队,从而当达到最大线程数之后,响应时长开始上升。我们以 6000 用户的报告为例:
这幅图是请求响应时长随时间变化的图,可以看到大致可以分为五个段:
A. 有空闲线程可用,请求可以在 100ms + 时间返回;
B. 线程已满,新来的请求开始排队,因为 A 和 B 阶段是用户量均匀上升的阶段,所以排队的请求越来越多;
C. 每秒请求量稳定下来,但是由于排队,维持一段时间的高响应时长;
D. 部分用户的请求完成,每秒请求量逐渐下降,排队情况逐渐缓解;
E. 用户量降至线程满负荷且队列消化后,请求在正常时间返回;
所有请求的响应时长分布如下图所示:
A/E 段与 C 段的时长只差就是平均的排队等待时间。在持续的高并发情况下,大部分请求是处在 C 段的。而且等待时长随请求量的提高而线性增长。
增加 Servlet 容器处理请求的线程数量可以缓解这一问题,就像上边把最大线程数量从默认的 200 增加的 400。
最高 200 的线程数是 Tomcat 的默认设置,我们将其设置为 400 再次测试。在application.properties
中增加:
server.tomcat.max-threads=400
测试数据如下:
由于工作线程数扩大一倍,因此请求排队的情况缓解一半,具体可以对比一下数据:
“最大线程数 200 用户 5000”的 “95% 响应时长” 恰好与 “最大线程数 400 用户 10000” 完全一致,我对天发誓,这绝对绝对是真实数据,更加巧合的是,吞吐量也恰好是 1:2 的关系!有此巧合也是因为测试场景太简单粗暴,哈哈;
“95% 响应时长” 的曲线斜率也是两倍的关系。
这也再次印证了我们上边的分析。增加线程数确实可以一定程度下提高吞吐量,降低因阻塞造成的响应延时,但此时我们需要权衡一些因素:
增加线程是有成本的,JVM 中默认情况下在创建新线程时会分配大小为 1M 的线程栈,所以更多的线程意味着更多的内存;
更多的线程会带来更多的线程上下文切换成本。
我们再来看一下对于WebFlux-with-latency
的测试数据:
这里没有统计线程数量,因为对于运行在异步 IO 的 Netty 之上的 WebFlux 应用来说,其工作线程数量始终维持在一个固定的数量上,通常这个固定的数量等于 CPU 核数(通过 jconsole 可以看到有名为
reactor-http-nio-X
和parallel-X
的线程,我这是四核八线程的 i7,所以X
从 1-8),因为异步非阻塞条件下,程序逻辑是由事件驱动的,并不需要多线程并发;随着用户数的增多,吞吐量基本呈线性增多的趋势;
95% 的响应都在 100ms + 的可控范围内返回了,并未出现延时的情况。
可见,非阻塞的处理方式规避了线程排队等待的情况,从而可以用少量而固定的线程处理应对大量请求的处理。
“除此之外,我又一步到位直接测试了一下 20000 用户的情况:1. 对
”mvc-with-latency
的测试由于出现了许多的请求 fail 而以失败告终;2. 而WebFlux-with-latency
应对 20000 用户已然面不改色心不慌,吞吐量达到 7228 req/sec(我擦,正好是 10000 用户下的两倍,太巧了今天怎么了,绝对是真实数据!),95% 响应时长仅 117ms。
果然,WebFlux更胜一筹
最后,再给出两个吞吐量和响应时长的图,更加直观地感受异步非阻塞的 WebFlux 是如何一骑绝尘的吧:
此时,我们更加理解了 Nodejs 的骄傲,不过我们大 Java 语言也有了 Vert.x 和现在的 Spring WebFlux。
作者:享学IT
链接:
https://blog.csdn.net/get_set/article/details/79492439