RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

並列処理で発生したスレッド増加問題と解決策

はじめに

こんにちは。楽楽請求でバックエンド開発を担当しているmarumoです。
楽楽請求は2024年10月にサービスを開始した新サービスで、請求書を一元管理し、経理業務を効率化する請求書受領システムです。 その中で、請求書の内容をデータ化するために OCR エンジンの API を活用し、自動データ化機能を提供しています。

請求書の自動データ化は、楽楽請求の中核を担う機能です。
サービスの価値を支えるこの仕組みを安定かつ効率的に動作させるためには、大量の請求書を迅速に処理できることが欠かせません。
大量の請求書を確実に捌くため、OCR エンジンの API 呼び出し処理は高並列化を前提に構築しました。

しかし、高並列処理の負荷検証中に「CPU やメモリは安定しているのに、スレッド数だけが増え続ける」という現象に遭遇しました。 本記事では、その原因をどのように特定し、どのように解決したのかを紹介します。

利用技術

本記事で扱う実装は Spring Boot をベースにしています。主に利用している技術スタックは以下の通りです。

  • アプリケーションフレームワーク : Spring Boot 3.5
  • HTTP クライアント : RestTemplate(内部実装として Apache HttpClient 4 系を使用)

この記事では、これらの技術を前提にHTTP クライアントのライフサイクル管理に起因するスレッド増加問題とその解決策を解説します。 なお、本文中のサンプルコードは Kotlin で記載していますが、問題の本質は言語に依存しません。

背景:スレッドが増え続ける

OCR エンジンの API 呼び出し処理の負荷試験として、50 並列で OCR API を呼び出す構成に対して 500 ファイルを同時にアップロードしました。
すると CPU やメモリには大きな問題がないにもかかわらず、JVM のライブスレッド数だけが直線的に増加し、最終的には 1,000 を超える状態に達しました。

調査

最初に疑ったのは外部 API の遅延やハングでしたが、Thread Dump を確認すると RUNNABLE 状態の短命スレッドが大量に存在しており、この仮説は否定されました。

さらに Thread Dump を詳細に解析したところ、java.netorg.apache.http に関連するスレッドが多数存在していることが分かりました。
これらは HTTP クライアントの内部スレッドであり、RestTemplate の利用に伴うものです。

HttpClient-2-SelectorManager@19,900 in group "main": RUNNING
HttpClient-3-SelectorManager@19,902 in group "main": RUNNING
HttpClient-32-SelectorManager@19,942 in group "main": RUNNING
HttpClient-34-SelectorManager@19,944 in group "main": RUNNING
HttpClient-35-SelectorManager@19,946 in group "main": RUNNING
HttpClient-36-SelectorManager@19,949 in group "main": RUNNING
HttpClient-37-SelectorManager@19,950 in group "main": RUNNING
... 
... 
... 

解決策:HTTP クライアントの再利用

採用した方針は、HTTP クライアントを再利用可能な形で管理することです。
具体的には以下を実施しました。

  • RestTemplate を シングルトン Bean として定義
  • 呼び出し側での build() 呼び出しを廃止し、DI 渡しのインスタンスを利用

Before:毎回生成していたケース

この場合、リクエストのたびに HttpClient が新規生成され、内部で SelectorManagerWorker スレッドも作られるため、スレッド数が累積していきます。

class OcrServiceFactoryImpl {
    fun createRestTemplate(): RestTemplate {
        // 毎回 build() を呼び出して新しい HttpClient を生成
        return RestTemplateBuilder()
            .setConnectTimeout(Duration.ofSeconds(5))
            .setReadTimeout(Duration.ofSeconds(30))
            .build()
    }

    fun callOcrApi(request: OcrRequest): OcrResponse {
        val restTemplate = createRestTemplate()
        return restTemplate.postForObject("https://example.com/ocr", request, OcrResponse::class.java)!!
    }
}

After:シングルトンで再利用するケース

