Java と AWS X-Ray が関係する並列処理のハマりポイント

はじめに

こんにちは。

学生アルバイトをしている美馬 隆志と申します。バックエンドエンジニアとして、『ホットペッパービューティー』内の「美容クリニック(美容クリニックのカウンセリング予約ができるサービス)」を担当しています。

今回取り組んだタスクは美容クリニックのクライアントが使う管理サービスにある、請求画面の高速化でした。 具体的には、管理サービスの大半のページが0.5秒程度で表示されるのに対し、請求画面の表示には2〜3秒程度を要していました。 このページ表示速度を高速にすることで、美容クリニックを利用するクライアントの体験を良くすることができると考えました。

結果的に、並列処理を実装することで既存実装の2倍以上の高速化を実現することができました。

しかし、今回実施した高速化手法をテックブログとして寄稿するだけでは汎用性に乏しく、エンジニアの読者に有益な情報にはなりにくいと思われます。

そこで、本記事では高速化のタスクを進めるときに気づいた Java と美容クリニックで利用している AWS X-Ray が関係する並列処理のハマりポイントを紹介します。

そのために、まず美容クリニックにおけるシステムの概観を紹介し、 その次に並列処理のハマりポイントとして

  • なぜ、ただ並列処理を実装するだけでは問題があるのか
  • 親スレッドと子スレッドで共有されない情報と、その情報を並列処理でも利用するための方法
  • スタックトレースが長くなる問題と解決策

の 3 点を紹介します。

目次

  • 美容クリニックにおけるシステムの概観
  • なぜ、ただ並列処理を実装するだけでは問題があるのか
  • 親スレッドと子スレッドで共有されない情報と、その情報を並列処理でも利用するための方法
    • AWS X-Rayのトレースセグメント
    • Log4jのContextMap
  • スタックトレースが長くなる問題と解決策
  • まとめ
  • おわりに

美容クリニックにおけるシステムの概観

美容クリニックの Web アプリケーションは、レンダリングエンジンと BFF (Backend for Frontend) を含むフロントアプリケーションと DB 等と接続されている API で構成されています。 これらは Spring Boot フレームワークを利用した Java で開発されています。

今回のアルバイトで改善したのは BFF の実装だったため、連携している Elasticsearch や CDN といった記事の説明に不必要な情報は触れません。 詳しい構成はこちらの記事をご覧ください。

また、フロントアプリケーションおよび API では、アプリケーションがリクエストを捌く際の処理のトレース用に AWS X-Ray を導入しています。 これにより、開発者はどのメソッドにどれだけ時間を要したかを視覚的に判断することができます。

なぜ、ただ並列処理を実装するだけでは問題があるのか

すぐに思いつくのは、下記の2つの理由です。

  1. 利用するスレッドプールの特性を理解しないと逆に低速化に繋がる可能性があるため
  2. 親スレッドと子スレッドで共有されない情報があるため

前者は、 ThreadPoolExecutorForkJoinPool で二分されるスレッドプールの特性に起因します。 ただし、これらの情報は既に多くの先駆者が分かりやすい記事を投稿しているため、本記事での言及はいたしません。

一方で、後者の親スレッドと子スレッドで共有されない情報があるという点について触れている記事はあまり多くありません。 そこで、この問題に絞って話を進めます。

親スレッドと子スレッドで共有されない情報と、その情報を並列処理でも利用するための方法

では、親スレッドと子スレッドで共有されない情報とは何でしょうか?

例えば、下記の情報が該当します。

  • AWS X-Ray のトレースセグメント
  • Log4j の ContextMap

以降では AWS X-Ray のトレーシングについて並列処理で利用するための方法を紹介します。

AWS X-Ray のトレーシング

AWS X-Ray はデフォルトだとメインスレッドでしか機能しません。 子スレッドで機能させるためには、SegmentContextExecutors.newSegmentContextExecutor() が利用できます。 ちなみに、少し前までは setTraceEntity が使えたのですが、今は Deprecated になっているため非推奨です。詳しくは下記 Pull Request と Issue をご覧ください。

実装例は下記の通りです。

1
2
3
4
5
final Optional<Segment> baseSegment = AWSXRay.getCurrentSegmentOptional();
CompletableFuture.supplyAsync(() -> {
                      // ここに処理を書く
                    }, SegmentContextExecutors.newSegmentContextExecutor(baseSegment.orElse(null)));

JavaDoc によると、getCurrentSegment は Nullable であり、newSegmentContextExecutor も null の場合を加味してオーバーロードされているので、わざわざ getCurrentSegmentOptional しなくても良いと思う方もいるかもしれません。 しかし、テスト時や AWS X-Ray を無効化している場合には、当然この取得自体に失敗して処理が落ちてしまうため、わざわざこのような書き方をしています。

今回は詳細を割愛しますが、AWS X-Ray のトレーシングと同様にして Log4j の ContextMap も共有することができます。 簡単ですね。

スタックトレースが長くなる問題と解決策

Java では子スレッドの情報を親スレッドに渡すことはできますが、逐次実行と比較してスタックトレースが長くなるという問題があります。 この問題について説明する前に、 Java の例外について少し補足します。

Java には検査例外と非検査例外が存在します。 簡単にいうと、前者はコンパイル時に気付ける例外であり、後者は実行時に気付ける例外です。

今回の高速化実装では並列処理のために Stream API を利用しているため、 Stream の中で検査例外を出すことができず、非検査例外でラップすることでこの問題を解決する手法が取られることがあります。

1
2
3
4
5
6
7
8
9
stream.map(
  result -> {
    try {
      // 検査例外が発生する処理
    } catch(Exception e) {
      throw new RuntimeException(e); // 非検査例外であるRuntimeExceptionでラップすることで誤魔化す
    }
  }
).collect(Collectors.toList()); // ここでも例外が発生してしまうので二重に RuntimeException でラップされてしまう

この非検査例外のラップは逐次処理では発生しない例外であり、これがスタックトレースが長くなる理由です。 開発者はスタックトレースが長くなると問題の根源を見つけづらくなるため、下記の通りアンラップする事でこの問題を解決しました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
try {
  stream.map(
    result -> {
      try {
        // 検査例外が発生する処理
      } catch(Exception e) {
        throw new RuntimeException(e); // 非検査例外である RuntimeException でラップすることで誤魔化す
      }
    }
  ).collect(Collectors.toList());
} catch (Exception e) {
  while (e != null && e.getCause() instanceof RuntimeException) {
    e = e.getCause();
  }
  throw e; // 型はThrowable
}

まとめ

本記事では高速化のタスクを進めるときに気づいた Java と AWS X-Ray が関係する並列処理のハマりポイントを紹介しました。

おわりに

本記事内では紹介できませんでしたが、今回のアルバイトでは BFF におけるパフォーマンス改善と並列処理のパッケージ化を行いました。 その過程で、 BFF におけるパフォーマンス改善も、既存のパフォーマンス改善と同様に計測を行った後に変更できる場所とそうでない場所を見分けて改善するという流れを着実にこなしていくことが重要であると学べました。

また、パフォーマンス改善の観点として、ただ高速化するだけではなく、その変更が何に対してどのような変化をもたらすかを考慮すべきであることを経験を通して学べました。 例えば、複雑性の増加や既存のコード規範を壊すことによる保守性の低下や、行った改善は本当にユーザのためになるのか、といった観点です。 これらは言われてみれば納得できるものの、元々意識していなかった観点だったので、このアルバイトを通じて学ぶことができました。

最後までお読みいただきありがとうございました。少しでも学びになる部分があれば幸いです。