TT Lab
はじめる
学ぶ 学習パス コース

ACKの前に止まったお菓子の自販機

二度届いた注文でもお菓子は一度だけ

TT Labで続きを見る

一言でいうと

重複メッセージを受け取らないのではなく、同じ業務IDの効果をもう一度適用しないように、受信記録と業務を一緒にコミットします。

なぜ必要なのか

倉庫がお菓子7個を準備してACKを送りましたが、受付側の接続が切れました。受付側には2つの仮説が残ります。倉庫が何もできなかったのかもしれませんし、すでに行ったのに応答だけが消えたのかもしれません。タイムアウトを長くしても、この区間が完全になくなるわけではありません。いつかは接続が切れ、プロセスは再起動します。確実な失敗と、結果のわからない失敗を同じものとして扱うと、二重出荷や欠落につながります。

受付側が不確実な項目をもう一度送るには、倉庫が同じ業務を見分けられなければなりません。メモリのsetにIDを入れると、実行中は重複を防げますが、次のプロセスには記憶がありません。ファイルにIDだけを先に書いてから在庫を更新すると、途中で終了したときに在庫が抜け、在庫を先に書いてからIDを記録すると、在庫が2回増えることがあります。受信側にも、1つの原子的な境界が必要です。

どう動くのか

inboxは業務と一緒に保存する

倉庫のinboxには、idとqtyが入ります。stockの単一の行には、累積数量totalがあります。receiveはBEGIN IMMEDIATEで書き込みトランザクションを開き、同じIDの記録を探します。初めて見るIDなら、inboxを挿入してtotalを増やしたあと、コミットします。同じIDと同じ数量なら、すでに適用された業務なので、もう一度増やさずに終わります。同じIDでも数量が違えば、Conflictとして拒否し、以前の業務を保存します。

このとき、receiveの戻り値Trueは、今回の呼び出しで新しい効果を適用したという意味です。Falseは、有効な重複を確認したという意味で、失敗ではありません。HTTPの受信側は、どちらの場合も同じIDのaccepted=true ACKを返します。新しい効果がなかったという理由で、重複リクエストに失敗を返し続けると、パブリッシャーは永遠にリトライします。内部関数の「新しく適用したか」と、通信応答の「この業務を受け入れたか」を混同しないことが重要です。

처음 snack-7 / 7 → inbox 1행, total=7, 새 적용 True, ACK 성공
다시 snack-7 / 7 → inbox 1행, total=7, 새 적용 False, ACK 성공
다시 snack-7 / 8 → 내용 충돌, 기존 total=7 보존, 성공 ACK 없음
다른 snack-8 / 7 → inbox 2행, total=14, 별개 업무 ACK 성공

この表は、内容が同じだからといって別の業務をまとめないことを示しています。SHA-256のような内容のフィンガープリントを付けても、同一業務を定義する責任はなくなりません。フィンガープリントは、同じキーのもとで内容が変わったかどうかを比べる手段であり、顧客が2回注文したかどうかを決める業務ルールではありません。この小さなラボでは、内容が整数の数量1つなので、直接比較します。構造化された内容に広げるときは、正規化とスキーマバージョンも考慮する必要があります。

いつACKを作ってよいのか

ACKは、receiveのコミットが終わったあとで作ります。HTTPステータス200だけで、パブリッシャーが要求した業務が確認されたとはみなしません。応答JSONのidが要求したidと同じか、acceptedが正確なブール値のTrueかを確認します。文字列"true"や数字の1を、真偽値として通過させません。ほかの注文の成功応答を、この注文の証拠として使うことはできないためです。実際のサービスでは、認証された相手かどうかと、応答のプロトコルバージョンも別に確認する必要があります。

今回のサーバーは教育用で、127.0.0.1の一時ポートでだけ動作します。認証とTLSがなく、外部公開用のAPIではありません。HTTPを使ったのは、関数呼び出しの間で起こした模擬エラーだけを見るのではなく、実際のリクエスト・応答と接続の終了を観察するためです。同一ホストの独立したDB2つを、ネットワークの向こうの2つのサービスだと拡大解釈してはいけません。公衆網の遅延やサーバー間の時計のずれ、デバイスの喪失は、このテストに含まれていません。

同じIDが同時に来たらどうなるか

重複の確認と書き込みを1つのトランザクションに入れる必要があるもう1つの理由は、競合です。2つのリクエストがどちらも「まだない」と読み、それぞれ業務を実行したあとで、IDだけを保存しようとすると、検査と実行の間にすきまができます。SQLiteのBEGIN IMMEDIATEは、書き込みの競合を直列化するために使われます。ほかの書き込みがすでに進行中なら、待機するか、ロックエラーになることがあります。それを「この業務は完了した」という意味に置き換えてはいけません。

一意キー制約は、重複した行を防ぐのに役立ちますが、別の外部の副作用までは元に戻してくれません。inboxの挿入と同じトランザクションでstockを変えるという、このラボの性質を、外部の決済呼び出しにそのまま当てはめることはできません。外部の作業には、相手の冪等性の契約、補償、状態照会のような別の設計が必要です。また、受信IDをいつまで保管するかを決めないと、古い再送が新しい業務として処理されることがあります。

現場での姿

通知、顧客データの連携、リアルタイムのイベントのコンシューマーは、「再送はまれだ」という仮定でよく失敗します。デプロイ直後にプロセスが入れ替わったり、プロキシが応答を切ったりすると、正常な業務にも再送が起こります。通信リクエスト数と実際の業務の適用数を別々に観測すれば、これを区別できます。リクエスト2件に対して業務1件は、重複防止が働いた正常な結果である場合があります。リクエスト1件に対して業務0件を、単に平均レイテンシが低いというメトリクスで覆い隠すことはできません。

エラーログに業務IDを残すことは役に立ちますが、顧客の名前・住所・決済情報まで無条件に残すと、別のリスクが生じます。このラボは、仮想のIDと数量だけを使います。運用の設計では、必要な相関情報だけを残し、保存期間とアクセス権限を決める必要があります。再送の内容をすべてログにダンプする方法で、冪等性ストアの代わりにしないでください。

次の確認ですること

すぐあとのクイズで、有効な重複、内容の衝突、別々の業務とACKの意味を区別します。次のモジュールでは、パブリッシャーの完了印がどこにあるべきかと、失敗したバッチの再開順序を学びます。ラボでは、inboxの挿入後、totalの更新後、コミット後にプロセスを終了させ、それぞれ独立した接続で状態を読みます。

公式ドキュメントでさらに読む