SpringWebflux与SpringMVC性能对比及适用场景分析

101 篇文章 14 订阅

 

今天网上看到这篇文章,很欣赏作者进行性能对比时的分析思路,这里转载分享一下

前言

最近在做一个开源项目OpenQueue,这是一个IO密集型应用,需要API网关级别的并发性能。后端采用SpringBoot+Redis开发,原型开发完成后做了并发性能测试,和理想中的结果还有差距,因此开始寻找提升并发性能的途径。后来听说了WebFlux这样一种在Spring5中引进的非阻塞编程模型,而与之相对应的是SpringBoot默认的SpringMVC这样一种阻塞式模型。一看到非阻塞就想到了高性能,肯定是nginx和netty给我留下的刻板印象。后来再一调查,Spring Cloud Gateway,Zuul2都是用的SpringWebflux开发的,那还犹豫什么,直接上Webflux呗,有这么多nb项目背书。反形式编程又是一个听起来高大上的名词,赶紧学起来。于是花了一周时间学了SpringWebflux,并做了一个基准性能测试,看看这个SpringWebflux比SpringMVC到底厉害在哪方面,厉害多少。

基准性能测试

测试用代码都在这里,MVC版本Webflux版本

  • 简单REST接口测试
//MVC
@GetMapping(value = "/hello")
public String hello() {
	return "Hello!";
}

//Webflux
@GetMapping(value = "/hello")
public Mono<String> hello() {
	return Mono.just("Hello!");
}

使用ab进行压测,测试机器是一台阿里云8vCPU 16GB机器。

ab -c 1000 -n 100000 http://172.16.65.146:8080/hello

得到如下结果

