朱晔和你聊Spring系列S1E5:Spring WebFlux小探

阅读PDF版本

本文会来做一些应用对比Spring MVC和Spring WebFlux,观察线程模型的区别,然后做一下简单的压力测试。

创建一个传统的Spring MVC应用

先来创建一个新的webflux-mvc的模块:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>me.josephzhu</groupId>
    <artifactId>spring101-webflux-mvc</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>spring101-webflux-mvc</name>
    <description></description>

    <parent>
        <groupId>me.josephzhu</groupId>
        <artifactId>spring101</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-mongodb</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

然后在项目里定义一个我们会使用到的POJO:

package me.josephzhu.spring101webfluxmvc;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Document(collection = "mydata")
public class MyData {
    @Id
    private String id;
    private String payload;
    private long time;
}

这里的@Document和@Id是为Mongodb服务的,我们定义了MyData将会以mydata作为Collection的名字,然后id字段是Document的Id列。
然后我们来创建Controller,在这个Controller里面我们尝试三种不同的操作:

  1. Sleep 100ms的纯获取数据的方法。从请求中获得length参数作为payload字符串的长度,从请求中获得size参数作为MyData的个数。我们在之后的测试过程中可以随意调节这两个参数来调整我们的数据量。
  2. 从Mongodb获取数据的方法,获取到数据后直接返回。
  3. 复合逻辑。先走HTTP请求从data方法获取数据,然后把数据保存进入Mongodb,最后返回这些数据。
package me.josephzhu.spring101webfluxmvc;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

@RestController
public class MyController {
    @Autowired
    private RestTemplate restTemplate;
    @Autowired
    private MyRepository myRepository;

    @GetMapping("/data")
    public List<MyData> getData(@RequestParam(value = "size", defaultValue = "10") int size,@RequestParam(value = "length", defaultValue = "100") int length) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {

        }
        String payload = IntStream.rangeClosed(1,length).mapToObj(i->"a").collect(Collectors.joining());
        return IntStream.rangeClosed(1, size)
                .mapToObj(i->new MyData(UUID.randomUUID().toString(), payload, System.currentTimeMillis()))
                .collect(Collectors.toList());
    }

    @GetMapping("/dbData")
    public List<MyData> getDbData() {
        return myRepository.findAll();
    }

    @GetMapping("/saveData")
    public List<MyData> saveData(@RequestParam(value = "size", defaultValue = "10") int size,@RequestParam(value = "length", defaultValue = "100") int length){
        UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl("http://localhost:8080/data")
                .queryParam("size", size)
                .queryParam("length", length);
        ResponseEntity<List<MyData>> responseEntity =
                restTemplate.exchange(builder.toUriString(),
                        HttpMethod.GET, null, new ParameterizedTypeReference<List<MyData>>() {});
        return responseEntity.getBody().stream().map(myRepository::save).collect(Collectors.toList());
    }
}

注意,在这里我们使用了Java 8的Steam来做一些操作避免使用for循环:

  1. 通过length参数构建payload(payload由length个字符a构成)。
  2. 通过size参数构建MyData的List。
  3. 在RestTemplate获取到MyData的List后,把每一个对象交由myRepository的save方法来处理,然后统一收集返回结果。
    这些Stream的代码都是同步处理,也不涉及外部IO,和非阻塞没有任何关系,只是方便代码编写。为了让代码可以运行,我们还需要继续来配置下Mongodb的Repository:
package me.josephzhu.spring101webfluxmvc;

import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface MyRepository extends MongoRepository<MyData, String> { }

因为我们没有用到复杂的查询,在代码里只是用到了findAll方法,所以这里我们无需定义额外的方法,只是声明接口即可。
最后,我们创建主应用程序,顺便配置一下Mongodb和RestTemplate:

package me.josephzhu.spring101webfluxmvc;

import com.mongodb.MongoClientOptions;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@SpringBootApplication
@Configuration
public class Spring101WebfluxMvcApplication {

   @Bean
   MongoClientOptions mongoClientOptions(){
       return MongoClientOptions.builder().connectionsPerHost(1000).build();
   }

    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder builder) {
        return builder.build();
    }

    public static void main(String[] args) {
        SpringApplication.run(Spring101WebfluxMvcApplication.class, args);
    }
}

