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

冪等性 — 二度押しても決済は一度だけ

リトライは避けられない

TT Labで続きを見る

一言でいうと

リトライは避けられません。だから重複はサーバーが防がなければなりません。

なぜ必要なのか: クライアントは区別できない

決済リクエストを送ったのに、応答が来ませんでした。次のどちらでしょうか。

  1. リクエストがサーバーに届いていなかった → もう一度送る必要がある
  2. 届いて処理されたのに、応答だけが来なかった → もう一度送ると2回決済される

クライアントには、これを区別する方法がありません。 そのため、どちらを選んでも間違いです。

だからクライアントはリトライし、サーバーが2回目を見分けられるようにするしかありません。

冪等キー

クライアントが、リクエストごとに一意のキーを作って付けます。

POST /pay
Idempotency-Key: 9f2c-...-a1
{"user":"u1","amount":1000}

リトライするときは、同じキーを使います。サーバーはそのキーを見たことがあれば、処理せず、保存しておいた応答をそのまま返します。

核心はこれです。2回目のリクエストも、成功の応答を受け取ります。 エラーではありません。クライアントから見れば「1回リクエストして、1回成功した」で、それが正しいのです。

どこに保存するのか

メモリ上のディクショナリではだめです。理由は2つです。

  1. 再起動すると忘れる
  2. サーバーが複数台ある: 1つ目のPodが覚えたことを、2つ目のPodは知らない

2つ目のほうがずっと一般的です。Podが1つでテストすると完璧に動き、オートスケールが付いた瞬間に重複が起きます。

そのため、保存は全員が一緒に見る場所でなければなりません。DBやRedisです。

同じキーで別の本文が来たら

Idempotency-Key: k1   {"amount": 1000}
Idempotency-Key: k1   {"amount": 99999}

2回目を黙って通すと、1000ウォンの応答を99999ウォンのリクエストに返すことになります。 クライアントは、99999ウォンが決済されたと思います。

そのため、リクエスト本文のハッシュも一緒に保存し、違っていれば422で拒否します。 キーを再利用するバグが、静かに埋もれないようにする仕組みです。

同時に同じキーが来たら

最もよく間違える場所です。

row = db.get(key)          # 없다
if not row:                # ← 열 개가 동시에 여기를 통과한다
    charge()
    db.put(key, response)

参照と挿入の間に、他人が割り込みます。素朴な方式は、負荷がかかった瞬間に壊れます。

正しくやるには、キーに一意制約をかけて、挿入に失敗した側が待ってから保存された応答を読むようにするか、ロックで包みます。データベースがすでに持っている保証を使うのが、最も安上がりです。

保存した応答はいつまで置くのか

永遠には置けません。通常は24時間くらい置いて、消します。

短すぎると、遅いリトライが重複を作り、長すぎると、ストレージが増え続けます。クライアントのリトライの窓より、余裕を持って長く設定します。

どのエラーでリトライするのか

応答 リトライ 理由
応答なし(タイムアウト) ✅ 届いたかわからない
500、502、503 ✅ サーバー側の一時的な問題
429 ✅(待ってから) Retry-Afterを守る
400、422 ❌ リクエストが間違っている。永遠に同じ
404 ❌ たいてい永続的

そして、間隔を延ばしながら(exponential backoff)行い、クライアントごとに揺らして(jitter)あげます。そうしないと、回復しようとするサーバーにリトライが同じ瞬間に集中して、また倒します。

どんなリクエストに付けるのか

お金が動くリクエストや、何かを作るリクエストに付けます。POSTと、副作用のあるPATCHです。

GET・PUT・DELETEは、もともと冪等になるように設計されたメソッドです。PUTは、同じものを2回書いても結果が同じで、DELETEは、2回目が「すでにない」です。設計がそうだというだけで、実装が自然にそうなるわけではありません。

保存した応答を返すときの落とし穴

冪等性を付けたあとに、新しく生じる問題があります。最初の応答をそのまま返すことが、常に正しいとは限りません。

状態がその間に変わっているかもしれません。 注文を作ったあとユーザーがキャンセルしたのに、リトライが来て、保存された201の応答(「作成された」)をそのまま返すと、クライアントは生きている注文があると信じます。保存するのはそのリクエストの結果であるべきで、クライアントが現在の状態を知る必要があるなら、応答に取得の経路も一緒に渡します。

応答を丸ごと保存すると、大きくなります。 大きな本文をそのまま入れると、ストレージが早く埋まります。必要なのは、たいてい、ステータスコードと作られたリソースの識別子だけなので、それだけを入れて、残りは作り直して渡します。

保管期間を決めて、消します。 冪等キーは、永遠には必要ありません。クライアントのリトライの窓(たいてい24時間)より余裕を持たせ、それ以降は消します。消さなければ、テーブルが大きくなり続け、インデックスが大きくなれば、もともと速かった取得が遅くなります。

delete from idempotency_keys where created_at < now() - interval '7 days';

期限切れのキーがまた来たらどうするかを決めます。 黙って新しく処理すると重複が生じ、拒否すると、非常に遅いリトライが失敗します。拒否するほうが安全で、そのときは「このキーは期限切れなので、新しいキーでもう一度送ってください」を、明確に知らせます。

応答ヘッダーで、リトライだと知らせます。 Idempotency-Replayed: trueのようなヘッダーを付ければ、クライアントも調査する人も、この応答が新しく処理されたものなのか、保存されたものなのかがわかります。ログにも残せば、「リクエストが2倍に増えた」のが、リトライなのか実際の増加なのかが、すぐに分かれます。

キー自体を信用しません。 クライアントが作った値なので、ほかのユーザーのキーと重なることがあります。組織やユーザーの識別子と一緒に一意性を取って初めて、他人の応答を受け取ってしまうことがなくなります。

現場では

二重決済の報告は、たいていユーザーではなく、精算のほうで先に発見されます。ユーザーは「決済できていないようなので、もう一度押した」としか覚えておらず、その間に何が起きたかは、サーバーのログにしか残らないからです。

そのため、冪等キーは、事故が起きたあとに付けるのが、特に難しいです。すでに出ているクライアントがキーを送らないので、サーバーは、しばらくキーのあるリクエストとないリクエストを、一緒に受ける必要があります。キーのないリクエストをどう処理するか(そのまま通すのか、拒否するのか、期間を決めて猶予するのか)を先に決めておかないと、移行の途中で、もっと大きな混乱が生じます。

モバイルアプリのように、デプロイを強制できないクライアントがあるなら、サーバーがリクエスト本文のハッシュで仮のキーを作って、短い時間だけ重複を防ぐ方法も使います。完全ではありませんが、ユーザーが続けて2回押す最も一般的なケースは、ふるい落とされます。