この構成では RestTemplate がアプリケーション全体で 1 インスタンス共有されるため、内部の HttpClient も再利用され、スレッド数が増え続ける問題が解消される。

@Configuration
class RestTemplateConfig {

    @Bean
    fun restTemplate(builder: RestTemplateBuilder): RestTemplate {
        return builder
            .setConnectTimeout(Duration.ofSeconds(5))
            .setReadTimeout(Duration.ofSeconds(30))
            .build()
    }
}

@Service
class OcrService(
    private val restTemplate: RestTemplate
) {
    fun callOcrApi(request: OcrRequest): OcrResponse {
        return restTemplate.postForObject("https://example.com/ocr", request, OcrResponse::class.java)!!
    }
}

検証

検証目的

  • RestTemplate をシングルトンに変更した構成で、HTTP 通信が直列化していないか(並列性を維持しているか)を確認する
  • 併せて、以前発生していた「スレッド数が通信ごとに増加する問題」が解消されているかを確認する

検証方法

以下を観測しました
- RestTemplate の通信ログ(DEBUG)によるリクエストの並列性
- Grafanajvm_threads_live_threads メトリクスによるスレッド数の挙動
- IntelliJ の Thread Dump によるスレッドの状態(処理中と処理完了後の2タイミング)

検証結果

  • スレッド数の挙動
    • 修正前:アップロードのたびに HttpClient-SelectorManager が増加し、jvm_threads_live_threads が400超に
    • 修正後:RestTemplate の使い回しにより、スレッド数は一定値(100前後)で安定し、増加し続ける挙動は再現されなかった

  • Thread Dump

    • 通信中は HttpClient-1-SelectorManagerRUNNABLE 状態で非同期処理を担当
    • 通信後も同一スレッドが残存していたが、新規増加は見られず再利用が確認された
  • 並列性

    • 通信ログのタイムスタンプを比較すると、複数のスレッドが同時にリクエストを発行しており直列化はされていない
    • レスポンス順序もランダムで、リクエストが並列で処理されていることを確認
// リクエスト開始ログ
2025-04-15 10:49:26.204 DEBUG 1 --- [pool-5-thread-6] o.s.web.client.RestTemplate              : HTTP POST https://example.com/ocr
2025-04-15 10:49:26.204 DEBUG 1 --- [pool-5-thread-7] o.s.web.client.RestTemplate              : HTTP POST https://example.com/ocr
2025-04-15 10:49:26.204 DEBUG 1 --- [pool-5-thread-4] o.s.web.client.RestTemplate              : HTTP POST https://example.com/ocr
2025-04-15 10:49:26.204 DEBUG 1 --- [pool-5-thread-5] o.s.web.client.RestTemplate              : HTTP POST https://example.com/ocr
...
// レスポンスログ
2025-04-15 10:49:31.756 DEBUG 1 --- [pool-5-thread-7] o.s.web.client.RestTemplate              : Response 200 OK
2025-04-15 10:49:31.929 DEBUG 1 --- [pool-5-thread-5] o.s.web.client.RestTemplate              : Response 200 OK
2025-04-15 10:49:32.029 DEBUG 1 --- [pool-5-thread-6] o.s.web.client.RestTemplate              : Response 200 OK
2025-04-15 10:49:32.871 DEBUG 1 --- [pool-5-thread-4] o.s.web.client.RestTemplate              : Response 200 OK

結論

  • RestTemplate をシングルトンにしたことで、内部の HttpClient も共有され、通信スレッドの再利用が有効に働いた
  • その結果、スレッド数が通信ごとに増加する問題は解消
  • HttpClient-1-Worker-* および HttpClient-1-SelectorManager のスレッドが再利用されていることが Thread Dump により確認され、リソースの安定性が確保された

まとめ

今回の事例、HTTP クライアントのライフサイクル管理不足がスレッド増加の原因になり得るという点が明らかになりました。
RestTemplate をシングルトン化し用途ごとに分離することで、リソースの再利用・安定性・性能の維持を実現しました。

Copyright © RAKUS Co., Ltd. All rights reserved.