这里我们配置了Mongodb客户端使得之后在进行压力测试的时候能有超过100个连接连接到Mongodb,否则会出现无法获取连接的问题。

创建WebFlux版本的应用

现在我们再来新建一个webflux模块:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>me.josephzhu</groupId>
    <artifactId>spring101-webflux</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>spring101-webflux</name>
    <description></description>

    <parent>
        <groupId>me.josephzhu</groupId>
        <artifactId>spring101</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-mongodb-reactive</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
        
        <dependency>
            <groupId>io.projectreactor</groupId>
            <artifactId>reactor-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

这里可以注意到,我们引入了webflux这个starter以及data-mongodb-reactive这个starter。在之前的Spring MVC项目中,我们引入的是mvc和data-mongodb两个starter。
然后,我们同样需要创建一下MyData类(代码和之前一模一样,这里省略)。
最关键的一步,我们来创建三个Controller方法的定义:

package me.josephzhu.spring101webflux;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.time.Duration;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import static org.springframework.web.reactive.function.server.ServerResponse.ok;

@Component
public class MyHandler {
    @Autowired
    private MyReactiveRepository myReactiveRepository;

    public Mono<ServerResponse> getData(ServerRequest serverRequest) {
        int size = Integer.parseInt(serverRequest.queryParam("size").orElse("10"));
        int length = Integer.parseInt(serverRequest.queryParam("length").orElse("100"));

        String payload = IntStream.rangeClosed(1,length).mapToObj(i->"a").collect(Collectors.joining());
        Flux<MyData> data = Flux.fromStream(IntStream.rangeClosed(1, size)
                .mapToObj(i->new MyData(UUID.randomUUID().toString(), payload, System.currentTimeMillis()))).delaySequence(Duration.ofMillis(100));

        return ok()
                .contentType(MediaType.APPLICATION_JSON)
                .body(data, MyData.class);
    }

    public Mono<ServerResponse> getDbData(ServerRequest serverRequest) {
        Flux<MyData> data = myReactiveRepository.findAll();
        return ok()
                .contentType(MediaType.APPLICATION_JSON)
                .body(data, MyData.class);
    }

    public Mono<ServerResponse> saveData(ServerRequest serverRequest) {
        int size = Integer.parseInt(serverRequest.queryParam("size").orElse("10"));
        int length = Integer.parseInt(serverRequest.queryParam("length").orElse("100"));

        Flux<MyData> data = WebClient.create().get()
                .uri(builder -> builder
                        .scheme("http")
                        .host("localhost")
                        .port(8080)
                        .path("data")
                        .queryParam("size", size)
                        .queryParam("length", length)
                        .build())
                .accept(MediaType.APPLICATION_JSON)
                .retrieve()
                .bodyToFlux(MyData.class)
                .flatMap(myReactiveRepository::save);

        return ok()
                .contentType(MediaType.APPLICATION_JSON)
                .body(data, MyData.class);
    }

}

这里要说明几点:

  1. 在WebFlux中,我们可以采用传统的@Controller方式来定义Controller,也可以采用函数式方式来声明对外的Endpoint,也就是声明Handler+Router。我们这里采用的是更有特色的后者来演示。
  2. 请你比较一下三个方法的实现对于两个版本的区别。最主要的区别,我们返回的实际数据是Mono<>和Flux<>,分别代表0~1个对象和0~N对象的响应式流。
  3. 在saveData方法中,对于Spring MVC我们使用的是阻塞的RestTemplate来从远端获取数据,对于Spring WebFlux我们使用的是非阻塞的WebClient来获取数据。获取数据后,我们直接使用flatMap获取到了所有的MyData转给我们的响应式的Mongodb Repository来处理数据。
  4. 对于saveData方法中插入Mongodb的操作,这里和MVC的例子有很大的不同需要注意。在MVC中,我们把远程服务返回的结果转为Stream数据流,同步依次调用save方法,整个过程只会有占用一个Mongodb的连接。而在这里,直接对Flux流进行了Map,整个过程相当于并发进行了Mongodb的调用。在之后做压测的时候,我们会再次提到这点。
    刚才有提到,采用函数式声明对外的Endpoint的话除了定义Handler,还需要配置Router来和Handler关联,配置如下:
