厳密に一度という神話
一言でいうと
ちょうど1回=少なくとも1回の配信+受信側での重複排除。インフラが魔法のようにやってくれるのを待たず、コンシューマーを冪等にします。
なぜ必要なのか
決済ボタンを押したのに、画面が止まりました。ユーザーはもう一度押します。サーバーは2回のリクエストを受け取り、両方を処理すると二重請求です。
この場面の根源は、前に見た、見分けがつかないという問題です。送った側が確認応答を受け取れなかったとき、メッセージが届かなかったのか、届いたのに応答だけが消えたのか、知る方法がありません。そのため、消失を防ぐにはリトライが必要で、リトライは重複を生みます。
戦略は2つしかありません。最大1回は、送って忘れる方式で、消失はありえて、重複はありません。少なくとも1回は、確認応答を受け取るまでリトライする方式で、消失はなく、重複はありえます。ほとんどのシステムは後者を選びます。消失のほうが重複より悪いからです。
どう動くのか
まず、冪等な操作とそうでない操作を区別する必要があります。「メールアドレスをa@b.comに設定せよ」は、何回送っても結果が1つです。「残高を100ウォン増やせ」は、2回送ると200ウォンになります。設定は冪等で、増加はそうではありません。
HTTPは、これをメソッドのレベルですでに規定しています。GET/HEAD/OPTIONSは安全で冪等、PUT/DELETEは冪等、POSTはそうではありません。PUTが冪等な理由は、「このリソースをこの値にせよ」という設定の操作だからです。DELETEも同じです。すでに消したものをもう一度消せと言っても、最終的な状態は同じです。レスポンスコードが404に変わることはありえますが、冪等性は状態に関する性質であって、レスポンスコードに関する性質ではありません。
POSTを冪等にする標準的な方法が、冪等キーです。クライアントがリクエストごとにUUIDを1つ作ってヘッダーに載せ、サーバーはそのキーを覚えておいて、同じキーがまた来たら新しく処理せず、保存しておいた最初の応答をそのまま返します。StripeとPayPalが、まさにこの方式を使っています。
実装で気をつける点が4つあります。
1つ目、キーの保存と期限切れ。永遠に保管はできないので、通常は24時間を置きます。
2つ目、並行性。同じキーのリクエスト2つがほぼ同時に届くと、両方とも「初めて見るキー」と勘違いして、二重に処理することがあります。キーを受け取った瞬間に、アトミックに先取り(claim)する必要があります。Redisなら、SET key <state> NX EX 86400の1回で済みます。
3つ目、キーと本文の一致の検証。同じキーなのに本文が違うなら、クライアントのミスか攻撃です。拒否するほうが安全です。
4つ目、失敗の性質の区別。一時的な失敗(DBタイムアウト)は、リトライが本当にもう一度試されなければなりませんが、確定的な失敗(残高不足)は、キャッシュして同じ答えを返すほうがよいです。
現場での姿
メッセージキューのコンシューマーでは、キーがすでにある場合が多いです。イベントIDか、集約ID+シーケンスです。処理したIDをRedisの集合やDBのユニーク制約で記録すれば済みます。ユニーク制約を使うと、データベースがアトミック性まで代わりに保証してくれるので、競合状態がなくなります。
注意すべき落とし穴が1つ。「処理したと記録」することと「実際の処理」が別々のストアにあると、また二重書き込みの問題です。可能なら、同じトランザクションの中に入れます。
失敗したメッセージはどこへ行くのか
冪等性を備えても残る問題があります。永遠に失敗するメッセージです。形式が壊れている、参照するデータが消えている、コードに欠陥がある、といった理由で、何度やり直しても同じ例外が出ます。このメッセージがキューの先頭にあると、その後ろの正常なメッセージがすべて塞がれ、リトライがリソースを食い続けます。
そのため、2つの仕組みが必要です。
リトライに上限と間隔を置きます。 すぐにやり直すと同じ理由でまた失敗するので、間隔を少しずつ延ばします。そして、複数のコンシューマーが同時に失敗したなら、リトライの時刻も一緒に集中するので、間隔に少しランダム性を混ぜて散らします。これがないと、相手のサービスが回復した途端にリトライストームに見舞われて、また倒れます。
上限を超えたらデッドレターキューに送ります。 処理の流れから外すけれど、捨てはしない場所です。ここに移すときは、元のメッセージと一緒に、なぜ失敗したのか、何回試したのか、最後のエラーが何だったのかを一緒に残します。 この情報がないと、あとでそのキューを開いても、何をすればよいかわかりません。
デッドレターキューは、作ったまま忘れがちです。そのキューの長さにアラートをかけておかなければ、誰も見ません。 そして、アラートが鳴ったときにすることも決めておく必要があります。コードを直してから入れ直すのか、データを直すのか、単に捨ててよいのか。この判断はたいてい業務の担当者の役目なので、何が失敗したのかを人が読める形で見せる方法も、一緒に必要です。
最後に、処理の順序についてもう1つ。リトライは順序を壊します。 前のメッセージがリトライで後回しにされている間に、後ろのメッセージが先に処理されるからです。順序が重要な流れなら、同じキーのメッセージを一列にまとめて処理するか、そもそも順序に依存しないように、各メッセージが最終状態を含むように設計する必要があります。後者のほうがはるかに堅牢で、それが前に見た「設定は冪等で、増加はそうではない」と同じ話です。
次のラボですること
わざと二重請求が起きる決済サービスを作り、冪等キーを付け、同時リクエストでも1回だけ処理されるようにアトミックな先取りを入れ、本文の不一致を拒否し、キーの期限切れまで設定します。