131
128

Spring Boot 爆速パフォーマンスアップ

Last updated at Posted at 2020-03-07

タイトルがあれですみません。同時実行性能を求められる要件があり、Web MVC と WebFlux のパフォーマンスを比較しました。WebFlux は Web MVC と同じように Vue.js や Thymeleaf などを使用した画面や、REST API で使用でき、Reactor の継続渡しスタイル (CPS) と関数型プログラミングにより、非同期でノンブロッキングな処理を実現し、少ないスレッドでの並行処理と少ないハードウェアリソースでスケールが可能な Web スタックです。

2024/06/06 追記
Java 21 以降は仮想スレッドも検討してください。

関連記事
Java仮想スレッドではsynchronizedの代わりにReentrantLockで同期化する
https://qiita.com/cypher256/items/e7be58ebad6f745a2e21

結果

計測内容: 1 万リクエストし、全レスポンスが完了するまでの時間
結果評価: サーバ処理時間によらず、WebFlux のほうが 2 倍くらい速い
(注意: 単発性能ではない。単発ではわずかに遅くなる可能性もある。)
result.png

サーバ側 1 回の処理時間に 50 ミリ秒かかる場合で、WebFlux 1 万リクエスト全正常レスポンス 1.7秒。Web MVC は 2.9 秒でデフォルトの 200 スレッドで 1 万リクエストを処理する理論上の最速値は 2.5 秒のため、妥当かと思います。サーバ側の 1 回の処理時間を 1 秒に設定し、1 万リクエストすると Web MVC はエラーとなり処理できませんでしたが、WebFlux は 20 秒で完了しました。

環境と条件

  • 2 年ぐらい前の MacBook Pro
  • 念の為、サーバとクライアントは別 VM で起動 (同じ VM でも結果は変わらんかった)
  • クライアントは Spring WebClient で localhost に http アクセス
  • Gatling や JMeter より WebClient のほうが多くの処理が可能
  • 設定は Spring Boot デフォルト (なんも指定なし、バージョンは build.gradle 参照)
  • サーバは Netty だと同時 700 リクエストあたりからエラーが発生するため Tomcat

どちらを選ぶ?

以下のような要件や理由がある場合は WebFlux がおすすめ

  • 同時実行性能が高くないと困る
  • クラウドの費用をとにかく安くしたい (PG コスト高いかも)
  • リアクティブプログラミングをやっときたい
  • for 文禁止 Stream 推進派の人 (ちゃう?)

WebFlux が 2017 年に登場した当初は JDBC が処理をブロックするため、様子見の方も多かったと思いますが、現在ではノンブロッキング DB ドライバの選択肢が増え(後述)、Line やクックパッドなどで採用されていて実績面でも問題ありません。ただ、比較的非同期処理が扱いやすいスレッドとは別に、ノンブロッキングによる非同期処理、継続渡しスタイル、バックプレッシャーなどのメカニズムを理解しておく必要があり、一般的には開発工数が増え、保守性が低いなどの短所もあります。いずれにしても、今のところ、Spring Boot を利用する開発者としては、どちらでも対応できるようにしておいたほうが良いかもしれません。

Spring WebFlux 概要
https://spring.pleiades.io/spring/docs/current/spring-framework-reference/web-reactive.html#header-spring

将来、Project Loom で仮想スレッドを意識せずにスレッドとして扱えるようになれば、Spring Boot のデフォルト最大スレッド数が 20 万とかになって、そもそもノンブロッキングとか継続とか考えなくても、Spring MVC のような普通の簡潔なコードで、リソース消費量も少なく処理できるようになるかもしれません。

2024/06/06 追記
仮想スレッドのスレッド数は無制限です。

ノンブロッキングで書く必要がある

つい、やってしまいそうなのが、ブロッキングなコードを書いてしまうことです。計測コードでも、最初普通に sleep 書いてしまって、あれ? ってなりました。以下のようなノンブロッキング対応 API を使用する必要があります。Java の場合、サーバ側の node.js のように、ひどい結果になることはありませんが、WebFlux を使っている意味が薄れてしまいます。

