maxThreads 200が実際に何を意味するのか
一言でいうと
acceptCount・maxConnections・maxThreadsは、それぞれ別の地点で別の役割を果たしており、応答が遅い原因がスレッド不足でないときにmaxThreadsを上げると、状況が悪化します。
なぜこの区別が必要なのか
3つの値を1つにまとめてしまうと、対応がいつも同じになります。数字を上げることです。ところが、それぞれが詰まる地点が違うので、症状も違います。acceptCountを超えると、カーネルが接続そのものを拒否してConnection refusedが出て、maxConnectionsを超えると、接続はできるのに応答が来ず、maxThreadsを超えると、リクエストがキューで順番を待ちます。症状だけを見て、どの数字を触るかを選べる必要があります。
3つの数字の役割は、それぞれ違う
性能の課題が出ると、誰かが必ずこう言います。「maxThreadsを上げてみましょう。」ところが、大半の場合、その対応は何も変えられないか、状況をさらに悪化させます。なぜかを知るには、3つの値がそれぞれどの地点で動作するかを知る必要があります。
[클라이언트 소켓]
|
v
(1) acceptCount ← OS 소켓 백로그 큐. 여기까지 넘치면 Connection refused
|
v
(2) maxConnections ← 톰캣이 동시에 '들고 있는' 커넥션 수
|
v
(3) maxThreads ← 동시에 '처리 중인' 요청 수 (워커 스레드)
|
v
[애플리케이션]
- maxThreads(デフォルト200): 同時にリクエストを処理するワーカースレッドの数です。リクエスト1つがスレッド1つを占有します。DBを待っている間も占有します。
- maxConnections(NIOのデフォルト10000): Tomcatが保持するソケットの数です。Keep-Aliveで開いているだけでリクエストを送らないコネクションは、スレッドを消費しないので、この値はmaxThreadsよりはるかに大きくてもかまいません。それがNIOを使う理由です。
- acceptCount(デフォルト100): maxConnectionsまで埋まったとき、OSのソケットバックログで待つコネクションの数です。ここまであふれると、クライアントは503ではなく
Connection refusedを受け取ります。この違いが、障害分析で決定的です。
Connection refusedが出力されたということは、「サーバーが死んだ」ではなく、「サーバーは生きているが、受け入れる余力がなかった」という可能性があります。前段のnginxのログにconnect() failed (111: Connection refused)が見えたら、バックエンドプロセスの死亡だけでなく、バックログの飽和も候補に挙げる必要があります。
maxThreadsを上げても効かない理由
リクエスト処理時間の大半がDB待ちなら、スレッドを増やすのは、DBの前に並ぶ人を増やすことです。スループットはそのままで、待ち時間だけが増えます。ひどいと、コネクションプールの枯渇で全体が止まります。
スレッドダンプを取って、こんなパターンが見えたら、スレッドプールの問題ではありません。
"http-nio-8080-exec-73" ... WAITING (parking)
at jdk.internal.misc.Unsafe.park
at com.zaxxer.hikari.pool.HikariPool.getConnection ← DB 커넥션 풀 대기
このダンプのメッセージは明確です。問題はWASのスレッドプールではなく、DBコネクションプールです。maxThreadsを400に上げても、待っているスレッドが200個から400個に増えるだけです。
根拠をもって算出する: リトルの法則
勘で決めずに、計算しましょう。リトルの法則は、次のように書きます。
동시 처리 수 = 목표 처리량(TPS) × 평균 응답시간(초)
このコードブロックの韓国語は、同時処理数は、目標スループット(TPS)に平均応答時間(秒)を掛けた値である、という意味です。
目標が毎秒300件で、平均応答が200msなら、
300 × 0.2 = 60
つまり、最低60個のスレッドが同時に働いていて初めて、300 TPSが出ます。ここに余裕率(ピーク比、通常20–50%)を掛けます。30%の余裕なら、60 × 1.3 = 78です。
CPUバウンドの作業なら違います。コア数より多いスレッドは、コンテキストスイッチのコストを増やすだけです。経験則は、おおよそ次のとおりです。
- I/Oバウンド(大半の業務Webアプリ):
코어 수 × (1 + 대기시간 / 처리시간)(韓国語の語は、順に「コア数」「待ち時間」「処理時間」を意味します) - CPUバウンド(暗号化、画像処理):
코어 수 + 1(韓国語の語は「コア数」を意味します)
そして必ず確認すべきことがあります。maxThreadsを決めたあとは、DBコネクションプールとの関係を見ます。WASのスレッド200個がすべてDBを使うのに、プールが20個なら、180個は待ちます。逆に、プールを200個に増やすと、今度はDB側のmax_connectionsが破綻します。インスタンスが複数台なら、掛け算になります。
서비스 A 12대 × 풀 20 = 240
서비스 B 8대 × 풀 15 = 120
배치 워커 4대 × 풀 5 = 20
합계 = 380
DB max_connections = 200 ← 롤링 배포로 인스턴스가 잠깐 두 배 되는 순간 터진다
このコードブロックの韓国語は、サービスA 12台、サービスB 8台、バッチワーカー4台と、それぞれのプールサイズ、合計、そしてローリングデプロイでインスタンスが一時的に2倍になる瞬間に破綻する、という意味です。
この計算をしていないシステムは、ローリングデプロイの最中に、FATAL: sorry, too many clients alreadyで崩壊します。さらに悪いのは、そのエラーがヘルスチェックとモニタリングエージェントまで止めてしまう点です。障害の瞬間に、観測手段も一緒に消えます。
Keep-Aliveは両刃の剣
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
keepAliveTimeout="5000"
maxKeepAliveRequests="100"
maxThreads="150" minSpareThreads="20"
maxConnections="2000" acceptCount="50" />
connectionTimeout: コネクションを受け付けたあと、リクエスト行が到着するまで待つ時間です。リクエストの処理時間制限ではありません。これを勘違いして、「タイムアウトを伸ばしたのに、まだ遅いのですが」が出てきます。keepAliveTimeout: 次のリクエストを待ちながら、コネクションを維持する時間です。長ければ、コネクションの再利用でレイテンシが減りますが、アイドルのコネクションがmaxConnectionsを食いつぶします。maxKeepAliveRequests: 1つのコネクションで処理する最大リクエスト数です。-1は無制限です。
前段にnginxがあるなら、nginx ↔ Tomcatの間のコネクションは少数で、再利用率が高いです。その場合は、keepAliveTimeoutを余裕を持って与えるほうがよいです。逆に、Tomcatがインターネットに直接公開されていると、アイドルのコネクションがリソースを消費するので、短く設定します。「正解の値」ではなく、「前段の構成によって変わる値」です。
共有Executor
コネクターを複数置くと、それぞれがスレッドプールを持ちます。そうなると、全体のスレッド数を制御しにくくなります。このとき、共有Executorを使います。
<Executor name="tomcatThreadPool" namePrefix="labhub-exec-"
maxThreads="150" minSpareThreads="20"/>
<Connector executor="tomcatThreadPool" port="8080" protocol="HTTP/1.1" ... />
namePrefixに意味のある名前を付けるのが、実務のコツです。スレッドダンプを取ったときに、labhub-exec-37のように出れば、これがどのコネクターのスレッドかがすぐわかります。デフォルトのhttp-nio-8080-exec-も悪くありませんが、コネクターが複数あるときは、区別が必要です。
ヒープとGCログは、チューニングの前に有効にする
CATALINA_OPTS="-Xms1g -Xmx1g \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/logs/dump \
-Xlog:gc*:file=/logs/gc.log:time,uptime:filecount=5,filesize=10m"
-Xmsと-Xmxを同じ値にするのが、サーバーアプリケーションの慣行です。ヒープが伸びたり縮んだりする際に発生するGCをなくします。-XX:+HeapDumpOnOutOfMemoryErrorというフラグは、選択肢ではなく必須です。OOMは再現が困難です。発生した瞬間にダンプを取れなければ、原因分析は推測になります。ただし、ダンプファイルはヒープサイズ分できるので、ディスクの空きを確認する必要があります。- GCログは、性能問題が出たあとに有効にしても遅いです。最初から有効にしておいて、ファイルローテーションを設定します。
この3行をサービスイン前に入れておけば、安定化期間の障害分析の時間が半分になります。
現場での姿
性能会議で最もよく出る対応が、「maxThreadsを上げてみましょう」です。そして、たいてい何も良くなりません。リクエストがDBの応答を待っている間もスレッドは占有されているので、ボトルネックがDBなら、スレッドを増やしても待つスレッドが増えるだけです。むしろ、コンテキストスイッチとヒープ使用量が増えて、GCの停止が長くなります。
そのため、順序があります。まず、スレッドダンプを取って、ワーカーたちが何をしているかを見ます。大半がRUNNABLEならCPUが足りないのであり、大半がソケットやロックでWAITINGなら、スレッドではなく、その先がボトルネックです。後者でmaxThreadsを上げるのは、待合室を広げることであって、窓口を増やすことではありません。