SpringBoot2のBlocking Web vs Reactive WebについてLTしてきた

社内ミートアップでLTの時間をもらったので、ここに書いた内容を3分で喋ろうとしたんだけど、どうしても3分をちょっと超えてしまうので無理を言って4分もらって喋ってきた(運営のみなさんありがとうございます)。ぐったり。明日から普通のエンジニアに戻ります。

dev.to

実感を大切にしてみた

こういうことに気をつけてLTしてみた:

  • ブログの記事に書いてるコードを見せるんじゃなくて、実際のIDE内のソースコードを見せること
  • そのときにIDEAのプレゼンテーションモードで見せること
  • 実際にアプリを動かした状態でcurlで叩いて動いてるのを見せること
  • Gatlingを実際にgradleで実行すること
  • そこでBlockingAppのスレッドが増えるところをVisualVMで実際に見せること

その方が聞いてて「へー。本当に動くんだ」とか「さわってみようかな」とか思ってもらえるかなーと思って。ってことで以下は内容の日本語訳。全部喋ると全然時間が足りないので、実際はこの中の要点だけをpick upして喋った。

バズワードをポッケに

リアクティブなウェブアプリケーションで嬉しいことのひとつは、たくさんのリクエストを少ないスレッドで効率良くさばくことができるってこと。なので、それを実際に自分の目で確認してみようと思う。

f:id:bufferings:20180327215354p:plain

SpringBoot2 Reactive Web

SpringBoot2が今月頭についにリリースされた。その中の大きな新機能がReactive Web対応(Spring5が対応して、それをSpringBoot2でもサポートしたという感じかな)。これまでの、サーブレットAPIベースでブロッキング処理のServlet Stack(下図の右半分)はもちろんそのまま使えるんだけど、それとは別に、リアクティブストリームベースのReactive Stack(図の左半分)というものが追加された。

ので、従来のSpring MVCを使ったアプリと、新しいWebFluxを使ったアプリのパフォーマンスを比べてみる。