package me.josephzhu.spring101webflux;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;

import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;

@Configuration
public class RouterConfig {
    @Autowired
    private MyHandler myHandler;

    @Bean
    public RouterFunction<ServerResponse> config() {
        return route(GET("/data"), myHandler::getData)
                .andRoute(GET("/dbData"), myHandler::getDbData)
                .andRoute(GET("/saveData"), myHandler::saveData);
    }
}

这段代码没有太多需要说明,这里我们定义了三个GET请求(相当于MVC的@GetMapping),然后对应到注入的myHandler的三个方法上。
然后我们还需要创建Mongodb的Repository:

package me.josephzhu.spring101webflux;

import org.springframework.data.mongodb.repository.ReactiveMongoRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface MyReactiveRepository extends ReactiveMongoRepository<MyData, String> { }

以及配置和启动类:

package me.josephzhu.spring101webflux;

import com.mongodb.ConnectionString;
import com.mongodb.async.client.MongoClientSettings;
import com.mongodb.connection.ClusterSettings;
import com.mongodb.connection.ConnectionPoolSettings;
import com.mongodb.reactivestreams.client.MongoClient;
import com.mongodb.reactivestreams.client.MongoClients;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@SpringBootApplication
@Configuration
public class Spring101WebfluxApplication {

    @Bean
    MongoClient mongoClient(){
        return MongoClients.create(mongoClientSettings());
    }

    @Bean
    MongoClientSettings mongoClientSettings(){
        return MongoClientSettings.builder()
                .clusterSettings(ClusterSettings.builder().applyConnectionString(new ConnectionString("mongodb://localhost")).build())
                .connectionPoolSettings(ConnectionPoolSettings.builder().minSize(200).maxSize(1000).maxWaitQueueSize(1000000).build())
                .build();
    }

    public static void main(String[] args) {
        SpringApplication.run(Spring101WebfluxApplication.class, args);
    }
}

这里对Mongodb做了一些配置,主要也是希望放大连接池这块的默认限制,为今后的压测服务。注意,在这里配置的Bean是com.mongodb.reactivestream.client下的MongoClient,如下图所示,还有其它两个MongoClient,如果修改了不匹配的MongoClient的话是不会有作用的,我在这个坑里躺了两小时。

完成后可以打开浏览器测试一下接口:

Spring MVC还是WebFlux?

下图是官网的一个图说明了两者的关系,然后官网也给出了一些建议:

  1. 如果你现在的Spring MVC运行的没啥问题的话就别改了,有大量的类库可以使用,实现简单易于理解。
  2. 如果你希望实现轻量级的,函数式Web框架,那么可以考虑WebFlux的函数Web端点。
  3. 如果你依赖阻塞的持久化API比如JPA和JDBC那么也就只能选择Spring MVC了。目前对于非阻塞的JDBC实现有一些早期的项目在探索,但是没有到可以上生产的成熟度。
  4. 在Spring MVC应用程序中进行远程调用也是可以使用响应式的WebClient的。Spring MVC也可以使用其它的响应式组件。每次调用延迟越厉害受益越大。
  5. 对于大型应用程序要考虑到非阻塞方式实现的学习曲线。最简单的起步方式就是使用WebClient,完全切换到非阻塞需要花时间熟悉函数式声明式的编程API。

    官方的意思也是可以在一些小引用上尝试WebFlux,对于大型应用不建议冒然转到WebFlux。

观察线程模型