MVC /hello
Requests per second:    32393.74 [#/sec] (mean)
Time per request:       30.870 [ms] (mean)

Webflux /hello
Requests per second:    28907.11 [#/sec] (mean)
Time per request:       34.594 [ms] (mean)
复制代码

从这个结果来说,两者性能不相上下,不过这也符合理论,要知道为什么说非阻塞要比阻塞性能好呢,是因为程序在做一些io操作时,例如发网络请求,从磁盘读写文件时,线程会进入阻塞状态,虽然不会再为其分配CPU时间直到阻塞结束,但是该线程就干不了别的事了(之前我看到别人说这句话的时候,就不明白这个线程还能干什么其他的事啊,为什么要干别的事啊,其实真能干很多别的事)。线程作为一种操作系统的宝贵资源(Tomcat默认也就200个线程),首先自身会占用1M左右的内存,其次线程太多,上下文切换也会带来时间开销,因此提高线程利用率,不让它闲下来阻塞在那里,就能用较少的线程完成原本的甚至更多的任务。那非阻塞的Webflux是怎么做的呢,在发起IO请求的时候,例如请求访问Redis时,会向Netty注册一个监听事件,然后发送Redis访问请求,这时不会阻塞等待结果而是处理其他任务(例如发送其他的Redis请求),当Redis返回了结果,刚才注册的事件就会触发并执行相应的响应方法,通过这种机制,Webflux仅仅使用CPU*2的线程数,就能干以前Tomcat需要200线程甚至更多线程才能干到的事。这就是为什么说非阻塞在处理IO任务时性能好的原因。

回到这个测试任务上,因为这里只是简单的返回一个字符串,并没有IO操作,因此Webflux不比MVC性能好也是应该的,接下来我们来测试一下IO操作,去访问Redis,这里使用的redis是阿里云提供的标准8G版Redis。Redis和服务器在同一内网。

  • 简单IO性能测试
//MVC
@GetMapping(value = "/io")
public String redis() {
    // 随机插入一个key, value
	redisTemplate.opsForValue().set(RandomCodeGenerator.get(), "iotest");
	return "Ok";
}

//Webflux
@GetMapping(value = "/io")
public Mono<String> redis() {
    // 随机插入一个key, value
	return reactiveRedisTemplate.opsForValue().set(RandomCodeGenerator.get(), "iotest")
			.thenReturn("Ok");
}

使用ab进行测试

ab -c 1000 -n 100000 http://172.16.65.146:8080/io

测试结果如下

//Webflux
Requests per second:    27501.09 [#/sec] (mean)
Time per request:       36.362 [ms] (mean)

//MVC
Requests per second:    21461.32 [#/sec] (mean)
Time per request:       46.595 [ms] (mean)

从这里的TPS对比结果看出,Webflux相对于MVC有28%的提升,可能你觉得还不够明显,毕竟还在一个数量级上。那是因为这里测试的数据库是Redis,它实在是太快了,响应时间基本上都在2ms内。理论上来说,响应时间越长,阻塞IO的性能越差,非阻塞的优势就越明显,我们可以通过实验来证明。我们接下来模拟一下耗时需要10ms~50ms的请求。

//MVC
@GetMapping(value = "/sleep/{time}")
public String sleep(@PathVariable int time) {
	try {
		Thread.sleep(time);
	} catch (InterruptedException e) {
		e.printStackTrace();
	}
	return "Sleep " + time + "ms, Current Time:" + System.currentTimeMillis();
}

//Webflux
@GetMapping("/sleep/{duration}")
public Mono<String> sleep(@PathVariable int duration) {
	return Mono.delay(Duration.ofMillis(duration))
			.thenReturn("Sleep " + duration + "ms, Current Time:" + System.currentTimeMillis());
}

测试命令

ab -c 1000 -n 100000 http://172.16.65.146:8080/sleep/10
ab -c 1000 -n 100000 http://172.16.65.146:8080/sleep/30
ab -c 1000 -n 100000 http://172.16.65.146:8080/sleep/40
ab -c 1000 -n 100000 http://172.16.65.146:8080/sleep/50

测试结果

请求耗时Webflux(TPS/RT)MVC(TPS/RT)
10ms23598/4219040/52
30ms20803/489807/101
40ms18317/544949/202
50ms16175/613963/252

从上面的结果可以看出,请求阻塞时间越长,Webflux的性能优势相较于MVC愈发明显,在50ms时,Webflux的并发性能是MVC的400%。

Webflux适用场景分析

从上面的测试结果可以联系到实际使用场景,如果我们的应用重度使用redis,那么单个请求注定延迟不会太高(如果高的话势必会拖累redis的性能,而且计算量这么大的redis操作,并发量也不会高,并发高耗时长的操作肯定要通过别的方式优化掉),这种场景下无论是阻塞式还是非阻塞式的并发性能差别不大,不建议将之前运行良好的程序使用Webflux进行重构,这样做的收益可能还比不上重构带来的成本(这是理论上的建议,实际还要在具体业务上做测试,要是性能提升不少,可对部分API进行反应式改造)。除Redis以外,现在支持反应式的数据库还有MongoDB,Cassandra等,而关系型数据库Mysql,Oracle的反应式数据库驱动并不成熟,还得等一段时间。那么对于当下来说,最适合上Webflux的场景就是MongoDB了,作为文档数据库,请求耗时相对较长。而Cassandra这种大数据数据库耗时可能更长,因此可以预见,如果你的应用程序需要访问MongoDB/Cassandra,并且后端使用阻塞式请求方式,那么现在使用Webflux进行重构,性能将会得到大幅提升。展望一下,等将来关系型数据库的反应式数据库驱动成熟了,大批依赖MySQL的应用将从阻塞式编程模型重构成非阻塞式。

另外一个场景,就是你的微服务需要调用多个别的微服务,导致整个调用链耗时较长,因此很适合使用Webflux提供的WebClient进行改造。WebClient是一个非阻塞的基于响应式编程HTTP客户端工具,这类场景的典型例子就是API网关,API网关对作为应用的入口对性能要求非常高,而它又是一个做转发功能的应用,要是采用阻塞式模型(Zuul1),每转发一个请求,就有一个线程等着上游服务器响应,那并发量性能一定很差。因此Zuul2和Spring Cloud Gateway都采用了Webflux的非阻塞式实现,性能得到了大幅提升。

Webflux的一个坑

在初步学习完Webflux并做完基准性能测试之后,对OpenQueue的最高频访问接口使用Webflux进行了重写,该接口最初使用SpringMVC实现,进行若干个Redis操作。重写之后进行了压测,TPS如下

MVC: 16516

Webflux: 7648

当时结果出来给我吓坏了,费心费力学了半天,重构半天,换来的是性能下降的结果,简直怀疑人生。这个问题是这样的,在一次用户请求处理过程中,要先访问一次redis,根据返回结果再发出另一个redis操作,接着再发出一个redis操作。简单来说就是串行的redis访问,这个过程在Webflux中的并发性能比在MVC中差。

//MVC
@GetMapping(value = "/io/{times}")
public String multiIO(@PathVariable int times) {
	assert times > 0;
	// 发起times次redis请求
	for (int i = 0; i < times; i++) {
		redisTemplate.opsForValue().set(RandomCodeGenerator.get(), "iotest");
	}
	return "Ok";
}

@GetMapping(value = "/io/{times}")
public Mono<String> multiIO(@PathVariable int times) {
    String value = RandomCodeGenerator.get();
    AtomicInteger index = new AtomicInteger(0);

    Function<Boolean, Mono<Boolean>> redisOperation =
            success -> reactiveRedisTemplate.opsForValue().set(value + ":" + index.incrementAndGet(), value);

    return Mono.just(Boolean.TRUE)
            .flatMap(redisOperation)
            .repeat(times - 1)
            .then(Mono.just("OK"));
}

用1000个并发线程做总共10w次测试,分别在单次http请求里进行1,2,3,10,15次redis请求。

ab -c 1000 -n 100000 http://172.16.65.146:8080/io/1
ab -c 1000 -n 100000 http://172.16.65.146:8080/io/2
ab -c 1000 -n 100000 http://172.16.65.146:8080/io/3
ab -c 1000 -n 100000 http://172.16.65.146:8080/io/10
ab -c 1000 -n 100000 http://172.16.65.146:8080/io/15

测试结果如下

 Webflux(TPS/RT)MVC(TPS/RT)
/io/127501/3621461/46
/io/216770/5919188/52
/io/311918/8321163/47
/io/103709/2698303/120
/io/152491/4015142/194

可以看出当一次请求里面发起2次以上的redis请求时,Webflux并发性能会低于MVC。

来看看TPS和并发度的关系。

ab -c X -n 1000 http://172.16.65.146:8080/io/10,X等于1-1000

从上面测试结果可以看出,MVC在线程数到200以后TPS达到上限(200作为一个性能拐点,正是因为Tomcat默认线程池的容量为200)。可以看出,MVC的TPS最终比Webflux要稳定高出很多。

但是,当一次http请求里只发一次redis请求时,无论并发多少,Webflux和MVC的性能都非常接近。

ab -c X -n 1000 http://172.16.65.146:8080/io/1,X等于1-1000

通过以上分析,要稳定复现这个问题,首先每次http请求里要发起多个redis请求,其次是并发度大于200。

是因为Webflux本身就比MVC要慢吗?

排除并发的影响,这次只用一个线程测试一千次,

ab -c 1 -n 1000 http://172.16.65.146:8080/io/10

Webflux和MVC的对比结果如下

//Webflux
Concurrency Level:      1
Time taken for tests:   4.528 seconds
Requests per second:    220.85 [#/sec] (mean)
Time per request:       4.528 [ms] (mean)

//MVC
Concurrency Level:      1
Time taken for tests:   4.648 seconds
Requests per second:    215.16 [#/sec] (mean)
Time per request:       4.648 [ms] (mean)

基本相同,说明不存在Webflux本身就比MVC要慢的可能,如果是Webflux自身开销,那么这里的性能差距也应该很明显。因此上述猜想不成立。

是Netty的锅吗?

Netty server作为Webflux默认服务器,会不会是Netty的配置有不对导致的性能下降?于是我将Netty server更换成了Undertow,Tomcat,Jetty。方法就是在pom里面将netty server依赖移除,再添加要替换的服务器。

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-webflux</artifactId>
	<exclusions>
		<exclusion>
			<artifactId>spring-boot-starter-reactor-netty</artifactId>
			<groupId>org.springframework.boot</groupId>
		</exclusion>
	</exclusions>
</dependency>

<!--添加Tomcat依赖-->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-tomcat</artifactId>
</dependency>

执行以下测试命令。

ab -c 100 -n 10000 http://172.16.65.146:8080/io/10

测试结果如下

 TPS/RT
Netty3932/25.4
Tomcat3933/25.4
Undertow4024/24
Jetty3703/27

可以看出,几乎没有区别。Netty不背这个锅。那就只能是Webflux及reactor的问题了。

是不是因为Redis太快了?

为了验证这个想法,我们将手动将每个redis请求的耗时延迟

//Webflux
@GetMapping(value = "/delayio/{times}")
public Mono<String> delayIO(@PathVariable int times, @RequestParam int delay) {
   assert delay >= 0;
   String value = RandomCodeGenerator.get();
   AtomicInteger index = new AtomicInteger(0);

   //使用Delay将每个请求耗时延迟
   Function<Boolean, Mono<Boolean>> redisOperation =
           success -> Mono.delay(Duration.ofMillis(delay)).then(reactiveRedisTemplate.opsForValue().set(value + ":" + index.incrementAndGet(), value));

   return Mono.just(Boolean.TRUE)
           .flatMap(redisOperation)
           .repeat(times - 1)
           .then(Mono.just("OK"));
}

//MVC
@GetMapping(value = "/delayio/{times}")
public String delayIO(@PathVariable int times, @RequestParam int delay) {
   assert times > 0;
   assert delay >= 0;

   for (int i = 0; i < times; i++) {
   	String value = RandomCodeGenerator.get();
   	String key = value + ":" +i;
   	try {
   		Thread.sleep(delay);
   	} catch (InterruptedException e) {
   		e.printStackTrace();
   	}
   	redisTemplate.opsForValue().set(key, value);
   }
   return "Ok";
}

接下来,为每个redis请求延迟5ms,使用以下测试命令

ab -c X -n 10000 http://172.16.65.146:8080/delayio/10?delay=5,X为并发度1~1000。

可以看出在将redis请求"变慢"之后,性能趋势立马反转了过来,也符合理论预期。因此,造成这个问题的原因就是单次redis请求实在是太快了,Webflux在这种场景下由于异步的开销反而使性能下降。

解决办法

最近学习了用Lua脚本来执行Redis命令,发现这个途径可以有效解决上述问题,上述场景就是后面的redis指令依赖前面的redis请求的结果,必须等前面的结果返会了才能接着发。这样一来就必然产生多次网络通信,要是使用Lua脚本的方式,把所有的操作写到Lua脚本里,只发一次redis请求就能得到最终结果,性能必定得到提升。如果你的请求之间没有先后顺序,使用redis的pipeline功能也能实现一次请求完成所有的操作。

Webflux总结

适用场景:

  • io密集型应用
  • 对并发性能要求高
  • 延迟较高的网络请求场景(10ms以上)
  • 支持响应式数据库驱动

优点:

  • 提供优雅的异步编程模型,大大提高io密集场景下的并发性能。
  • 有Spring生态背书,被大型开源项目采用(Zuul2,SpringCloud Gateway)。之所以提这个是因为Java界还有别的反应式编程框架/库,例如Vertx,RxJava,还有Akka(Scala)。

缺点:

  • 有一定的反应式编程学习成本,项目迁移成本。
  • 不适合编写复杂业务逻辑
  • 反应式编程Debug较难,出问题不易排查。
  • 相关资料还不多,未在市面上大规模应用,出现问题不如传统MVC一样容易找到资料或者人员帮忙。

题外话

最近学习了OpenResty,瞬间喜欢上了这个项目,也喜欢上了Lua语言,学习新东西的过程总是愉快的。并用上面同样的测试方式进行了访问Redis的性能测试。 SpringMVC,Webflux,OpenResty结果对比如下

 Webflux(TPS/RT)MVC(TPS/RT)OpenResty(TPS/RT)
/io/127501/3621461/4633217/30
/io/311918/8321163/4731270/31
/io/103709/2698303/12015500/64

OpenResty果真是对得起“高性能开发利器”的称号,借助于Nginx强大的性能优势,性能轻松超过前两者。但是,OpenResty只适用于实现简单的API接口,业务逻辑复杂的,或者并发量不高的接口还是建议使用Spring开发,毕竟使用Spring,Java进行项目开发维护会更加容易。

 

参考:

https://juejin.cn/post/6844904138287874055
https://blog.lovezhy.cc/2018/12/29/webflux%E6%80%A7%E8%83%BD%E9%97%AE%E9%A2%98/

https://cloud.tencent.com/developer/article/1540201

  • 18
    点赞
  • 44
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值