定義
ほとんどのキューはメッセージを最低一回(at-least-once)配信します。ワーカーが処理に成功したことを知らせる ack(確認応答) が届くまでメッセージを削除せず、失敗したり応答がなかったりすると再配信します。そのため、同じメッセージが二度以上届くことがあります。
冪等(idempotent) とは、同じリクエストを何度実行しても、結果が一度だけ実行したときと同じになる性質です。同じリクエストには同じ番号(冪等キー)を付け、すでに処理した番号が再び届いたらスキップする形で実現します。
なぜ必要か
二つの事故が、互いに反対の方向から起こります。
- 欠落: ワーカーが失敗したのに、例外を捕捉して正常に返してしまう(= ack)と、キューは成功したと判断してメッセージを削除します。ユーザーにはすでに「受付完了」と返したあとなので、誰も気づかないままデータが消えます。
- 重複: 処理は成功したのに、ack が届く途中でネットワークが切れると、キューは失敗とみなして再配信します。処理が二回行われ、同じ行が二つ溜まったり、同じ通知が二回送られたりします。
解決策はセットです。成功したときだけ ack して欠落を防ぎ、重複実行に対して安全にして再配信に耐えます。
動作原理
| 状況 | キューの動作 | 設計上の対応 |
|---|---|---|
| 処理成功 + ack | メッセージを削除 | 正常 |
| 処理失敗 + 例外 | 間隔を置いて再配信 | 再試行の経路として使う |
| 処理成功 + ack 消失 | 再配信 | 冪等キーで重複をスキップ |
| 失敗が続く | 最大配信回数まで繰り返す | 使い切ったら報告して隔離/ack |
冪等キーの流れです。
- ユーザーの操作ひとつを作る瞬間(フォーム送信など)に、一意の番号(UUID)を作ります。
- 番号をメッセージに載せてキューに入れます(キューが対応していれば、キューの重複排除オプションにも同じ番号を渡します)。
- 最終的な保存の段階で「この番号はすでにあるか」を確認し、あれば書き込まずに成功として応答します。
実務での適用
async function handle(message: { id: string; values: Values }) {
const result = await store.appendOnce(message.id, message.values); // 同じ id があれば何もしない
if (result.kind === "retryable") throw new Error(result.code); // throw すると再配信される
if (result.kind === "fatal") {
report({ id: message.id, code: result.code }); // 識別子と一緒に報告する
return; // ここでだけ意図的に ack する
}
}
- 例外の経路を ack しません。
try/catchでエラーを握りつぶして終わるコードが、欠落のもっとも多い原因です。 - 最大配信回数を決めます。 使い切ったら、識別子と一緒にエラー追跡ツールへ報告したうえで、ack するか dead-letter(失敗したメッセージを隔離するキュー)へ移します。
- 重複の検査は最終的な保存先で行います。 キューの手前だけでフィルタしても、再配信はキューの後ろで起こります。
- 三つのケースをテストとして残します: 同じメッセージを二回処理する、失敗後に再配信される、使い切ったときに報告する。
トレードオフ
| 選択 | 長所 | コスト |
|---|---|---|
| at-least-once + 冪等 | 欠落がなく、実装が現実的 | すべてのコンシューマが冪等である必要があります |
| at-most-once(先に ack) | 重複がない | 失敗すると欠落になります |
| 「ちょうど一回」を目指す | 理論的には理想的 | 多くは冪等な処理で代用します。コストが大きいか、範囲が限られます |
使うべきでない場合
- 重複が実際には害にならないログ的なイベントなら、複雑な冪等ストアは過剰です。
- 「とりあえず ack して、あとで処理する」は、欠落が許されるデータにだけ使うべきです。
- 冪等キーをサーバー側でリクエストのたびに新しく作ると、重複排除が働きません。ユーザー操作の単位で一度だけ作る必要があります。
よくある間違い
- catch ですべての例外を握りつぶして正常に返す。 静かな欠落のもっとも多い原因です。
- 再配信の間隔が設定どおりに動くと思い込む。 実際の配信時刻をログで確認します。SDK のオプションが環境によって無視されることがあります。
- 永遠に失敗するメッセージ(poison message)を放置する。 キューを詰まらせるので、隔離の経路が必要です。
- 使い切りの報告をせずに ack する。 人が気づくためのシグナルがありません。
関連概念
- queue-based-load-leveling — この ack のルールが必要になるキューとワーカーの構造
- retry-backoff-with-jitter — 再配信の間隔と、再試行の分類
- optimistic-outbox-rebase — 送る処理を溜めておき、順序を合わせて再適用する、似た問題へのクライアント側の解決策