我们知道对于阻塞的实现方式,我们采用线程池来服务请求(线程池中的会维护一组普通的线程,线程池只是节省线程创建的时间),对于每一个请求的处理,至始至终都是在一个线程中进行,如果处理的过程中我们需要访问外部的网络或数据库,那么线程就处于阻塞状态,这个线程无法服务其它请求,如果当时还有更多的并发的话,就需要创建更多的线程来服务其它请求。这种实现方式是非常简单的,应对压力的增长扩容方式也是粗暴的,那就是增加更多线程。
对于非阻塞的方式,采用的是EventLoop的方式,IO操作的时候是不占用工作线程的,因此只会创建一组和CPU核数相当的工作线程用于工作处理(NodeJS甚至是单线程的,这种就更危险了,就那么一个工作线程,一旦被长时间占用其它请求都无法处理)。由于整个处理过程中IO请求不占用线程时间,线程不会阻塞等待,再增加超过CPU核数的工作线程也是没有意义的(只会白白增加线程切换的开销)。对于这种方式在压力增长后,因为我们不需要增加额外的线程,也就没有了绝对的瓶颈。
试想一下在阻塞模型下,对于5000的并发,而且每一个并发阻塞的时间非常长,那么我们其实需要5000个线程来服务(这么多线程99%其实都是在等待,属于空耗系统资源),创建5000的线程不谈其它的,如果线程栈大小是1M的话就需要5GB的内存。对于非阻塞的线程模型在8核机器上还是8个工作线程,内存占用还是这么小,可以以最小的开销应对大并发,系统的损耗很少。非阻塞的Reactive模式是内耗非常小的模式,但是这是有代价的,在实现上我们需要确保处理过程中没有阻塞产生,否则就会浪费宝贵的数目固定的工作线程,也就是说我们需要依赖配套的非阻塞IO类库来使用。
在默认情况下tomcat的工作线程池初始化为10,最大200,我们通过启动本文创建的Spring101WebfluxMvcApplication程序,用jvisualvm工具来看下初始的情况(35个线程):

在项目的application.properties文件中我们配置tomcat的最大线程数:
server.tomcat.max-threads=250
在压力的情况下,我们再来观察一下线程的情况(272个线程):

的确是创建多达250个工作线程。这里看到大部分线程都在休眠,因为我们这里运行的是刚才的data()方法,在方法内我们休眠了100毫秒。对于同样的压力,我们再来看一下Spring101WebfluxApplication程序的线程情况(44个线程):
可以看到用于处理HTTP的Reactor线程只有8个,和本机CPU核数量一致(下面有十个Thread打头的线程是处理和Mongodb交互的,忽略),只需要这8个线程处理HTTP请求足以,因为HTTP请求的IO处理不会占用线程。

使用Gatling进行压力测试

我们可以使用Gatling类库进行压力测试,我个人感觉比Jmeter方便。配置很简单,首先我们要安装Scala的SDK,然后我们新建一个模块:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>me.josephzhu</groupId>
    <artifactId>spring101-webstresstest</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>spring101-webstresstest</name>
    <description></description>

    <dependencies>
        <dependency>
            <groupId>io.gatling.highcharts</groupId>
            <artifactId>gatling-charts-highcharts</artifactId>
            <version>2.3.1</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>io.gatling</groupId>
                <artifactId>gatling-maven-plugin</artifactId>
                <version>2.2.4</version>
                <configuration>
                    <simulationClass>me.josephzhu.spring101.webstresstest.StressTest</simulationClass>
                    <resultsFolder>/Users/zyhome/gatling</resultsFolder>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

引入了garling的maven插件,在这里配置了测试结果输出路径以及压测的类。接下去创建一下这个Scala测试类:

package me.josephzhu.spring101.webstresstest

import io.gatling.core.Predef._
import io.gatling.core.scenario.Simulation
import io.gatling.http.Predef._

class StressTest extends Simulation {

  val scn = scenario("data").repeat(1000) {
    exec(
      http("data")
        .get("http://localhost:8080/data?size=10&length=1000")
        .header("Content-Type", "application/json")
        .check(status.is(200)).check(substring("payload")))
  }

  setUp(scn.inject(atOnceUsers(200)))
}

