使用Spring Webflux中的WebClient在阻塞应用程序设计中是否比RestTemplate消耗更多资源?

25

我正在处理几个使用传统的“请求线程模式”的spring-boot应用程序。我们使用Spring-boot-webflux来获取WebClient,以在应用程序之间执行RESTful集成。因此,我们的应用程序设计要求在接收到响应后立即阻塞发布者。

最近,我们一直在讨论是否在我们原本的阻塞应用程序设计中不必要地使用了反应模块。据我理解,WebClient通过为事件循环分配工作线程来执行反应式操作。因此,使用带有.block()的webclient会让原始线程休眠,同时指定另一个线程执行http请求。与替代方案RestTemplate相比,似乎WebClient会通过使用事件循环花费额外的资源。

部分引入spring-webflux这种方式是否正确地导致了额外的资源开销,而没有对性能产生任何积极的贡献,无论是单线程还是并发?我们不希望将当前堆栈升级为完全反应式,因此逐步升级的观点不适用。

3个回答

16

这个演示中,Spring团队的Rossen Stoyanchev解释了一些要点。

WebClient将使用有限数量的线程-每核心2个线程,在我的本地机器上总共为12个线程来处理应用程序中所有请求及其响应。所以如果你的应用程序接收了100个请求并针对每个请求向外部服务器发起一次请求,WebClient将使用这些线程以非阻塞/异步方式处理所有这些请求和响应。

当然,正如你提到的那样,一旦调用block,您的原始线程将被阻塞,因此处理这些请求需要112个线程(100个请求线程+ 12个WebClient线程)。但请记住:这12个线程不会随着您发出更多请求而增长,并且它们不会执行I/O重任务。 因此,WebClient不会生成线程来实际执行请求,也不会像逐个请求一样保持它们忙碌。

我不确定当线程正在block时,它是否与通过RestTemplate进行阻塞调用时相同-在前者中,线程应该处于非活动状态等待NIO调用完成,而在后者中,线程应该处理I/O工作,因此可能存在差异。

如果您开始使用reactor,例如处理彼此依赖的请求或并行处理多个请求,则变得有趣。然后,WebClient肯定会占据优势,因为它将使用相同的12个线程执行所有并发操作,而不是使用每个请求一个线程。

假设这是一个应用程序示例:

@SpringBootApplication
public class SO72300024 {

    private static final Logger logger = LoggerFactory.getLogger(SO72300024.class);

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

    @RestController
    @RequestMapping("/blocking")
    static class BlockingController {

        @GetMapping("/{id}")
        String blockingEndpoint(@PathVariable String id) throws Exception {
            logger.info("Got request for {}", id);
            Thread.sleep(1000);
            return "This is the response for " + id;
        }

        @GetMapping("/{id}/nested")
        String nestedBlockingEndpoint(@PathVariable String id) throws Exception {
            logger.info("Got nested request for {}", id);
            Thread.sleep(1000);
            return "This is the nested response for " + id;
        }

    }

    @Bean
    ApplicationRunner run() {
        return args -> {
            Flux.just(callApi(), callApi(), callApi())
                    .flatMap(responseMono -> responseMono)
                    .collectList()
                    .block()
                    .stream()
                    .flatMap(Collection::stream)
                    .forEach(logger::info);
            logger.info("Finished");
        };
    }

    private Mono<List<String>> callApi() {
        WebClient webClient = WebClient.create("http://localhost:8080");
        logger.info("Starting");
        return Flux.range(1, 10).flatMap(i ->
                        webClient
                                .get().uri("/blocking/{id}", i)
                                .retrieve()
                                .bodyToMono(String.class)
                                .doOnNext(resp -> logger.info("Received response {} - {}", I, resp))
                                .flatMap(resp -> webClient.get().uri("/blocking/{id}/nested", i)
                                        .retrieve()
                                        .bodyToMono(String.class)
                                        .doOnNext(nestedResp -> logger.info("Received nested response {} - {}", I, nestedResp))))
                .collectList();
    }
}

如果你运行此应用程序,你会发现所有的30个请求都立即由同一台计算机上的12个线程并发处理。很棒!如果您认为这种并行处理方式对您的逻辑有益,那么尝试使用WebClient可能是值得的。

如果不需要这种并行处理,虽然我不认为有额外的资源开销可以担忧,但考虑到以上原因,我认为为此添加整个reactor/webflux依赖项不值得——除了额外的负担,在日常操作中使用RestTemplate基于请求的线程模型更容易进行推理和调试。

当然,正如其他人所提到的,您应该运行负载测试以获得正确的指标。


1
非常好的答案,谢谢!我们目前正在运行WebClient,因此需要考虑是否转向RestTemplate或者jdk的HttpClient以减少线程消耗并放弃对webflux的需求。阅读您的回答,我相信更改该工具的成本超过了性能的提高。在执行并发操作时获得优势的添加是一个不错的建议,我会在进一步开发中记住这一点。 - ODDminus1
不确定这是正确的比较。响应式API非常灵活,允许您控制执行流程。默认情况下,flatMap并行运行Queues.SMALL_BUFFER_SIZE,但您可以控制并发性或使用其他运算符,如按顺序处理数据的concatMap。您还可以控制调度程序(线程池运行)。无论如何,WebClient的优势不在于响应式API,而在于NiO客户端(默认为Netty),其中所有IO操作都是异步和非阻塞的。您可以找到许多Netty基准测试,证明它的性能。 - Alex
嗨,Alex,感谢您的评论。虽然您提出了很好的观点,但我不确定为什么这会使比较不正确。如果您对处理并行性、线程池等感兴趣,那么您提到的内容很好 - 否则,除了我们已经涵盖的内容之外,我真的看不到阻塞应用程序的整体好处。能否详细说明一下?谢谢! - Tomaz Fernandes
@Tomaz Fernandes 我更新了我的答案,提供了更多细节。 - Alex

