負荷試験をかけたら急にレスポンスが伸びた、本番でアクセスが集中したら接続が失敗した。こういうとき「Tomcatのスレッド数を増やせばいいのかな」と思いつつ、server.tomcat.threads.max をいくつにすべきか根拠が持てなくて困りますよね。

この記事では、Spring Boot 3.x の組み込みTomcatで効いている4つのプロパティがリクエストのどの段階で働くのかを整理し、Actuatorのメトリクスと負荷試験で値を決める手順をまとめます。HikariCPチューニング記事の隣にあたる内容で、プール数とスレッド数の整合も扱います。

対象は Spring Boot 3.x(同梱の Tomcat は 10.1 系)の組み込みTomcatで、従来のスレッドモデルです。Jetty、Undertow、WebFlux(Netty)は設定体系が異なるので扱いません。

server.tomcat.* のデフォルト値と役割

まず4つのプロパティをデフォルト値ごと押さえましょう。

プロパティデフォルト役割
server.tomcat.threads.max200リクエストを処理するワーカースレッドの上限
server.tomcat.threads.min-spare10常に確保しておくスレッド数
server.tomcat.max-connections8192Tomcatが同時に保持する接続数の上限
server.tomcat.accept-count100接続を受け付けきれないときにOSが待たせるキューの長さ

application.yml に書くとこうなります。

server:
  tomcat:
    threads:
      max: 200
      min-spare: 10
    max-connections: 8192
    accept-count: 100

旧名の server.tomcat.max-threadsmin-spare-threads は Boot 2.3 で非推奨になり、3.x では削除済みです。古い記事の名前をそのまま書いても無効なので注意してください。

これらの値は ServerProperties に読み込まれ、TomcatWebServerFactoryCustomizer がTomcatのConnectorに反映しています。自分でTomcatをカスタマイズしなくても、application.yml だけで完結するわけです。

ひとつ混同しやすいのが @AsyncTaskExecutor のスレッドプールです。あれはアプリ内で非同期処理を実行するための別のプールで、Tomcatのワーカースレッドとは無関係です。そちらのサイジングは非同期処理の記事を参照してください。

リクエストが処理されるまでの3段階

4つのプロパティを理解するには、リクエストが通る道筋を3段階に分けて見るのが近道です。

クライアント


[1] OSのacceptキュー ........ server.tomcat.accept-count (100)
   │  溢れると → 主に connect timeout
   │            (tcp_abort_on_overflow=1 の環境では Connection refused)

[2] Tomcatが保持する接続 .... server.tomcat.max-connections (8192)
   │  上限に達すると → 1接続だけ accept して処理せず、残りは[1]で待つ

[3] ワーカースレッド ......... server.tomcat.threads.max (200)
   │  全部busyだと → 接続済みのまま処理待ち、レスポンスが伸びる

アプリケーション (Controller → Service → DB / 外部API)

段階1はOSのbacklogです。TomcatのAcceptorが接続を取り出す前に溜まる場所で、accept-count がその長さになります。ここが溢れたときの見え方はOS設定で変わります。Linuxのデフォルト(net.ipv4.tcp_abort_on_overflow=0)ではSYNが黙って捨てられ、クライアントは再送を繰り返した末に connect timeout になります。RSTが返って Connection refused になるのは tcp_abort_on_overflow=1 にしている環境です。

段階2はTomcatのPollerが保持する接続数です。Tomcat公式ドキュメントの表現では、max-connections に達すると「さらに1接続だけ accept するが処理はしない」動作になり、それ以降の接続は段階1のキューで待たされます。

段階3がワーカースレッドで、threads.max 本まで増えます。全スレッドがbusyのときは接続自体は受け付けられているので、クライアントは「つながっているのに返ってこない」状態になります。

ここで大事なのは、max-connectionsaccept-count をいくら増やしても、スレッドが詰まっていれば待ち行列が長くなるだけという点です。接続層を広げると、むしろタイムアウトまでの待ち時間が伸びて症状が悪化することもあります。

症状から原因の層を切り分ける

自分の症状がどの層にあたるかを最初に絞り込みましょう。

