TL;DR
-
WebClient
の初回リクエストはデフォルトだと1秒以上かかることが有る- 原因は恐らく内部内の初期化コスト関係
-
warmup
することでこの問題はある程度回避できる- 確実なのは1度通信しておくこと
-
HttpClient
のwarmup
呼び出しなど、その他の手段が有る場合も
状況
Spring WebFlux
で実装したAPI
が、デプロイ直後の初回だけ20秒程実行時間がかかるという状況がありました。
調査の結果、原因の一部1はWebClient
による初回通信の遅さだということが分かりました(当該API
は最大5つ程のWebClient
を呼び出します)。
ローカルでの検証について
ローカルでWebClient
を利用したAPI
通信を連続実行して確認したところ、初回リクエストは1~2秒程度、2回目以降は0.5秒以下で通信できていることが分かりました。
つまり、単純に考えると、初回だけは秒単位で余分にコストがかかっているということになります。
これはローカルでの検証であって必ずしも実環境の状況を反映しているとは言えませんが、ローカルでも秒単位の問題が出ている以上軽微とも言えません。
また、少し古い & 環境が違いますが、より正確と思われるNetty
2側の検証でも、初回リクエスト時に時間がかかることが示されています。
対処法
基本的には事前にwarmup
しておくことが対策になります。
1度通信しておく
追記: lazy-init
を有効化した場合下記のサンプルコードでエラーが発生する問題について
lazy-init
が有効な場合、以下のサンプルコードを実行すると、リクエストに対する処理内でblock()
が呼び出された結果エラーが生じることが分かりました。
そもそもlazy-init
が有効であれば、リクエストを待たせてまでwarmup
する意味は無いため、この処理をスキップすることをお勧めします。
block
をsubscribe
に書き換える形でも良さそうですが、検証はできていません。
一番確実なのは一度通信しておくことです。
自分は以下のようにWebClient
のBean
初期化時に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
はデフォルトだと通信にNetty
のHttpClient
を使います。
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度通信しておくのに比べると効果は小さいようです。
また、別のクライアントを用いている場合にも同様のオプションが提供されている可能性はあります。
おまけ: WebClient
のwarmup
オプションを待つ
年単位で放置されていますが、WebClient
にもwarmup
オプションを追加するissue
が存在しています。
上記のような少しハッキーな方法を避けたい方は是非してみて下さい。