定義
フォーム送信のように「受け取って別のシステムに記録する」仕事では、リクエストを受ける側(API)と実際に記録する側の間にキュー(queue、やるべき仕事を順番に並べておく場所) を挟むパターンがあります。APIはリクエストを検証してキューに入れ、すぐに成功を返します。そしてワーカー(worker) という別のプログラムがキューから一件ずつ取り出して、実際の仕事を行います。
名前の「負荷平準化」は、急に集中したリクエストの尖った山をキューが押さえて、相手のシステムには処理できるペースでだけ届くようにする、という意味です。キューが「一度に一件ずつ処理」を自動的に保証してくれるという意味ではありません。この違いは後でもう一度取り上げます。
なぜ必要か
APIが外部システムを直接呼び出して終わるまで待つと、次のような問題が起きます。
- 外部システムが書き込み中に全体をロック(lock) する場合(スプレッドシートに行を追加するスクリプトサーバーが代表的です)、同時に10件来ると1件だけが入れて、残りは「今は忙しい」で失敗します。
- 外部システムが呼び出し回数を制限していると(rate limit)、集中した瞬間にだけエラーが出ます。普段は見えず、イベントの直後にだけ起きるバグになります。
- ユーザーは遅い呼び出しが終わるまで待たされ、失敗すると入力した内容を失います。
キューを置くと、ユーザーへの応答は「キューに入れる時間」だけで済むので速くなり、処理の失敗はワーカーがリトライで吸収できます。
動作原理
ブラウザ → API: 検証 → 一意の番号を付与 → キューにpublish → すぐに { ok: true }
↓
キュー(耐久性をもって保管) → ワーカー: 外部呼び出し1回
├─ 成功 → ack(キューから削除)
├─ 一時的な失敗 → ワーカー内で短いジッター付きリトライ
└─ それでも失敗 → 例外を投げる → キューが間隔を空けて再配信
- 受付(publish): APIは入力の検証、重複防止用の一意の番号の生成、キューへのpublishだけを行って応答します。この時点から「受付完了」を約束した状態です。
- 配信: キューはメッセージをワーカーに渡します。ワーカーが成功を知らせるack(確認応答) を送るまで、メッセージは削除されず再び配信されます。
- リトライの二層構造: ワーカー内の短いリトライがロック競合のような瞬間的な衝突を解消し、キューの長い間隔での再配信が、相手の障害のように長く続く失敗を担います(retry-backoff-with-jitter を参照)。
- 使い切った場合の処理: 最大配信回数を超えたら、識別子と一緒にエラー追跡ツールへ報告してからackし、キューが詰まらないようにします。
注意: キューは同時実行数を減らしてくれないことがあります。 多くのマネージドキューにはFIFOや「同時に一件だけ配信」のオプションがないため、10件が同じ瞬間にワーカーへ配信され、再配信も同じ時刻に集中します。順序を整える役割は、ワーカー内のランダムなリトライが担う必要があります。
実務での適用
// API: 受付だけして応答する
export async function POST(req: Request) {
const values = validate(await req.json());
const submissionId = crypto.randomUUID();
try {
await queue.send("contact", { submissionId, values }, { idempotencyKey: submissionId });
} catch {
await callBackendDirectly(submissionId, values); // キューへのpublishが失敗したときだけ直接呼び出しにフォールバック
}
return Response.json({ ok: true });
}
// ワーカー: 成功したときだけ正常に返す(= ack)
export async function handle(message: { submissionId: string; values: Values }) {
const result = await callBackendWithJitterRetry(message); // 短いジッター付きリトライを含む
if (!result.ok) throw new Error(result.code); // 投げるとキューが再配信する
}
- フォールバック経路とワーカーが同じ呼び出し・失敗分類のコードを使うようにしないと、二つの経路が別々の動きをしてしまいます。
- キュー経路は、ローカル開発では認証トークンがなくて呼び出しが失敗することがよくあります。ローカルのテストがフォールバック経路だけで通っていないかを確認し、キュー経路は本番のログで一度以上検証します。
トレードオフ
| 得られるもの | 失うもの |
|---|---|
| 速い応答、集中の吸収、失敗時のリトライ | 即時の一貫性: 応答した時点では記録がまだありません |
| 相手のシステムの保護 | キューとワーカーという構成要素と運用の負担 |
| 障害中も受付を継続 | 欠落・重複を自分で設計する必要があります(idempotent-consumer-ack-timing) |
使うべきでない場合
- ユーザーが結果をすぐに知る必要があるリクエスト(決済の承認結果など)では、受付の応答だけでは足りません。
- 相手のシステムが十分に速く、同時実行の制限もないなら、キューは複雑さを増やすだけです。
- キューが順序や同時実行を保証してくれないのに、「キューがあるから競合はなくなる」と想定してはいけません。
よくある間違い
- 想定外の例外をackして黙って捨てる。 すでに「受付完了」と答えたあとなので、誰にも気づかれずに欠落します。成功したときだけackします。
- キューの再配信だけを頼りにする。 同じ時刻に集中した再配信は、また同じところで衝突します。まずワーカー内でランダムな間隔でリトライします。
- publishの失敗を処理しない。 キューの障害がそのまま受付の障害になります。直接呼び出しのフォールバックを用意しつつ、回数を制限します。
- キュー経路を一度も実行しない。 ローカルではフォールバックだけが動いていることがあります。
関連概念
- retry-backoff-with-jitter — ワーカー内とキューのレベルでリトライを分ける方法
- idempotent-consumer-ack-timing — 再配信で重複が生じる理由とackのルール
- optimistic-outbox-rebase — 送る仕事を溜めておき、あとから届けるクライアント側の構造
- serverless-stateless-execution — ワーカーがリクエストごとに新しく起動する無状態のプロセスなので、リトライの状態をメモリに置けない理由