症状原因候補
接続はできるがレスポンスが遅いthreads.max の枯渇、またはその先のDB・外部API待ち
connect timeoutaccept-count の溢れ、max-connections 上限、OSのsomaxconn
Connection refused上と同じ層。ただしRSTが返るのは tcp_abort_on_overflow=1 などOS設定次第
read timeoutthreads.max 枯渇で処理待ち、または処理そのものが遅い
HikariPool のtimeoutログDBプール枯渇(接続待ちが connection-timeout を超過)

大まかには「接続はできるが遅い」ならスレッド層かその先、「接続自体ができない」なら接続層かOS側、という二分で考えると迷いにくいです。refused が出ていないからbacklogは無関係、とは言い切れない点だけ覚えておいてください。

そして最初に確認すべきは tomcat.threads.busythreads.max に張り付いているかどうかです。これが次の節で見るメトリクスになります。

なお、1台のCPUが飽和しているケースはスレッドをいじっても解決しないのでスケールアウトの出番です。この記事は1インスタンス内の設定に絞ります。

Actuator で tomcat.threads.* を確認する

spring-boot-starter-actuator を依存に追加し、metrics エンドポイントを公開します。Actuatorが初めての方はActuator入門記事も合わせてどうぞ。

ここでハマりやすい前提がひとつあります。Micrometer の Tomcat メトリクスは Tomcat の ThreadPool MBean 経由で登録されるのですが、server.tomcat.mbeanregistry.enabled は Boot 2.2 以降デフォルトで false です。有効化しないと tomcat.sessions.* しか出ず、tomcat.threads.*tomcat.connections.* は 404 になります。

server:
  tomcat:
    mbeanregistry:
      enabled: true   # これがないと tomcat.threads.* が登録されない

management:
  endpoints:
    web:
      exposure:
        include: health,metrics

メトリクスが見つからないときは、まず mbeanregistry が有効か、次に management.metrics.enable.tomcat=false を書いていないか、の順で疑ってください。

curl -s http://localhost:8080/actuator/metrics/tomcat.threads.busy
# {
#   "name": "tomcat.threads.busy",
#   "measurements": [{ "statistic": "VALUE", "value": 12.0 }],
#   "availableTags": [{ "tag": "name", "values": ["http-nio-8080"] }]
# }

measurements[0].value が今まさに処理中のスレッド数です。見るべきメトリクスは3つあります。

  • tomcat.threads.config.max は設定値そのもの(200)
  • tomcat.threads.current は生成済みのスレッド数。min-spare から始まり負荷に応じて max まで増える
  • tomcat.threads.busy は処理中のスレッド数。これが config.max に張り付いていればスレッド枯渇

接続層は tomcat.connections.currenttomcat.connections.config.max で同じように見られます。

curlで十分傾向はつかめますが、時系列で追いたいならPrometheusとGrafanaに流すのが楽です。その構築はObservability記事にまとめています。

ab / k6 で負荷をかけて busy とレイテンシ��突き合わせる

計測には I/O 待ちを模した検証用エンドポイントがあると便利です。

@RestController
public class LoadTestController {

    // 200msのDB・外部API待ちを模擬する
    @GetMapping("/slow")
    public String slow() throws InterruptedException {
        Thread.sleep(200);
        return "ok";
    }
}

abで同時接続数を段階的に上げていきます。別ターミナルで tomcat.threads.busy をcurlし続けてください。

ab -n 2000 -c 50  http://localhost:8080/slow
ab -n 2000 -c 200 http://localhost:8080/slow
ab -n 2000 -c 400 http://localhost:8080/slow

# 別ターミナルで観測
watch -n 1 'curl -s localhost:8080/actuator/metrics/tomcat.threads.busy | jq .measurements[0].value'

ab はデフォルトで keep-alive を使わず接続を毎回張り替えるので、-c はそのまま同時接続数として max-connectionsaccept-count の検証にも使えます。

k6を使うなら、stagesでVUを上げていくスクリプトが最小構成です。

import http from 'k6/http';

export const options = {
  stages: [
    { duration: '30s', target: 50 },
    { duration: '30s', target: 200 },
    { duration: '30s', target: 400 },
  ],
  thresholds: { http_req_duration: ['p(95)<1000'] },
};

