Amazon SQSでFIFOだからってシステム全体が Exactly-Once になると思ったら大間違いだっていう話

TL; DR

  • Amazon SQS で Exactly-Once なキューを使おうとも冪等な処理を書くべき
    • キューが Exactly-Once であるという性質はシステム全体が Exactly-Once になることを保証できない
    • 結局マルチデータソースへの書き込みの問題が残る
  • Designing Data-Intensive Applications (邦訳: データ指向アプリケーションデザイン) が良書でした
    • 邦訳は未読1ですが原著の内容がいいのできっとだいじょうぶでしょう

Designing Data-Intensive Applications: The Big Ideas Behind Reliable, Scalable, and Maintainable Systems

Designing Data-Intensive Applications: The Big Ideas Behind Reliable, Scalable, and Maintainable Systems

データ指向アプリケーションデザイン ―信頼性、拡張性、保守性の高い分散システム設計の原理

データ指向アプリケーションデザイン ―信頼性、拡張性、保守性の高い分散システム設計の原理

経緯

先日 twitter を見ていたら以下のような発言がありました。

Amazon SQS は同一メッセージが複数回配信される可能性があるので冪等性を保証する必要がある」という言明がある。しかしこれは FIFO キューを使えば Exactly-Once な配信を保証できるので偽である

しかしこれは正しくありません。FIFOキュー使ってもシステムを堅牢にするためには冪等性を保証しておいたほうがよい、ということを以下で主張します。

複数データソースにおけるトランザクション

本記事では、なんらかの処理をして副作用として永続化を行うような一連のステップのことをトランザクションと呼びます。たぶん一般的な定義ともそんなにずれてないと思います。トランザクションは開始したあとに、コミットあるいはロールバックをして終了します。トランザクションを張りっぱなしで掴んだままのケースもありますが、適当にタイムアウトしてロールバックされるものとします。

複数のデータソースに対してトランザクションを行うとき、正常系ではコミットも複数回行う必要があります。このときに問題になる典型的なパターンがあります。それは、あるデータソースではコミットに成功したが違うデータソースではコミットに失敗するケースです。コミットはいろいろな事情により失敗する可能性があります。RDBのなんらかの制約であったり、ネットワークの不通であったり、あるいはプロセスが殺されたり、もしくはデータセンターに隕石が降ってくるかもしれません。ここで重要なのは、どんなトランザクションでもコミットに失敗しうる、ということです。

例えばよくあるトランザクションの例では、1000円の口座残高をもつAさんと、同じく1000円の口座残高をもつBさんがいたときに、AさんがBさんに100円送金する例が挙げられます。これはトランザクションを張っておきましょうねで済みます。しかし、このときAさんのレコードは mysql に、Bさんのレコードは postgres にいたりすると片方だけコミットに成功し、片方がロールバックされるような状況も起きえます。

この例では「いやいや mysql と postgres って。設計がヤバすぎでしょう」という気持ちにもなりますが、上述のSQSの問題についてもまさに同様のことが起きます。このケースでは我々はRDB2とSQSという2つのデータソースに対して一貫性を保ちたいということを言っているのですから。

SQSというデータソース

SQSにおけるトランザクション

Amazon SQS についてはトランザクションという言葉が(たぶん)出てこないので、本エントリでは Amazon SQS のどういうところを指してトランザクションと言っているのか、またそれがどういった性質をもっているのかを説明します。詳細な仕様は公式ドキュメントを参照してください。

SQSはメッセージキューです。キューの両端には producer と consumer がいます。producer がメッセージを送信して consumer がメッセージを受信します。

メッセージの受信方法が特徴的で3、 consumer は以下のような処理を行います。

  1. タイムアウトの時間を指定しつつ」メッセージを受信する
  2. メッセージの内容を解釈して実際の処理を行う
  3. メッセージの削除をおこなう。ただし、タイムアウト時間内にメッセージが削除されなかった場合は、そのメッセージは再度キューから受信できるようになり、1に戻る。

これは上述のトランザクションでいうと、メッセージの取得がトランザクションの開始に、メッセージの削除がコミットに対応すると言えます。ロールバックに関してはタイムアウト時間の経過により引き起こされると捉えることができます4

対応表を作ると以下のようになります。

意味合い\ 実際の処理 RDBの処理 SQS の処理
トランザクション開始 BEGIN + 書き込みロック メッセージの受信
コミット COMMIT メッセージの削除
ロールバック ROLLBACK タイムアウト

Exactly-Once なキューとは

SQSにはキューの種類が2種類あります。通常のキューとFIFOキューです。FIFOキューは名前の通り、(同一メッセージグループにおいて)先入れ先出しであることが保証されます。また Exactly-Once という性質があります。Exactly-Once については、あるメッセージが同時に複数の consumer に見えることがない、という意味です(以下抜粋)。