这段代码定义了如下的测试行为:

  1. 声明一个data测试场景,重复进行1000次测试,发起一个远程调用,验证调用结果的响应状态码是200并且返回的结果包含字符串payload。
  2. 测试启动的时候直接压上去200个用户,每一个用户运行完这1000次测试后结束了,所以这种方式一开始会是200用户到测试最后阶段用户数会慢慢减少。当然还有其它一些测试方式(比如慢慢递增用户的方式),详见官网:
    nothingFor(4 seconds), // 1
    atOnceUsers(10), // 2
    rampUsers(10) over (5 seconds), // 3
    constantUsersPerSec(20) during (15 seconds), // 4
    constantUsersPerSec(20) during (15 seconds) randomized, // 5
    rampUsersPerSec(10) to 20 during (10 minutes), // 6
    rampUsersPerSec(10) to 20 during (10 minutes) randomized, // 7
    splitUsers(1000) into (rampUsers(10) over (10 seconds)) separatedBy (10 seconds), // 8
    splitUsers(1000) into (rampUsers(10) over (10 seconds)) separatedBy atOnceUsers(30), // 9
    heavisideUsers(1000) over (20 seconds) // 10

压力测试一

先来进行第一个测试,1000并发对data接口进行100次循环(还记得吗,接口有100ms休眠or延迟的):

class StressTest extends Simulation {

  val scn = scenario("data").repeat(100) {
    exec(
      http("mvc data")
        .get("http://localhost:8080/data?size=10&length=1000")
        .header("Content-Type", "application/json")
        .check(status.is(200)).check(substring("payload")))
  }

  setUp(scn.inject(atOnceUsers(1000)))
}

下面两个图分别是MVC和WebFlux的测试结果(因为都是8080端口,所以测试的时候记得切换重启两个应用哦):


可以看到WebFlux的吞吐几乎是MVC的翻倍,平均响应时间少了两倍不止,很明显,在等待的时候,2000个并发用户大大超过了我们配置的250个线程池的线程数量,这个时候只能排队,对于非阻塞的方式,延迟是不会占用处理线程的,在延迟结束后才会去占用处理线程的资源进行处理,不会收到并发用户数受限于线程池线程数的情况。
我们把Sleep相关代码注释再进行一次测试看看情况,分别是MVC和WebFlux:


这个时候WebFlux优势没有那么明显了。

性能测试二

现在我们来访问一下http://localhost:8080/saveData?size=100&length=1000 接口往Mongodb来初始化100条数据,然后修改一下测试脚本压测dbData接口:
class StressTest extends Simulation {

val scn = scenario("data").repeat(100) {
exec(
http("data")
.get("http://localhost:8080/dbData")
.header("Content-Type", "application/json")
.check(status.is(200)).check(substring("payload")))
}

setUp(scn.inject(atOnceUsers(1000)))
}
下面看下这次的测试结果 ,分别是MVC和WebFlux:


吞吐量没有太多提高,平均响应时间快不少。

性能测试三

再来试一下第三个saveData接口的情况。修改测试代码:
class StressTest extends Simulation {

val scn = scenario("data").repeat(100) {
exec(
http("data")
.get("http://localhost:8080/saveData?size=5&length=100000")
.header("Content-Type", "application/json")
.check(status.is(200)).check(substring("payload")))
}

setUp(scn.inject(atOnceUsers(200)))
}
这里我们修改并发用户为200,每个用户进行100次测试,每次测试存入Mongodb 5条100KB的数据,一次测试后总数据量在10万条。这次测试我们并没有使用1000并发用户,原因是这个测试我们会先从远端获取数据然后再存入Mongodb,远端的服务也是来自于当前应用程序,我们的Tomcat最多只有250个线程,在启动1000个用户后,一些线程服务于saveData接口,一些线程服务于data接口(saveData接口用到的),这样相当于造成了循环依赖问题,请求在等待更多的可用线程执行服务data接口的响应,而这个时候线程又都被占了导致无法分配更多的请求,测试几乎全部超时。
下面看下这次的测试结果 ,分别是MVC和WebFlux:


WebFlux也是并发略高,性能略好的优势。对于响应时间的分布我们再来细看下下面的图:


第一个图是MVC版本的响应时间分布,可以看到抖动比第二个图的WebFlux的大不少。
最后来看看测试过程中MVC的JVM情况(263个线程):

以及WebFlux的(41线程):

性能测试四:

我们来测试一下下面两种情况下对于WebFlux版本Mongodb侧的情况:

class StressTest extends Simulation {

  val scn = scenario("data").repeat(1000) {
    exec(
      http("data")
        .get("http://localhost:8080/saveData?size=1&length=1000")
        .header("Content-Type", "application/json")
        .check(status.is(200)).check(substring("payload")))
  }

