LoginSignup
1

【Spring WebFlux】WebClientの初回リクエストが遅い問題と対処法

Last updated at Posted at 2023-01-06

TL;DR

  • WebClientの初回リクエストはデフォルトだと1秒以上かかることが有る
    • 原因は恐らく内部内の初期化コスト関係
  • warmupすることでこの問題はある程度回避できる
    • 確実なのは1度通信しておくこと
    • HttpClientwarmup呼び出しなど、その他の手段が有る場合も

状況

Spring WebFluxで実装したAPIが、デプロイ直後の初回だけ20秒程実行時間がかかるという状況がありました。
調査の結果、原因の一部1WebClientによる初回通信の遅さだということが分かりました(当該APIは最大5つ程のWebClientを呼び出します)。

ローカルでの検証について

ローカルでWebClientを利用したAPI通信を連続実行して確認したところ、初回リクエストは1~2秒程度、2回目以降は0.5秒以下で通信できていることが分かりました。
つまり、単純に考えると、初回だけは秒単位で余分にコストがかかっているということになります。

これはローカルでの検証であって必ずしも実環境の状況を反映しているとは言えませんが、ローカルでも秒単位の問題が出ている以上軽微とも言えません。
また、少し古い & 環境が違いますが、より正確と思われるNetty2側の検証でも、初回リクエスト時に時間がかかることが示されています。

対処法

基本的には事前にwarmupしておくことが対策になります。

1度通信しておく

追記: lazy-initを有効化した場合下記のサンプルコードでエラーが発生する問題について
lazy-initが有効な場合、以下のサンプルコードを実行すると、リクエストに対する処理内でblock()が呼び出された結果エラーが生じることが分かりました。
そもそもlazy-initが有効であれば、リクエストを待たせてまでwarmupする意味は無いため、この処理をスキップすることをお勧めします。
blocksubscribeに書き換える形でも良さそうですが、検証はできていません。


一番確実なのは一度通信しておくことです。

自分は以下のようにWebClientBean初期化時にbase-path向けに1度通信を打つようにしました。
効果としては、先程と同じローカルの環境で初回リクエストの実行時間が0.6秒以下にまで短縮し、体感的には2回目以降とそう変わらない速度になっていました。
コメントの通りこの通信はエラーになっても構いません。

import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.http.HttpMethod
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono

@Bean("foo-client")
fun fooClient(
    @Value("\${base-path-to-foo}") basePath: String,
    builder: WebClient.Builder,
): WebClient = builder.baseUrl(basePath).build().apply {
    // warmup用に通信
    this.method(HttpMethod.TRACE).retrieve().toBodilessEntity() // traceなのは何か他の問題が起きないようにするため
        .onErrorResume { Mono.empty<Nothing>() } // エラー前提 & 最悪リクエスト時に通信できれば良いためエラーは握り潰す
        .block()
}

補足: 通信先について

自分は検証していませんが、内部的にプールされたコネクションが使いまわされる可能性が有ることを考えると、初期化時の通信はそのWebClientがよく通信する先を指定するのが良いと考えられます。

補足: コネクションのタイムアウトについて

コネクションは放っておくとタイムアウトするため、長い間通信されないような時間が有る場合、何もしない場合の初回リクエスト同様のコストがかかる可能性も有ります。
この辺りは今の所調査・検証共にできていません。

HttpClientのwarmupを用いる

脚注でも触れましたが、WebClientはデフォルトだと通信にNettyHttpClientを使います。
HttpClientにはwarmup関数が有るため、これを呼び出しておくことでも改善する可能性が有ります。

先ほどのBean定義に書き加えたサンプルは以下の通りです。

import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.http.HttpMethod
import org.springframework.http.client.reactive.ReactorClientHttpConnector
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono
import reactor.netty.http.client.HttpClient

@Bean("foo-client")
fun fooClient(
    @Value("\${base-path-to-foo}") basePath: String,
    builder: WebClient.Builder,
): WebClient {
    val clientConnector = HttpClient.create()
        .compress(true) // この指定はReactorClientHttpConnectorでのデフォルトから
        .apply { warmup().block() } // warmup
        .let { ReactorClientHttpConnector(it) }

    return builder.baseUrl(basePath).clientConnector(clientConnector).build().apply {
        // warmup用に通信
        this.method(HttpMethod.TRACE).retrieve().toBodilessEntity() // traceなのは何か他の問題が起きないようにするため
            .onErrorResume { Mono.empty<Nothing>() } // エラー前提 & 最悪リクエスト時に通信できれば良いためエラーは握り潰す
            .block()
    }
}

これについて個別に検証した限り、少し改善されていそうな雰囲気は有ったものの、それ程大きな改善は感じませんでした。
当然かもしれませんが、少なくとも1度通信しておくのに比べると効果は小さいようです。

また、別のクライアントを用いている場合にも同様のオプションが提供されている可能性はあります。

おまけ: WebClientwarmupオプションを待つ

年単位で放置されていますが、WebClientにもwarmupオプションを追加するissueが存在しています。
上記のような少しハッキーな方法を避けたい方は是非:thumbsup:してみて下さい。

  1. 細かいですが、諸々調査した結果を見る限り、これが問題の原因の全てというわけではなさそうでした。

  2. WebClientはデフォルトだと通信にNettyを使います。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1