ある操作が冪等(idempotent)であるとは、それを複数回実行してもシステムの状態が1回実行した場合と同じになることを指します。同じリクエストを2回送っても余計なことは何も起こりません。2つ目の注文も、2重の請求も、2つ目のアカウントも作られません。
この言葉が登場する場面はたいてい次の2つのいずれかです。本番環境での二重請求か、あるいはIdempotency-Keyヘッダーを要求しながら、サーバー側でそれをどう扱うのかをほとんど説明していない決済APIか。本記事では、この契約の両側面を扱います。クライアントがキーをどう扱うのか、そしてサーバーが繰り返されたリクエストを「たいていは無害」ではなく「確実に無害」にするために何をするのか、です。
要点
- 冪等性キーの状態は2つではなく3つあります。存在しない(absent)、処理中(in flight)、完了(complete)です。キーがin flight状態であることを検出したリクエストは、2度目の実行ではなく409 Conflictを受け取るべきです。
- キーが存在するかをチェックしてから後で書き込む、というのが競合状態の正体です。安全なのは、処理を始める前に単一のアトミックなINSERTでキーを確保する方法だけです。
- 保存する処理結果は、業務上の変更と同一のデータベーストランザクションでコミットしなければなりません。別々にコミットするのは競合状態を移動させているだけです。
- クライアントは最初の試行の前にキーを生成し、すべてのリトライで同じキーを再利用します。そしてリクエストボディのハッシュからキーを導出してはいけません。
- テストは、同一のリクエスト2件を同時に発射して行います。逐次実行する重複テストは、コードに競合があっても通ってしまいます。
冪等性は何を防ぐのか
冪等性は重複リクエストからあなたを守ります。そして重複リクエストは特殊な事態ではなく、ごく日常的なものです。ユーザーがページの反応を待たずに送信ボタンをダブルクリックする。クライアントライブラリがレスポンス待ちでタイムアウトして再送する。プロキシやサービスメッシュが接続断でリトライし、アプリケーションはそれを知らない。いずれの場合も最初のリクエストは成功しているかもしれず、2つ目を素朴に処理すれば2つ目の注文が作られたり、お金が2回動いたりします。二重送信バグのセッションリプレイを見ると、たいていはありふれた光景が映っています。スピナーがまだ画面に出ている間にユーザーがもう一度送信を押す、という、まさにキーがサーバー側で解決しようとしている問題のクライアント側の姿です。
なぜリトライは届き続けるのか
リトライは故障ではありません。HTTPクライアント、モバイルアプリ、その間にあるインフラは、すべて設計上タイムアウト後にリクエストを再送します。レスポンスの喪失はリクエストの喪失と区別がつかないからです。リトライが届くのを止めることはできません。できるのは、同じリクエストを2回受け取っても安全なエンドポイントにすることだけです。
RFC 9110はPUTとDELETEを冪等と定義していますがPOSTはそうではなく、RFC 5789で定義されたPATCHも冪等ではありません。メソッドのラベルだけでハンドラーが安全になることは決してありません。冪等性は動詞の性質ではなく、実装の性質です。
クライアント側の契約
クライアントは最初の試行の前に冪等性キーを生成し、その操作のすべてのリトライで同一のキーを送り、本当に新しい操作のときにだけ新しいキーを使います。UUIDのような高エントロピーのランダム文字列で十分です。Stripeのガイダンスでは、V4 UUID、上限255文字、そしてキー自体には機密情報を含めないこと(メールアドレスやその他の個人識別情報は不可)とされています。キーはログに出るからです。
リクエストボディのハッシュからキーを導出してはいけません。たまたま内容が同一の2つの注文、たとえば同じ顧客が同じ商品を続けて2回買った場合、同じ値にハッシュされて1つに統合されてしまいます。ハッシュは2つのペイロードが一致するかどうかを教えてくれますが、キーは呼び出し側がどの操作を意図したのかを示します。この2つは別の仕事です。カートIDのような、ユーザーがすでに操作対象としている安定した何かからキーを導出するのは問題ありません。カートが操作そのものを表しているからです。
なお、Idempotency-Keyヘッダーは業界慣行であって、批准された標準ではありません。IETF httpapiワーキンググループのドラフトはrevision 07でRFCにならないまま失効したため、各プロバイダーが独自のセマンティクスを定義しています。
サーバー側:キーをアトミックに確保する
冪等性キーの状態は2つではなく3つです。存在しない(absent)、処理中(in flight)、完了(complete)です。壊れた実装のほとんどは2状態しかモデル化していません。キーが存在するかチェックし、ハンドラーを実行し、それから結果を保存します。これでは、2つの同時リトライがどちらも「何もない」と判断して両方が実行される時間窓が残ります。修正方法は、処理を始める前に単一のアトミックなINSERTでキーを確保することです。
INSERT INTO idempotency_keys
(tenant_id, idem_key, fingerprint, state, locked_until)
VALUES
($1, $2, $3, 'in_flight', now() + interval '90 seconds')
ON CONFLICT (tenant_id, idem_key) DO NOTHING
RETURNING id;
PostgreSQLでは、キーが衝突した場合ON CONFLICT DO NOTHINGがINSERTをスキップし、RETURNINGは行を返しません。したがって返却行数がゼロなら、別のリクエストがそのキーを保持しているということです。既存の行を読み、その状態がcompleteならば保存済みの処理結果をリプレイし、まだin_flightならば2度目の実行はせず409 Conflictを返します。これは各プロバイダーのドキュメント化された挙動と一致します。Stripeは最初のリクエストが実行中にキーが再利用された場合409 Conflictを返し、その衝突をキーに対して記録しないので、クライアントは後で改めてリトライできます。Stripeはまた、リプレイされたレスポンスにIdempotent-Replayed: trueヘッダーを付けています。これは真似する価値のある、コストの低い配慮です。
処理結果は業務上の変更と一緒にコミットする
保存する処理結果と業務上の変更は、同一のデータベーストランザクションでコミットしなければなりません。別々に書き込んでも競合状態はなくなりません。2つのコミットの間の隙間に移動するだけです。請求の後、キーの更新の前にプロセスが死ねば、お金は動いたのに行はまだin_flightのままです。
BEGIN;
INSERT INTO orders (tenant_id, customer_id, total_cents)
VALUES ($1, $2, $3);
UPDATE idempotency_keys
SET state = 'complete', status_code = 201, response_body = $4
WHERE tenant_id = $1 AND idem_key = $5;
COMMIT;
両方の行が存在するか、どちらも存在しないかのどちらかになります。それがこの手法の要点そのものです。
キーに対して何を、どれくらいの期間保存すべきか
ハンドラーが生成したものは、失敗も含めてすべて保存します。Stripeの冪等性ルールでは、最初の試行のステータスコードとボディが保持され、再利用時に再度返されます。エラーレスポンスや500も含まれます。実際のエラーをリプレイするほうが、こっそり操作を2回実行するよりも誠実です。本当の境界線は、ハンドラーが動く前に拒否されるものすべてです。レートリミットや認証は冪等性レイヤーの手前に位置するため、それらのレスポンスはキーに紐づくことがなく、リトライ可能なままです。
保存方式の選択肢は簡潔に3つあります。
- レスポンス全体。 正確にリプレイするのが最も簡単ですが、ストレージがペイロードサイズに比例して増えます。
- リソース参照。 作成された注文のIDを保存してレスポンスを再構築します。軽量ですが、追加のルックアップが必要です。
- マーカー+リクエストのフィンガープリント。 ストレージは最小限ですが、レスポンスが再計算可能な場合にのみ成立し、フィンガープリントはオプションではなく必須になります。
どの選択肢でも4つのルールが適用されます。ユニーク制約はキー単体ではなく(tenant_id, key)に付けます。そうすれば、あるテナントが別のテナントのキーと衝突したり、それを探り当てたりできません。有効期限を設定します。Stripeは24時間を過ぎたキーを削除しますが、原則はリトライ窓より長く生存させつつ、テーブルが永遠に膨張しないようにすることです。in_flight状態の行にはリース(上のlocked_untilカラム)を設定し、リクエストの途中で死んだプロセスがリトライを永久にブロックしないようにします。そして、フィンガープリントが保存済みのものと一致しないリトライは拒否します。同じキーで異なるボディが来るのはクライアントのバグを示しており、無関係なレスポンスを返してしまうほうが悪い結果になります。
冪等性を正しくテストするには
同一のキーを持つ同一のリクエスト2件を同時に発射し、リソースがちょうど1つだけ存在することをアサートします。重複を逐次実行しても何も証明できません。2つ目が見に行く前に1つ目が完了してしまうため、競合のあるコードでも通ってしまうからです。
KEY=$(uuidgen)
for i in 1 2; do
curl -s -o "resp_$i.json" -w "%{http_code}\n" \
-X POST http://localhost:3000/orders \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"cart_id":"c_42","total_cents":1900}' &
done
wait
そのカートに対してordersの行が1つであること、そして2つのステータスコードが201と、リプレイされた201または409のいずれかであることをアサートします。両方が異なる注文IDで201を返してきたなら、check-then-writeの競合が起きています。
押さえるべき3つのこと
ヘッダーは2つのシステムに1つの操作の共通の名前を与えるだけです。安全性そのものは、データベース内の3つの要素から生まれます。ユニーク制約、アトミックな確保、そしてトランザクション境界です。これらを正しく実装すれば、リトライするあらゆるクライアント、つまりすべてのクライアントに対してエンドポイントは耐えられます。同じアプローチはメッセージコンシューマーにも引き継がれます。そこではat-least-once配信により、dedupeキーが別の名前で同じ仕事をしています。最も危険なPOSTエンドポイントから始めて、キーテーブルを追加し、信頼する前に並行テストを書いてください。
FAQ
GETとPUTのリクエストに冪等性キーは必要ですか?
通常は不要です。RFC 9110はGETをsafe、PUTとDELETEをidempotentと定義しているため、リソースを完全に置き換えるPUTはリトライされてもキーなしで同じ状態になります。キーが重要なのはPOSTで、リクエストごとに新しいものが作られるからです。例外は、メール送信やWebhook発火のような副作用を持つPUTやDELETEのハンドラーで、これらは依然としてサーバー側の重複排除が必要です。
冪等性キーとリクエストIDの違いは何ですか?
リトライをまたいだときの振る舞いが正反対です。リクエストIDや相関IDは、ロギングとトレーシングのために単一のHTTP試行を識別するので、リトライのたびに新しいものが割り当てられます。冪等性キーは1つの意図された操作を識別するので、リトライのたびに同じものを再利用します。リトライごとに新しい冪等性キーを生成するクライアントは重複排除を完全に無効化してしまい、サーバーは操作を2回実行します。
冪等性キーをPostgreSQLではなくRedisに保存できますか?
アトミックな確保についてはできます。NXフラグ付きのSETは1つのアトミックなステップでキーを確保し、insert-on-conflictパターンに対応します。Redisが提供できないのは、キーの処理結果を別の場所に保存された業務データの行と一緒にコミットする単一のトランザクションです。Redisへの書き込みとデータベースのコミットの間でクラッシュすると競合が再び開いてしまうため、キーは業務データベースに置くほうが安全です。
冪等性キーの有効期限切れ後にクライアントがリトライするとどうなりますか?
サーバーはそのリトライをまったく新しいリクエストとして扱い、再度実行するので、重複が生じる可能性があります。たとえばStripeは24時間を過ぎたキーを削除するため、その窓を過ぎてから再利用されたキーは操作を2回目に実行します。保持期間は、どのクライアント、キュー、バッチジョブでも起こりうる最長のリトライ遅延より長く設定してください。