export default function () {
  http.get('http://localhost:8080/slow');
}

結果はこう読みます。-c 200 までp95が約200msで一定、-c 400 でbusyが200に張り付いてp95が約400msに倍増したなら、threads.max がボトルネックです。逆にbusyが200に達していないのにレイテンシが伸びているなら、スレッドは余っているのにその先(DBや外部API)で待っているということなので、Tomcat側をいじっても効きません。

ローカルでの試験はあくまで傾向把握です。最終的な値は本番相当のデータ量と接続先で確認しましょう。

server.tomcat.threads.max の決め方

「コア数×何倍」のような機械的な式で決められないのは、処理の性質で最適値が大きく変わるからです。

CPUバウンドな処理が中心なら、コア数前後のスレッドで十分です。それ以上増やしてもCPUを奪い合ってコンテキストスイッチが増えるだけで、スループットは上がりません。

I/Oバウンド(DB待ち、外部API待ち)が中心なら、スレッドはほとんどの時間を待ちに使うので、コア数を大きく超える値が有効になりえます。ただし待ち先のDBプールや外部APIの上限が、今度は新しいボトルネックになります。

上げすぎのコストも知っておきましょう。スレッドスタックは1本あたり約1MB(-Xss 依存)が目安なので、threads.max=1000 なら最大で約1GBが予約されえます。加えてコンテキストスイッチとGCへの負担も増えます。

初期値の目安はリトルの法則で出せます。

必要スレッド数 ≒ 目標スループット(req/s) × 平均処理時間(s)
例: 500 req/s × 0.2 s = 100 スレッド

この値を起点に負荷試験で検証する、というのが基本の流れです。「500がベスト」のような固定の正解値は存在しないので、計測を根拠にしましょう。

HikariCP の maximumPoolSize と threads.max の整合

先に誤解を解いておくと、threads.max がプール数より大きいこと自体は正常な状態です。HikariCP の公式ドキュメントも、DB側で競合させるよりアプリ側のプールで短く待たせる方がスループットが出るとして、小さめのプールを推奨しています。ワーカー200本にプール10でも、DB待ちが短ければ問題は起きません。

問題になるのは「DB待ち時間 × 行列の長さ」が connection-timeout(デフォルト30秒)を超えて持続する過負荷状態です。このとき次のログが出ます。

java.sql.SQLTransientConnectionException:
  HikariPool-1 - Connection is not available, request timed out after 30000ms.

再現するには、接続を数秒占有するクエリが必要です。速いクエリだと待ちが30秒に届かず再現しません。SELECT pg_sleep(2) を実行するエンドポイントを用意し、maximum-pool-size: 5ab -c 100 をかけると、100リクエストを5接続で2秒ずつさばくので最後尾は約40秒待ちになり、30秒を超えた分がこのログになります。手っ取り早く見たいなら connection-timeout: 1000 に短縮しても構いません。

対処の方向は3つあります。

  1. プールを増やす。ただしDB側の max_connections をインスタンス数で割った範囲内に収める
  2. threads.max を下げて、DB待ちの行列をTomcat層で止める
  3. connection-timeout を短くして、待たせずに早く失敗させる

決める順序はDB側から逆算するのが安全です。DBの接続上限 → インスタンス数で割って maximumPoolSize → その値とI/O待ちの比率から threads.max という順番ですね。

server:
  tomcat:
    threads:
      max: 100          # DB待ち比率とプール数から逆算

spring:
  datasource:
    hikari:
      maximum-pool-size: 20   # DB max_connections=100 を 4インスタンスで分割
      connection-timeout: 3000 # 30秒待つより早く失敗させる

minimumIdlemaxLifetime を含むHikariCP側の細かい調整は、HikariCPチューニング記事にまとめているのでそちらを参照してください。

min-spare・max-connections・accept-count・keep-alive の調整ポイント

threads.max 以外は「いつ触るべきか」だけ押さえておけば十分です。

threads.min-spare は起動直後やアイドル後に一気にリクエストが来たとき、スレッド生成のコストを避けるためのウォームアップ値です。朝一番にスパイクが来るような環境では上げる価値があります。

max-connections はデフォルトの8192で通常は十分です。tomcat.connections.current が張り付いていない限り、上げる理由はほぼありません。