5
根据官方Spring文档中关于RestTemplate的说明,它处于维护模式,可能在未来版本中不会得到支持。
引用如下:
自5.0版本起,该类处于维护模式,只接受少量更改请求和错误报告。请考虑使用org.springframework.web.reactive.client.WebClient,它具有更现代的API并支持同步、异步和流场景。
至于系统资源,真正取决于您的用例,我建议进行一些性能测试,但似乎对于低负载情况下,使用阻塞客户端可能拥有更好的性能,因为每个连接都有一个专用线程。随着负载的增加,NIO客户端 tend to perform better。
更新-响应式API vs Http Client
重要的是要理解Reactive API(Project Reactor)和http client之间的区别。尽管WebClient使用Reactive API,但它不会添加任何额外的并发,除非我们显式使用像flatMapdelay这样的操作符,才能在不同的线程池上调度执行。如果我们只是使用
webClient
  .get()
  .uri("<endpoint>")
  .retrieve()
  .bodyToMono(String.class)
  .block()

这段代码将在调用方线程上执行,与阻塞客户端相同。

如果我们为此代码启用调试日志,我们会发现WebClient代码将在调用方线程上执行,但是对于网络操作的执行将切换到reactor-http-nio-...线程上。

主要区别在于,在内部,WebClient使用基于非阻塞IO(NIO)的异步客户端。这些客户端使用Reactor模式(事件循环)来维护一个单独的线程池,从而允许您处理大量并发连接。

I/O反应器的目的是响应I/O事件并将事件通知分派给各个I/O会话。 I/O反应器模式的主要思想是摆脱受经典阻塞I/O模型强加的每个连接一个线程的模型。

默认情况下,使用Reactor Netty,但是您可以考虑Jetty Rective Http Client、Apache HttpComponents(async)甚至是AWS Common Runtime(CRT)Http Client(如果创建所需适配器,则不确定是否已存在)。

总体而言,您可以看到整个行业趋势是使用异步I/O(NIO),因为它们对于高负载的应用程序更加资源高效。

此外,为了有效地处理资源,整个流程必须是异步的。通过使用block(),我们会隐式地重新引入每个连接一个线程的方法,这将消除大部分NIO的好处。同时,使用WebClientblock()可以被视为迈向完全反应式应用程序的第一步。


谢谢您的回答!我们知道RestTemplate已经进入了维护模式。然而,这是在它被标记为弃用之后设置的状态。撤销该状态对我来说意味着RestTemplate可能不会很快消失。进行一些性能测试是一个非常好的主意,在做出任何更改之前我应该仔细研究一下。 - ODDminus1
1
谢谢更新,Alex。据我所知,flatMap 在被调用的线程上运行,除非您使用 publishOnsubscribeOn 操作符。这是 并发不可知 的项目 Reactor 遵守的一部分。因此,在 retrieve() 调用之后的所有内容都应该在 NIO 线程上运行,直到 block() 将其合并回 main 线程 - 当然,除非使用其他 NIO 操作,在这种情况下,回调可能由另一个 NIO 线程处理。 - Tomaz Fernandes
你说得很对,publishOnsubscribeOn可以用来控制调度器,但这里的重点是WebClient在使用特定操作符(如flatMap)允许并行运行多个流之前不会引入新线程(除了NIO线程池)。 - Alex
关于您此处的声明@Alex:“如果我们为此代码启用调试日志记录,我们将看到WebClient代码在调用线程上执行,但对于网络操作,执行将切换到reactor-http-nio-...线程。”,您的示例使用了. block()。 如果我还使用WebClient ExchangeFilterFunction拦截请求,过滤函数中的代码也会在主线程上运行,还是会在不同的线程上运行? - Michael
请求过滤器将在调用线程上执行,响应过滤器将在HTTP客户端线程reactor-http-nio-..上执行。 - Alex

1

好问题。

上周,我们考虑从resttemplate迁移到webclient。本周,我开始测试阻塞式webclient和resttemplate之间的性能,令我惊讶的是,在响应负载较大的情况下,resttemplate表现得更好。差别相当大,resttemplate的响应时间不到一半,并且使用的资源更少。

我仍在进行性能测试,现在我开始为请求测试更广泛的用户。

该应用程序是mvc并且使用spring 5.13.19和spring boot 2.6.7。

对于性能测试,我正在使用jmeter,对于健康检查则是visualvm/jconsole。


谢谢你的回答!真的很有趣。根据实现方式,使用事件循环而不是同步执行操作,因此 Web 客户端的处理时间更长是有道理的。但我并不认为性能差异会受到有效负载大小的影响。我很想看到你进一步测试的结果。 - ODDminus1

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接