SQS のメッセージは本当に「少なくとも 1 回」届くのか - At-Least-Once 配信の仕組みと落とし穴

SQS Standard キューの At-Least-Once 配信がなぜ重複を生むのか、可視性タイムアウトの内部動作、FIFO キューの Exactly-Once との違い、冪等性設計の必要性を解説します。

At-Least-Once 配信とは何か

SQS Standard キューは「At-Least-Once Delivery」(少なくとも 1 回の配信) を保証しています。これは、送信されたメッセージが少なくとも 1 回はコンシューマーに配信されることを意味しますが、同じメッセージが 2 回以上配信される可能性があることも意味します。なぜ重複が発生するのでしょうか。SQS はメッセージを複数のサーバーに冗長化して保存しています。コンシューマーがメッセージを受信し、処理を完了して削除リクエストを送信するまでの間に、別のサーバーから同じメッセージが別のコンシューマーに配信されることがあります。分散システムでは、ネットワークの遅延やサーバー間の同期のタイムラグにより、この種の重複は原理的に避けられません。重複配信の頻度は公表されていませんが、AWS のドキュメントでは「まれに」発生すると記述されています。実際の運用では、秒間数千メッセージを処理する環境で、1 日に数件〜数十件の重複が観測されるケースが報告されています。

可視性タイムアウト - メッセージが「見えなくなる」仕組み

SQS の可視性タイムアウト (Visibility Timeout) は、メッセージの重複処理を防ぐための仕組みです。コンシューマーがメッセージを受信すると、そのメッセージは他のコンシューマーから一定時間「見えなくなり」ます。この時間が可視性タイムアウトで、デフォルトは 30 秒です。コンシューマーが可視性タイムアウト内に処理を完了し、DeleteMessage API でメッセージを削除すれば、メッセージは正常に処理されます。可視性タイムアウト内に削除されなかった場合、メッセージは再びキューに戻り、別のコンシューマーに配信されます。ここに落とし穴があります。処理に 30 秒以上かかる場合、可視性タイムアウトが切れてメッセージがキューに戻り、別のコンシューマーが同じメッセージを受信して二重処理が発生します。対策は 2 つあります。第 1 に、可視性タイムアウトを処理時間より十分に長く設定すること。第 2 に、処理中に ChangeMessageVisibility API で可視性タイムアウトを延長すること (ハートビートパターン) です。Lambda が SQS トリガーで起動される場合、Lambda サービスが自動的に可視性タイムアウトを関数のタイムアウト値の 6 倍に設定します。

FIFO キューの Exactly-Once 処理

2016 年に導入された FIFO (First-In-First-Out) キューは、メッセージの順序保証と重複排除を提供します。FIFO キューでは、メッセージ送信時に MessageDeduplicationId を指定すると、5 分間の重複排除ウィンドウ内で同じ ID のメッセージは 1 回だけ配信されます。これにより、プロデューサー側の重複送信 (ネットワークタイムアウトによるリトライなど) が排除されます。コンシューマー側の重複処理も、MessageGroupId による順序保証で緩和されます。同じ MessageGroupId のメッセージは、前のメッセージが削除されるまで次のメッセージが配信されないため、同一グループ内での並列処理による重複は発生しません。ただし、FIFO キューにはスループットの制限があります。バッチ処理なしで秒間 300 メッセージ、バッチ処理ありで秒間 3,000 メッセージが上限です。高スループットモードを有効にすると秒間 30,000 メッセージまで拡張できますが、Standard キューの事実上無制限のスループットと比較すると大幅に制限されます。順序保証と重複排除が不要なワークロードでは、Standard キューの方がスループットとコストの面で有利です。

冪等性設計 - 重複を前提としたアーキテクチャ

SQS Standard キューを使用する場合、コンシューマー側で冪等性 (Idempotency) を確保する設計が不可欠です。冪等性とは、同じ操作を複数回実行しても結果が変わらない性質です。たとえば、「ユーザー A の残高を 1,000 円に設定する」は冪等ですが、「ユーザー A の残高に 1,000 円を加算する」は冪等ではありません。後者を重複実行すると残高が 2,000 円になってしまいます。冪等性を実現する最も一般的なパターンは、処理済みメッセージの ID を DynamoDB に記録する方法です。メッセージを受信したら、まず DynamoDB に MessageId が存在するか確認し、存在すれば処理をスキップ、存在しなければ処理を実行して MessageId を記録します。DynamoDB の条件付き書き込み (ConditionExpression) を使えば、確認と記録をアトミックに実行できます。Lambda の Powertools ライブラリには、この冪等性パターンを簡単に実装できるデコレータが用意されています。関数に @idempotent デコレータを付けるだけで、DynamoDB ベースの冪等性チェックが自動的に組み込まれます。

デッドレターキュー - 処理できないメッセージの行き先

メッセージの処理が繰り返し失敗する場合、そのメッセージは永遠にキューに残り続け、コンシューマーのリソースを浪費します。デッドレターキュー (DLQ) は、指定回数の処理に失敗したメッセージを別のキューに移動する仕組みです。maxReceiveCount を 3 に設定すれば、3 回受信されても削除されなかったメッセージが DLQ に移動します。DLQ に移動したメッセージは、手動で確認して原因を調査し、修正後に元のキューに再送するか、破棄するかを判断します。DLQ の設計で見落としがちなのは、DLQ 自体のメッセージ保持期間です。SQS のメッセージ保持期間はデフォルト 4 日、最大 14 日です。DLQ のメッセージも同じ保持期間が適用されるため、14 日以内に対処しないとメッセージが自動的に削除されます。重要なメッセージを扱う場合は、DLQ に CloudWatch アラームを設定し、メッセージが到着したら即座に通知を受け取る体制を整えてください。2021 年に追加された DLQ リドライブ機能を使えば、DLQ のメッセージをコンソールから元のキューにワンクリックで再送できます。メッセージキューの設計パターンを体系的に学ぶには、専門書籍 (Amazon)が参考になります。