  setUp(scn.inject(atOnceUsers(200)))
}

以及

class StressTest extends Simulation {

  val scn = scenario("data").repeat(1000) {
    exec(
      http("data")
        .get("http://localhost:8080/saveData?size=5&length=1000")
        .header("Content-Type", "application/json")
        .check(status.is(200)).check(substring("payload")))
  }

  setUp(scn.inject(atOnceUsers(200)))
}

区别就在远程服务返回的Flux是1个还是5个。在1个的时候运行测试可以看到我们Mongodb有64个连接(需要把之前连接池的配置最小设置为小一点,比如50):

> db.serverStatus().connections
{ "current" : 64, "available" : 3212, "totalCreated" : 8899 }

在size为5的时候,Flux返回的是5个对象,使用这个请求压测的时候Mongodb的连接数如下:

> db.serverStatus().connections
{ "current" : 583, "available" : 2693, "totalCreated" : 10226 }

这是因为Flux拿到的数据直接以响应式进入Mongodb,并没有等到所有数据拿到之后串行调用方法。
总结一下这几次的测试,我们发现WebFlux方式对于MVC方式能有略微的性能提升,对于请求阻塞的时候性能优势明显。我本金的测试并没有看到现象中的几倍甚至几十倍的性能提升,我猜原因如下:

  1. 本机有性能瓶颈了,压测客户端、Mongodb服务器、服务端都在本机运行,干扰因素太多,CPU的使用你争我夺,测试不公平
  2. 测试的时候CPU永远是100%还死机好几次,我根本无法测试更高的并发,无法完全把非阻塞的性能压出来
  3. 我本机测试的时候走的是localhost而不是内网,不经过物理网卡,可能无法体现非阻塞的性能
    如果有条件可以使用三台独立服务器在内网进行1万以上并发用户的性能测试或许可以得到更科学的结果。

总结

本文我们创建了WebFlux和MVC两套应用对比演示了简单返回数据、发出远程请求、使用Mongodb等一些简单的应用场景,然后来看了一下ThreadPerRequest和EventLoop方式线程模型的区别,最后使用Gatling进行了几个Case的压力测试并且观察结果。我觉得:

  1. 非阻塞模型肯定是好东西,在IO压力和IO延迟很大的情况下,非阻塞模型因为不需要更多的线程,内耗小,性能略好,而且也稳定,所以更利于高并发
  2. WebFlux的函数式和声明方式实现需要有很高的API熟悉使用门槛,对于复杂的逻辑这种方式的实现比回调地狱更容易绕晕,而且容易产生Bug(或许以后有可能响应式的编程在API上有可能和传统方式进行统一)
  3. 目前和WebFlux配套的其它一些Reactive的库还不是很全面成熟,要对复杂的业务逻辑全面启用响应式编程有点难,阻塞调用不是不能在WebFlux中混用,但是这种方式还是采用了线程池来处理,现在容器也是NIO的了,有又多大区别
  4. 采用阻塞方式实现,由阻塞的线程进行天然背压进行流控,非阻塞方式很直接一竿子到底,从外部请求直接到最底层存储,需要做好流控,这是非常容易产生问题的一个点,当请求的处理无需通过线程来承载的时候,前端压力会直通最底层数据源,不收任何扩容方面的限制,直接击溃底层
  5. 对于阻塞的方式,多线程的调度天然就是一个任务的负载均衡,并不会出现太严重的卡死工作线程的问题,非阻塞应用编程我们要有意识代码在哪个线程上运行,如果是reactor线程的话千万不能长时间阻塞
    综上所述,使用WebFlux进行响应式编程我个人认为目前只适合做类IO转发的高并发的又看中资源使用效率的应用场景(比如Gateway网关服务),对于复杂的业务逻辑不太适合,在90%的情况下响应式的编程模型和线程模型不会享受大幅性能优势,更不建议盲目把现有的应用使用WebFlux来重写。当然,这肯定是一个会持续发展的方向,可以先接触研究起来。
posted @ 2018-10-05 13:41 lovecindywang 阅读(...) 评论(...) 编辑 收藏