要点: サーバーを増やしても、一つのリクエスト自体が重いという問題は解決しません。
この記事の用語: Backpressure:処理能力を超える前に受付を減らす仕組み。オートスケール:需要に応じて実行数を増減すること。
現場の課題
メール送信、帳票生成、ファイル処理を応答前に行えば、利用者は長く待たされます。さらにサーバーを増やすほどDB接続も増え、下流のボトルネックを悪化させます。
単純な対策だけでは不十分な理由
オートスケールは閾値を超えた後に反応し、重いリクエストを軽くはしません。同期経路を短くし、遅延可能な仕事を分け、下流に上限を置くことが先です。
処理フロー
検証して保存→API
早く応答→キュー
急増分を吸収→ワーカー
副次処理を実行
アーキテクチャ上の判断
同期処理の契約を定義する
利用者の操作を受付済みにするために必要な状態だけをCommitします。副作用はTransactional Outboxで発行し、業務データを保存した後にジョブだけを静かに失う事態を防ぎます。
安全に再試行できるワーカーにする
業務操作ごとに1つの冪等キーを使い、試行状態を保存し、Transient エラーと恒久的なValidation エラーを分けます。At-least-once配信で請求やメールを重複させてはいけません。
需要と飽和度からスケールする
APIは同時実行数と遅延を追えます。ワーカーは最古ジョブの待ち時間、Arrival Rate、サービス Rateを追うべきです。ワーカーの同時実行数は、DBと外部APIが実際にさばける範囲に収めます。
さらに深く考える
BackpressureはAPI契約の一部
キューは無限容量ではありません。即時のOverloadを遅延Workへ変換するだけです。ですから、許容できる最大の最古ジョブの待ち時間を決め、そこから受付 ルールを導きます。予測待機が目標を超えるときは、守れない約束を受付ける前に、低Priorityの処理を拒否または縮退させます。曖昧にTimeoutさせるのではなく、再試行-Afterや再開可能なStatus Resourceを返します。顧客向けPriorityと技術的Fairnessは分けましょう。テナントごとのQuotaで1つのBulk Importが全ワーカーを占有するのを防ぎ、Weighted Schedulingで小さなInteractive ジョブ用の処理能力を確保します。
Exactly-onceはブローカーの機能ではなく業務上のEffect
多くのブローカーは、特にCrash前後ではAt-least-once配信です。1つの業務Effectを実現するには、Durable 冪等性 レコードと状態遷移を、Outputと同じトランザクションでCommitします。キーはDelivery 試行ではなく業務操作を識別するものにします。トランザクションに参加できない外部副作用では、Intentを保存し、提供側の冪等キーで呼び出し、不明な結果をReconcileします。Timeoutは失敗の証明ではありません。再試行の前に提供側の状態を確認することこそが、重複した課金、Message、データ出力を防ぎます。
実装手順
- リクエスト時間を測り、利用者が完了を待つ必要のない処理を同期経路から外します。
- キュー ジョブは複数回実行されても結果が一つになるよう設計します。
- CPUだけでなく、最古ジョブの待ち時間と下流サービスの上限でワーカーを増減します。
コード例: キュー容量とScaling Signal
arrivalRate = jobsAdded / windowSeconds
serviceRate = jobsCompleted / windowSeconds
backlogTime = queueDepth / max(serviceRate, 1)
desiredWorkers = ceil(arrivalRate / jobsPerWorker)
desiredWorkers = clamp(desiredWorkers, minWorkers, maxWorkers)
if backlogTime > objectiveSeconds:
desiredWorkers = min(desiredWorkers + burstStep, maxWorkers)Little's Lawは、すべての計測を同じ時間枠で行い、系が比較的安定しているときにだけ成り立ちます。Burst時には、最古ジョブの待ち時間が利用者影響に最も近いGuardrailになります。
想定しておく障害
- キューへのジョブ投入がDBのCommit能力を上回ります。ワーカー追加は障害を悪化させるため、同時実行上限と受付制御でDBを守ります。
- デプロイが実行中のワーカーを終了させます。処理権限の有効時間、Heartbeat延長、冪等 Handlerを使い、未完了ジョブを安全に再取得します。
- 外部APIが特定の1種類のジョブをレート制限します。キューまたは同時実行数 プールを分離し、その依存が無関係な処理へ波及しないようにします。
監視すべきこと
| 指標 | 何が分かるか |
|---|---|
| 最古ジョブの待機時間 | 利用者が実際に受ける遅延を測り、件数が少なくてもDrainの遅れを検出します。 |
| Arrival RateとCompletion Rate | 滞留ジョブが一時的なものか、数学的に増え続ける状態かを判断します。 |
| ワーカー利用率とジョブ p95 | 純粋な容量不足と、遅いジョブや偏った分布とを区別します。 |
| DB プール飽和度と外部Throttle | 安全な上限を定めます。上限を超えるスケールは、Bottleneckを移すだけです。 |
設計の検証方法
- 各ジョブを複数回再実行し、Business Row、通知、外部呼び出しがそれぞれ1件だけであることを確認します。
- Arrival Rateをサービス Rate以上に上げ、DBが飽和する前に受付制御が作動するか確認します。
- Claim前、処理中、Commit後の各段階でワーカーを停止させ、Lease 復旧を検証します。
- 1つの外部依存をThrottleし、分離したキューが無関係なジョブの遅延を維持することを確認します。
- 長時間の障害後、全滞留ジョブと通常の流入を合わせた復旧 Timeを測定します。
本番導入の進め方
メールや帳票などの副作用を一つずつOutboxとキューへ移します。同期結果と比較した後、最古ジョブの待ち時間とDBの安全上限に基づいてワーカーを増やします。
本番前チェックリスト
- キューは耐障害性を高めますが、その分だけ完了は即時ではなく非同期になります。
- 滞留ジョブの増加は、利用者が遅延に気づく前に通知しましょう。
- 接続数と同時実行数の上限でDBを保護します。
まとめ
オートスケールは、仕事の単位、非同期境界、需要を表す指標が明確になって初めて効果を発揮します。