accept-count はOSのbacklogなので、net.core.somaxconn などOS側の上限にも影響を受けます。application.yml だけ上げてもOSが小さければそこで頭打ちです。

keep-alive には注意が必要です。server.tomcat.keep-alive-timeoutserver.tomcat.max-keep-alive-requests の設定次第で、アイドルな接続が max-connections を占有し続けます。keep-alive-timeout は未設定だと server.tomcat.connection-timeout の値(Tomcat のデフォルトは60秒)に従うので、何も書かないと1分間アイドル接続が居座ります。クライアント数は多いのに実際の処理数が少ない場合は、keep-alive-timeoutを短くする選択肢を検討しましょう。

どれを触るにしても、threads.maxbusy をセットで見るのが前提です。

Virtual Threads を有効化した場合の差分

Java 21 と Boot 3.2 以降では、spring.threads.virtual.enabled=true の1行でTomcatのワーカーが仮想スレッドのExecutorに置き換わります。このとき本記事の設定はこう変わります。

  • threads.maxthreads.min-spare は実質的に意味を持たなくなる
  • max-connectionsaccept-count は接続層の設定なので引き続き効く
  • スレッド数の上限が消える分、maximumPoolSize や外部APIの上限がそのままボトルネックになる

つまり前節の整合の話が、仮想スレッドではむしろ重要になります。

監視面でも注意が要ります。ThreadPool MBean 由来の tomcat.threads.busytomcat.threads.current は、仮想スレッドの Executor ではワーカーの実態を反映しない値(-1 のような固定値)になります。「busy が max に張り付いたら通知」のようなアラートは誤作動しうるので、代わりに hikaricp.connections.pending などプール側のメトリクスを監視しましょう。

仕組みやpinningの注意点、導入手順はVirtual Threads記事にまとめています。

初期値 → 計測 → 調整 の決定フロー

ここまでの内容を手順に圧縮するとこうなります。

  1. デフォルト値のまま、mbeanregistry.enabled=true と metrics 公開でActuatorを有効化する
  2. 負荷試験で tomcat.threads.busy とp95レイテンシを段階ごとに記録する
  3. busyが config.max に張り付くなら、DBの接続上限から maximumPoolSizethreads.max を決める
  4. 接続層の症状があれば max-connectionsaccept-count、keep-aliveを見直す
  5. 変更後に同じ負荷試験を再実行して効果を確認する

最終的な application.yml の例です。値はあくまで例なので、自分の計測結果で置き換えてください。

server:
  tomcat:
    mbeanregistry:
      enabled: true       # tomcat.threads.* を Actuator に出すため
    threads:
      max: 100            # 負荷試験: busy=100 で p95 が安定、DB 待ち比率から逆算
      min-spare: 20       # 朝のスパイク時にスレッド生成待ちが出たため引き上げ
    max-connections: 8192 # connections.current は最大 1500 程度で余裕あり
    accept-count: 100     # connect timeout は未観測のためデフォルト維持
    keep-alive-timeout: 20s # 未設定だと connection-timeout(60秒)に従うため短縮

spring:
  datasource:
    hikari:
      maximum-pool-size: 20  # DB max_connections=100 / 4 インスタンス = 25 から superuser 予約とマイグレーション用に 5 残して 20
      connection-timeout: 3000

デプロイ時に処理中のリクエストを抱えたスレッドが強制終了されないよう、Graceful Shutdownの設定も合わせて入れておくと安心です。

まとめ

Tomcatのスレッド設定は、accept-count → max-connections → threads.max の3段階でどこが詰まっているかを見極めるのが出発点です。接続できるのに遅いならスレッド層かその先、接続できないなら接続層かOS側を疑いましょう。

値を決める根拠は tomcat.threads.busy と負荷試験のレイテンシです。busyが張り付いていればスレッド不足、張り付いていないのに遅ければDBや外部I/Oが原因になります。

そして threads.max はDBプールの上限と一緒に決めること。固定の正解値はなく、計測が根拠になります。

プール側の詳細はHikariCPチューニング記事、スレッドの上限を気にせず高スループットを狙うならVirtual Threads記事を次に読んでみてください。