データベース I/O

普通の JDBC ドライバは SQL 結果待ちでブロックしてしまいます。かと言って DB アクセスを別スレッドで非同期実行してしまうと、結局スレッドリソース問題が発生してしまいます。そこでノンブロッキング対応で標準化された R2DBC ドライバが各 DB ベンダーにより用意されています。Spring Data シリーズ共通のリポジトリによる SQL 自動生成もありますが、DatabaseClient クラスでは直接 SQL 書けて、結果を流れるように処理でき、JdbcTemplate よりも大幅に洗練されています。

Spring Data R2DBC ドライバー (SQL Server、MySQL、Postgres、H2)
https://spring.pleiades.io/spring-data/r2dbc/docs/current/reference/html/#r2dbc.drivers

コード例
@Autowired DatabaseClient db;

// INSERT
db.insert().into(Person.class).using(person).then();
db.execute("INSERT INTO person (id, name, age) VALUES(:id, :name, :age)")
    .bind("id", "joe")
    .bind("name", "Joe")
    .bind("age", 34).then();

// UPDATE
db.update().table(Person.class).using(person).then();
db.execute("UPDATE person SET name = 'Joe'").then();

// SELECT
String sql = "SELECT id, name FROM person WHERE 〜";
Flux<Person> all = db.execute(sql).as(Person.class).fetch().all();
Mono<Person> all = db.execute(sql).as(Person.class).fetch().one();
〜 R2DBC は Web MVC でも JDBC より同時実行性能が高い 〜

以下の検証では、普通の Web MVC でも JDBC より R2DBC のほうがスループットが高くなっています (私は MVC のプロジェクトでも R2DBC を使用しています)。

応答時間と同様に、JDBCを使用したSpring Web MVCは、同時実行数が多くなるほどパフォーマンスが低下します。ここでもR2DBCの方が明らかに良い結果を出しています。
r2dbc_chart2.png
https://technology.amis.nl/2020/04/10/spring-blocking-vs-non-blocking-r2dbc-vs-jdbc-and-webflux-vs-web-mvc/

HTTP ネットワーク I/O

WebFlux でなくても、RestTemplate ではなく WebClient を使いましょう。 WebFlux の場合は WebClient、Spring MVC の場合は RestClient (下記追記参照) をお勧めします。本稿の計測は WebClient を使用しています。

2023/11/02 追記
Spring Boot 3.2 (Spring 6.1) で RestClient が追加されます。流れるような API を備えた同期クライアントです。WebClient はリアクティブを使用しなくても WebFlux の依存関係追加が必要で使いにくい面があったのですが、RestClient は仮想スレッドと組み合わせることを念頭に、今後はメインの HTTP クライアントになりそうです。

Spring WebClient の使い方
https://spring.pleiades.io/spring/docs/current/spring-framework-reference/web-reactive.html#webflux-client

コード例
Mono<Person> result = WebClient
    .create("https://example.org")
    .get()
    .uri("/persons/{id}", id).accept(MediaType.APPLICATION_JSON)
    .retrieve()
    .bodyToMono(Person.class);

計測コード

対象となるコントローラークラス

テストを除く実装は、この 1 クラスのみで、application.properties/yml は空です(デフォルト値はSpring Boot 共通アプリケーションプロパティ一覧を参照)。戻り値が Mono か Flux だと WebFlux になり、それ以外だと Web MVC になります。引数の wait は DB アクセスなどでかかる時間を想定した待機時間です。

package demo;

import java.time.Duration;
import java.util.concurrent.TimeUnit;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import reactor.core.publisher.Mono;

@SpringBootApplication
@RestController
public class PerformanceController {

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

	@GetMapping("/webmvc")
	String webmvc(long wait) throws InterruptedException {
		TimeUnit.MILLISECONDS.sleep(wait);
		return "Hello World";
	}

	@GetMapping("/webflux")
	Mono<String> webflux(long wait) {
		return Mono.just("Hello World").delayElement(Duration.ofMillis(wait));
	}
}