f:id:bufferings:20180327220211p:plain (Image from https://spring.io/ )

デモアプリの構成

SpringBoot1とSpringBoot2の比較をしためっちゃ良い記事があったので、これをほぼそのまま参考にしてSpring MVCとSpring WebFluxの比較をすることにした。

dzone.com

こんな感じの構成:

f:id:bufferings:20180327220957p:plain

3つのアプリがある:

  • delay-service
  • blocking-app
  • reactive-app

delay-serviceは外部APIみたいな役割をしてて、300ms待ってからレスポンスを返す。それをblocking-appとreactive-appがそれぞれ呼び出してて、blocking-appはブロッキングなやり方で、reactive-appはリアクティブなやり方で処理してそれぞれレスポンスを返す。このblocking-appとreactive-appのパフォーマンスを比較する。

ソースコードはここに置いといた:

github.com

delay-service

さっきは「delay-serviceは300ms待ってからレスポンスを返す」って書いたけど、実際はパスパラメータで指定した時間だけ待ってからレスポンスを返すようにしてる:

  @GetMapping("/{delayMillis}")
  public Mono<String> get(@PathVariable int delayMillis) {
    return Mono.just("OK")
        .delayElement(Duration.ofMillis(delayMillis));
  }

(最初はブロッキングなやり方で書いてたんだけど、そうするとこいつがパフォーマンスのボトルネックになってしまったので、リアクティブなやり方に変えた)

blocking-app

blocking-appは普通のSpring MVCアプリ。delay-serviceをRestTemplateで呼び出して文字列を返してるだけ:

  private static final String DELAY_SERVICE_URL = "http://localhost:8080";

  private final RestTemplate client;

  public BlockingApp(RestTemplateBuilder builder) {
    client = builder.rootUri(DELAY_SERVICE_URL).build();
  }

  @GetMapping("/{delayMillis}")
  public String get(@PathVariable String delayMillis) {
    String result = client.getForObject("/" + delayMillis, String.class);
    return "Blocking:" + result;
  }

reactive-app

reactive-appはリアクティブになってるんだけど、ほんとにこれまでのアプリを書くのと同じような感じで書ける。delay-serviceをWebClientを使って呼び出して Mono でラッピングした文字列を返すだけ:

  private static final String DELAY_SERVICE_URL = "http://localhost:8080";

  private final WebClient client = WebClient.create(DELAY_SERVICE_URL);

  @GetMapping("/{delayMillis}")
  public Mono<String> get(@PathVariable String delayMillis) {
    return client.get()
        .uri("/" + delayMillis)
        .retrieve()
        .bodyToMono(String.class)
        .map(s -> "Reactive:" + s);
  }

てことで、パフォーマンスをチェックしよう

delay-serviceを起動:

./gradlew -p apps/delay-service clean bootRun

curl -w "\n%{time_total}s\n" localhost:8080/1000
# returns "OK" after 1000ms

curl -w "\n%{time_total}s\n" localhost:8080/2000
# returns "OK" after 2000ms

blocking-appを起動:

./gradlew -p apps/blocking-app clean bootRun

curl -w "\n%{time_total}s\n" localhost:8081/2000
# returns "Blocking:OK" after 2000ms

reactive-appを起動:

./gradlew -p apps/reactive-app clean bootRun

curl -w "\n%{time_total}s\n" localhost:8082/2000
# returns "Reactive:OK" after 2000ms

これで3つとも起動したぞー。

負荷テストのシナリオ

Gatling(https://gatling.io/)を使う。シナリオは「1,2秒の間隔をあけてAPIを30回呼び出すのを1000人のユーザーが実施する」という感じ。初Scala

  val myScenario = scenario("Webflux Demo").exec(
    repeat(30) {
      exec(
        http("request_1").get(targetUrl)
      ).pause(1 second, 2 seconds)
    }
  )
  setUp(myScenario.inject(rampUsers(simUsers).over(30 seconds)))

blocking-appに対してGatlingを実行

./gradlew -p apps/load-test -DTARGET_URL=http://localhost:8081/300 \
    -DSIM_USERS=1000 gatlingRun

VisualVMで見てみると、スレッド数が200まで増えてることが分かる。この200はTomcatのmaxThreadのデフォルト値。

f:id:bufferings:20180327225950p:plain

reactive-appに対してGatlingを実行

./gradlew -p apps/load-test -DTARGET_URL=http://localhost:8082/300 \
    -DSIM_USERS=1000 gatlingRun

こっちはリクエストをさばくのに4スレッドしか使ってないからずっと一定:

f:id:bufferings:20180327230100p:plain

負荷テストの結果

下に結果を貼った。1000ユーザーのときはどちらのアプリも想定通り300msでレスポンスを返してる。けど、blocking-appのパフォーマンスは3000ユーザー、6000ユーザーに対してだんだん悪くなってる。

一方で、reactive-appはずっとグリーンのまま。最終的には6000ユーザーに対して95パーセンタイルが427msで、2000rpsくらい処理してる。(Core i7-7500U 2.7GHz/16GB RAMで確認)

へー。実際に書いて動かしてみると面白いね。

blocking-app

with 1000 users: f:id:bufferings:20180327230708p:plain

with 3000 users: f:id:bufferings:20180327230721p:plain

with 6000 users: f:id:bufferings:20180327230736p:plain

reactive-app

with 1000 users: f:id:bufferings:20180327230750p:plain

with 3000 users: f:id:bufferings:20180327230808p:plain

with 6000 users: f:id:bufferings:20180327230835p:plain

やり残し

TomcatのmaxThreadsのチューニング

今回はデフォルト設定のままで比較してみたんだけど、TomcatのmaxThreadsをチューニングしたら良いパフォーマンスが出そうではある。600rpsという数字も300msかかる処理を200スレッドで処理してるんだったらそうだろうという感じよね。

環境の分離

自分のノートPCで全部動かして確認したから、アプリとdelay-serviceとGatlingがそれぞれ作用しあってたよなーって思う。特にCPU。なので、環境をキレイにわけて別のマシンで動かしてるやつでパフォーマンスをちゃんと見たらまたちょっと違うのかなーと。

まとめ

面白かった。マイクロサービスアーキテクチャみたいにいっぱいAPIを呼び出すような場合は、リアクティブなやり方だと待ち時間が有効に使えるから良さそう。ただ、リアクティブプログラミングは、これまでサーブレットブロッキングな処理を書いてきたエンジニアにとっては頭の切り替えが必要だろうから(僕もそう)、そういう人はちょくちょく触って遊んでおくといいんじゃないかなー。

ということでWebFluxをポッケに入れといた。よー。今回はMonoだけを使ったけど、Fluxでごにょごにょしてみるのも面白そうだなー。