1 回だけの処理は、単一のコンシューマーおよび複数のコンシューマーシナリオの両方に適用されます。FIFO キューを複数のコンシューマー環境で使用する場合、現在のメッセージが削除されるか、可視性タイムアウトの有効期限が切れた後でのみ、メッセージを他のコンシューマーに表示するようキューを設定できます。
https://aws.amazon.com/jp/blogs/news/new-for-amazon-simple-queue-service-fifo-queues-with-exactly-once-delivery-deduplication/ より。

SQSを使ったシステム

SQSについての説明はここまでとし、次はSQSを使ったシステムを考えます。今回は consumer 側だけを考えるので十分です。consumer はメッセージを受け取りRDBに何らかの処理をするものとします。このとき、RDBとSQSの2つのデータソースがあるのでコミット順序についても選択肢が2つあります。

  1. RDBへのコミットをしてからメッセージの削除を行う
  2. メッセージの削除が成功してから RDB へのコミットを行う

それぞれのケースについて、先にしたコミットだけが成功するケースを考えます。

「1. RDBへのコミットをしてからメッセージの削除を行う」について

RDBへのコミットが成功しメッセージの削除が失敗したケースです。この場合、メッセージの削除に失敗したメッセージは複数回受信されることになります。

重複受信であることを何らかの手段で判断して捨てるなど、処理自体を冪等にしておかないと本当に重複実行されてしまいます。

「2. メッセージの削除が成功してから RDB へのコミットを行う」について

メッセージの削除が成功しRDBへのコミットが失敗したケースです。この場合、メッセージの内容は処理されておらず、キューからも消えているのでメッセージがロストすることになります。

どちらを選択するか

上述の失敗ケースはどちらもSQS上で Exactly-Once が保証されているかに関係ありません。

通常のシステムではメッセージのロストは許されない5と思うので、メッセージの削除に失敗した時に重複処理が起きることを受け容れることになるでしょう。

重複処理が起きるということは当然 Exactly-Once ではありません。これがタイトルでいう「Amazon SQSでFIFOだからってシステム全体が Exactly-Once になると思ったら大間違いだ」という主張でした。具体的には、consumer の処理を冪等にするなどの対策がよいと思います。やっぱり冪等性が大事なんですねえ。

終わりに

本記事は業務での経験と、書籍 Designing Data-Intensive Applications で得た知識によって構成されています。

もうちょい俯瞰した話を Designing Data-Intensive Applications Chapter 11. Stream Processing の Atomic commit revisited から抜粋します。

In order to give the appearance of exactly-once processing in the presence of faults, we need to ensure that all outputs and side effects of processing an event take effect if and only if the processing is successful.
...
If this approach sounds familiar, it is because we discussed it in "Exactly-once message processing" in the context of distributed transactions and two-phase commit.

ここでの exactly-once はSQS本体ではなくて処理全体としての話です。出力と副作用、つまりRDBへのコミットとSQSからの削除は、全部成功したときにしか現れてほしくないと言っています。そしてそれは分散トランザクションや2相コミットに似てると。先程はSQSとRDBだったので分散トランザクションとかは考えなかったのですが、そういうアプローチで解決することも可能なんでしょうね。本では Google Cloud Dataflow や VoldDB、Kafka などにそういった取り組みがあると書いてあります。

同Chapterの Idempotence セクションでは、分散トランザクションではなく冪等性で解決できるよ、ということも書いてあります。

Our goal is to discard the partial output of any failed tasks so that they can be safely retried without taking effect twice. Distributed transactions are one way of achieving that goal, but another way is to rely on idempotence.

この辺りのセクションが特に参考になるかと思いますので気になった方は読んでみてください。全編通して面白いのでおすすめです。日本語版の発売も楽しみですね。

余談

実装上の tips ですがSQSに DeleteMessage を送ってみたらタイムアウトしていた、というのに対応するために、RDBへのコミットまでのタイムアウトを5秒にして、SQSに対するタイムアウト時間を10秒にするなどのバッファをもっておくことで、送ってみたらタイムアウトしていたケースをほぼなくすことができるはずです。


  1. というか執筆当時未発売。2019年7月18日発売予定だそうです(公式ページより)

  2. なんらかのシステムにおけるプライマリなデータソースの代表例として挙げています。

  3. 他のメッセージキューに詳しいわけじゃないんで嘘かもですが

  4. 実際、あるメッセージの処理を即座に中断したいときにはタイムアウト時間を0に設定する処理によって実現することになります

  5. 許される要件があったら気になるので教えてください。