テストコード

WebClient で http://localhost にノンブロッキングで一気に 1 万アクセスします。最初、ループ部分は、せっかくなので Flux.range と全終了待ち block で書いていたのですが、CountDownLatch のほうが少しだけ速かったので、for ループにしました。なんか CountDownLatch 使うと負けた感が。

package demo;

import static org.assertj.core.api.Assertions.*;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

import org.assertj.core.api.AbstractStringAssert;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClient.RequestHeadersSpec;

import com.google.common.base.Stopwatch;

// 計測用にサーバと別 VM 起動するためコメントアウト (有効にするとサーバも起動してくれる)
// @SpringBootTest(webEnvironment = WebEnvironment.DEFINED_PORT)
class PerformanceControllerTests {

	@Test
	void test() throws InterruptedException {

		proc("webmvc" , 0, 50, 100, 200, 500);
		proc("webflux", 0, 50, 100, 200, 500, 1000);
	}

	void proc(String type, int... waitList) throws InterruptedException {

		final int COUNT = 10000;
		for (int serverWait : waitList) {

			String path = "/" + type + "?wait=" + serverWait;
			call(path, COUNT);
			Stopwatch watch = Stopwatch.createStarted();
			call(path, COUNT);
			if (serverWait == 0) continue;

			System.out.printf("処理時間 %-7s サーバ %4d ms/回, クライアント %8s/%d回\n",
					type, serverWait, watch, COUNT);
		}
	}

	void call(String path, int loopCount) throws InterruptedException {

		WebClient web = WebClient.create("http://localhost:8080");
		RequestHeadersSpec<?> request = web.get().uri(path);
		AbstractStringAssert<?> assertResponse = assertThat("Hello World");
		CountDownLatch latch = new CountDownLatch(loopCount);

		for (int i = 0; i < loopCount; i++) {
			request
				.retrieve()
				.bodyToMono(String.class)
				.doOnError(Assertions::fail)
				.doOnTerminate(latch::countDown)
				.subscribe(assertResponse::isEqualTo);
		}
		latch.await(1, TimeUnit.MINUTES);
	}
}

build.gradle

Spring MVC との違いは spring-boot-starter-web を spring-boot-starter-webflux に変更するだけです。WebFlux で標準の Netty ではなく Tomcat を使用する場合や、MVC と両方使用する場合は、両方記述します。ライブラリのバージョンは、Spring Boot 依存関係管理に含まれているものは基本的にバージョン指定しないようにしましょう。buildscript を書いているのは、Spring Boot のバージョン指定でワイルドカード指定するためです。マイクロバージョンは必ず上げる人はおすすめ。

buildscript {
	repositories {
		mavenCentral()
	}
	dependencies {
		classpath("org.springframework.boot:spring-boot-gradle-plugin:2.2.+")
	}
}
apply plugin: 'org.springframework.boot' // バージョンは上記でワイルドカード指定
apply plugin: 'io.spring.dependency-management'
apply plugin: 'java'

sourceCompatibility = '11'

repositories {
	mavenCentral()
}
test {
	useJUnitPlatform()
}
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web' // Tomcat を使う
	implementation 'org.springframework.boot:spring-boot-starter-webflux'
	implementation 'org.springdoc:springdoc-openapi-ui:1.2.+' // Swagger (下記スクショ)
	testImplementation('org.springframework.boot:spring-boot-starter-test') {
		exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
	}
	testImplementation 'io.projectreactor:reactor-test'
	testImplementation 'org.assertj:assertj-core'
	testImplementation 'com.google.guava:guava:28.+'
}

Swagger

計測に関係ないですが、build.gradle に springdoc-openapi-ui を追加するだけで、Java ソースコードを解析して API 仕様が表示され、動作確認もできます。以前はよく Spring Fox を使っていたのですが、springdoc-openapi はアノテーションや Java Config を書いたりする必要はなく、WebFlux にも対応しています。また、本番環境用にプロパティで OFF にできます。
http://localhost:8080/swagger-ui.html
swagger.png

131
128
0

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
131
128