Skip to content
Published on

決済の冪等性:二重課金を防ぐ

Authors

はじめに — 二度請求される悪夢

決済システムでもっともよくある、そしてもっとも恐ろしいバグは「ユーザーが二度請求されること」です。コーヒー一杯の代金が二度引き落とされればユーザーは腹を立て、大きな金額が二度引き落とされれば信頼が崩れます。しかもこのバグは、コードに明らかなミスがなくても発生します。原因はたいていネットワークの不確実性と、それに対応する再試行です。

分散システムの根本的な真実のひとつは「ネットワークはいつか必ず失敗する」ことです。そして失敗したとき、クライアントが取れる合理的な対応は再試行です。ところが決済では、この合理的な再試行が二重課金という不合理な結果を生みえます。

この記事はこの問題に正面から取り組みます。なぜ再試行が二重課金を生むのか、それを防ぐ**冪等性(idempotency)**とは何か、冪等キーと重複排除ウィンドウとユニーク制約をどう設計するのか、at-least-once配信にdedupをどう組み合わせるのか、決済をステートマシンでどうモデル化するのか、そしてStripeのような実際のシステムがどう冪等性を実装するのかを押さえます。再試行やフロー制御、最低一度の配信といった概念を目で実験してみたければ、このサイトのメッセージキュー・プレイグラウンドを並べて開いておくとよいでしょう。

冪等性とは何か

**冪等性(idempotency)**は数学と計算機科学から来た概念です。ある演算を何度適用しても、結果が一度適用したものと同じであれば、その演算は冪等であるといいます。

日常的な例で感覚がつかめます。エレベーターの階ボタンを一度押しても五度押しても、結果は同じです。その階へ行くだけです。これが冪等です。一方、自動販売機に硬貨を入れるのは冪等ではありません。入れるたびに残高が増えるからです。

HTTPメソッドで見るともっと明確です。

  • GETは冪等です。同じ資源を何度読んでもサーバーの状態は変わりません。
  • PUTDELETEも冪等に設計されます。同じ値を何度書いても、同じ資源を何度削除しても、最終状態は同じです。
  • POSTはデフォルトでは冪等ではありません。ふつう新しい資源を作るか何らかの動作を起こすので、二度呼べば二度起こります。

問題は、決済がたいていPOSTだということです。「決済を作成する」とは本質的に副作用(お金が出ていく)を起こす非冪等な演算です。私たちがやりたいのは、この非冪等な演算を冪等にすることです。つまり「同じ決済要求を何度送っても、決済はきっかり一度だけ起こる」ようにすることです。

再試行とタイムアウトの問題

冪等性がなぜ必要かを理解するには、再試行がどう二重課金を生むのかを正確に見なければなりません。核心はタイムアウトの曖昧さです。

クライアントがサーバーに決済要求を送り、応答を待ちます。ところがタイムアウトが起きました。このときクライアントが確実に知っていることはただ一つ、「定めた時間内に応答が来なかった」という事実だけです。実際に何が起きたのかは複数ありえます。

  タイムアウトが起きたとき、実際に起こっていたかもしれないこと:

  ケースA: 要求がサーバーに届かなかった        -> 未課金。  再試行すべき。
  ケースB: サーバーは処理したが応答が失われた   -> 課金済み。再試行してはいけない。
  ケースC: サーバーがまだ処理中で終わっていない -> 進行中。  再試行すると競合の危険。

問題の本質はここにあります。クライアントはA、B、Cを**区別できません。**応答がないという事実だけでは、決済が起きたのかどうか知りようがありません。

このときクライアントが取れる選択は二つです。再試行しなければ、実際にはケースAだったのに諦めてしまい決済を取りこぼしうる(ユーザーは決済しようとしたのにできなかった)。逆に再試行すれば、実際にはケースBだったのに送り直して二重課金になる。

だから冪等性がなければ、このジレンマから抜け出せません。冪等性がこの結び目をほどきます。「再試行しても安全」を保証すれば、クライアントは安心して再試行でき、ケースAは正常に処理され、ケースBの重複は取り除かれます。つまり冪等性は再試行を安全にする仕組みです。

冪等キー — 要求に名札をつける

冪等性を実装する標準的な方法が**冪等キー(idempotency key)**です。考え方は単純です。クライアントが各決済要求に一意の識別子をひとつ付けて送り、サーバーはそのキーを基準に「この要求を前に見たことがあるか」を判断します。

  POST /v1/charges
  Idempotency-Key: 9f2c1a7e-...-b3   <- クライアントが生成した一意のキー
  { "amount": 5000, "currency": "jpy", "source": "tok_..." }

サーバーの処理規則はこうです。

  要求を受信したとき:
  1) この冪等キーを前に見たことがあるか?
     - ない -> 決済を実際に処理し、(キー -> 結果)を保存してから結果を返す
     - ある -> 保存しておいたその結果をそのまま返す(決済を再度行わない)

この単純な規則のおかげで、クライアントが同じキーで何度再試行しても決済はきっかり一度だけ起こります。最初の要求だけが実際の決済を実行し、以後の再試行は保存された最初の結果の「コピー」を受け取ります。

ここで、キーを誰が、どう作るかが重要です。

  • キーはクライアントが生成します。そうすれば再試行のとき同じキーを送り直せます。サーバーがキーを作ると、クライアントは再試行時にどのキーを使えばよいか分かりません。
  • キーはひとつの論理的な作業にひとつずつ対応すべきです。たとえば「カートXを決済する」というひとつの作業にキーをひとつ与え、その作業のすべての再試行に同じキーを使います。UUIDのような十分にランダムな値を使えば、偶然の衝突を心配せずに済みます。
  • ユーザーが決済ボタンを再び押して新しい作業を意図した場合は新しいキーを作らなければなりません。同じキーを再利用すると、システムはそれを「再試行」とみなして新しい決済を止めてしまいます。この点をUXとうまく合わせることが重要です。

重複排除ウィンドウと保存

冪等キーを保存するには「いつまで」保存するかを決めなければなりません。これが**重複排除ウィンドウ(dedup window)**です。キー-結果の記録を永遠に保管はできないので、通常は一定期間(たとえば24時間)だけ保持します。

ウィンドウの長さを決める基準は「再試行が現実的にどれくらいの時間範囲で起こるか」です。ネットワークエラーによる再試行はたいてい秒・分の単位で起こります。しかしクライアントがしばらくオフラインだった後にあとで再試行する場合まで見込んで、実務では一日ほどを余裕をもって取ることが多いです。Stripeは冪等キーを24時間保持します。

保存先そのものは、速い参照と失効(TTL)をサポートするものがよいです。よくある選択は次のとおりです。

  • リレーショナルDBの専用テーブル: 決済の台帳データと同じトランザクションのなかで扱えるので整合性がよいです。あとで見るユニーク制約と相性がよいです。
  • Redisのようなインメモリ保存 + TTL: 非常に速く、失効が自動です。ただし台帳DBとは別の保存先なので、両者の間の整合性(たとえばキーは記録されたのに決済トランザクションは失敗)を慎重に扱わねばなりません。

何を使うにせよ、保存する値が単なる「このキーを見た」フラグではなく最初の要求の結果全体であるべき、という点が重要です。再試行したクライアントに、元の応答とまったく同じもの(同じ決済ID、同じ状態、同じ金額)を返さねばならないからです。

ユニーク制約 — データベースに委ねる安全装置

冪等キーを「まず参照し、なければ挿入する」方式だけで実装すると、微妙な競合状態が残ります。同じキーを持つ二つの要求がほぼ同時に到着すると、両方が「このキーはないな」と判断したあと、両方が決済を進めうるのです。参照と挿入の間の隙間が問題です。

この隙間を根本的にふさぐもっとも堅牢な道具が、データベースの**ユニーク制約(unique constraint)**です。冪等キーの列にユニーク制約をかけておけば、二つの要求が同じキーで同時に挿入を試みても、データベースがそのうち一つだけを成功させ、残りを拒否します。「同時にきっかり一つだけ」をデータベースが原子的に保証してくれるのです。

CREATE TABLE payment_requests (
  idempotency_key TEXT PRIMARY KEY,   -- ユニーク制約: 同じキーの重複挿入は不可
  status          TEXT NOT NULL,      -- 'in_progress' | 'succeeded' | 'failed'
  response_body   JSONB,              -- 最初の要求の結果全体を保存
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

この制約を活かす流れはおおよそこうです。

  1) このキーで 'in_progress' の行を INSERT しようとする
     - 成功したら -> 自分がこの作業の「所有者」。決済を実際に処理し、結果で行を UPDATE
     - ユニーク違反で失敗したら -> 別の要求がすでにこのキーを取ったという意味
         -> 既存の行を参照:
            - すでに完了していれば保存された結果を返す
            - まだ in_progress なら少し待って再確認、または「処理中」応答

核心は「先に席を取る」行為そのものを、ユニーク制約のあるINSERTにすることです。参照してから挿入する隙間が消えるので、同時に来た再試行の間でもきっかり一つだけが決済を実行します。台帳データと同じトランザクションでこの行を扱えば、決済記録と冪等記録が常に一緒にコミットされ、整合性が保証されます。SQLの制約とトランザクションを直接実験してみたければ、この種のスキーマと制約はこのサイトのSQL・Postgresプレイグラウンドでも試せます。

決済をステートマシンでモデル化する

冪等性をきちんと扱うには、決済を単純な「成功/失敗」の二値ではなく**ステートマシン(状態機械)**でモデル化するとよいです。決済は複数の中間状態を経て、各状態で再試行やコールバックがどう作用すべきかが異なるからです。

典型的な決済状態の流れはこうです。

  created ──▶ authorizing ──▶ authorized ──▶ capturing ──▶ captured
     │            │                              │
     │            ▼                              ▼
     │         failed                          failed
  canceled
                (別の流れ) captured ──▶ refunding ──▶ refunded

各状態の意味を短くまとめるとこうです。

  • created: 決済の意図は作られたがまだオーソリ前。
  • authorizing: オーソリ要求をPSP/イシュアへ送った状態(応答待ち)。
  • authorized: オーソリ完了、枠をホールド。まだキャプチャ前。
  • capturing / captured: キャプチャ進行/完了。
  • failed / canceled / refunded: 終了状態。

ステートマシンが冪等性に決定的に役立つ理由は、各状態で許される遷移を明確に定義できるからです。たとえばすでにcapturedの決済にキャプチャ要求がまた来たら、それは再試行なので再度キャプチャせず、現在の状態をそのまま返します。authorizing状態で同じ要求がまた来たら「まだ処理中」だと分かります。つまりステートマシンは「この要求は今の状態で有効なのか、それともすでに処理された再試行なのか」を判断する根拠になります。

また状態を明示的に保存するとクラッシュ復旧が容易になります。authorizing状態で止まっている決済があれば、システムはPSPにその取引の実際の状態を問い合わせ、authorizedなのかfailedなのかを確認して状態を訂正できます。こうした問い合わせをよく整合合わせ(reconciliation)や状態同期と呼びます。

at-least-once配信にdedupを足す

冪等性は決済APIだけでなく、決済システムのあちこちの非同期メッセージでも核心です。決済イベントはしばしばメッセージキューを通って流れ(たとえば「決済成功」イベントが精算・通知・会計サービスへ伝播)、ほとんどのキューは**at-least-once(最低一度)**配信を保証します。

at-least-onceの意味を正確に見ましょう。キューはメッセージが「少なくとも一度」配信されることを保証しますが、「ちょうど一度」は保証しません。つまり同じメッセージが二度以上配信されうるのです。なぜなら、消費者がメッセージを処理したあと「処理完了(ack)」をキューへ知らせる途中で障害が起きると、キューはそのメッセージが処理されたか分からず、安全のために再送するからです。先に見た決済APIのタイムアウト問題とまったく同じ構造です。

  消費者が「決済成功」イベントを受信
     -> 処理(例: 残高を付与、レシートを送信)
     -> ack を送る途中で障害
        -> キューは処理の有無を知らない -> 再配信
           -> 同じイベントが二度処理される危険

ここで「ちょうど一度(exactly-once)」を配信層で本当に保証するのは非常に難しいです。実務の標準的な解法はat-least-once配信 + 消費者側のdedupです。つまりキューは最低一度の配信だけを保証し、消費者が「このメッセージをすでに処理したか」を自ら確認して重複を取り除くのです。これをよく**冪等な消費者(idempotent consumer)**と呼びます。

実装は決済APIの冪等性と同じ原理です。各メッセージに一意のIDを付与し、消費者は処理したメッセージIDを保存しておきます。新しいメッセージが来たらそのIDがすでに処理済みリストにあるか確認し、あれば静かに無視します。ここでもユニーク制約が頼もしい安全装置になります。「処理したメッセージID」をユニーク列に挿入しようとして失敗すれば、それはそのまま「すでに処理した」という信号です。キューのat-least-once、ack、再配信の動きを目で確かめたければ、メッセージキュー・プレイグラウンドで直接触れます。

Stripe流の冪等性

ここまでの断片が実際の製品でどう合わさるのか、Stripeの冪等性設計を例にまとめてみましょう。Stripeは冪等性をAPIの一級機能として公開しており、その規則はこの記事で扱った原理をほぼそのまま踏襲しています。

Stripeの冪等性の核心的な規則はこうです。

  • クライアントがIdempotency-Keyヘッダーに一意のキーを入れて要求します。ふつうUUID v4のようなランダムな値を使います。
  • 同じキーで要求がまた来ると、Stripeは最初の要求の結果をそのまま再生(replay)して返します。決済は再度起こりません。
  • 冪等キーは24時間保持されます。それ以降、同じキーは新しい要求として扱われます。
  • 最初の要求がまだ処理中のときに同じキーで同時要求が来ると、エラー(409系の応答)を返して競合を防ぎます。クライアントは少しあとで再試行すればよいです。
  • 同じキーを異なる要求本文で送ると、Stripeはこれを誤用とみなしてエラーを出します。ひとつのキーはひとつの要求にだけ対応せねばならないからです。
  1回目の要求:  Idempotency-Key: K, body: {amount: 5000}
                -> 決済を実行、結果を K に記録、結果を返す

  再試行:      Idempotency-Key: K, body: {amount: 5000}   (同じ本文)
                -> 記録された結果をそのまま再生、決済は起こらない

  誤用:        Idempotency-Key: K, body: {amount: 9999}   (異なる本文)
                -> エラー(ひとつのキーに異なる要求は許さない)

この設計から学ぶべき実務指針をまとめるとこうです。クライアントは再試行時に必ず同じキーを送ること、ひとつの論理的な作業にはひとつのキーだけを使うこと、そして新しい決済を意図するときだけ新しいキーを作ること。サーバーはキーを結果とともに保存し、ユニーク制約で同時性を防ぎ、適切なdedupウィンドウを設けることです。

実務チェックリスト

冪等性を決済システムに導入するとき確認することを圧縮します。

  • すべての状態変更要求は冪等であるべき。 決済作成、キャプチャ、返金、取消はすべて再試行可能であるべきです。読み取り(GET)はもともと冪等ですが、お金を動かすすべてのPOSTに冪等キーを要求してください。
  • キーはクライアントが生成し、再試行時に再利用する。 サーバーがキーを作ると再試行の意味が失われます。
  • ユニーク制約で同時性を防ぐ。 参照してから挿入するだけでは競合状態が残ります。データベースのユニーク制約が最後の防衛線です。
  • キーとともに結果を保存する。 フラグだけ保存すると、再試行したクライアントに元の応答を返せません。
  • 決済をステートマシンでモデル化する。 各状態で有効な遷移を定義すれば、再試行とクラッシュ復旧が明確になります。
  • 非同期イベントには冪等な消費者を置く。 at-least-onceキューを前提とし、消費者がメッセージIDで重複を取り除きます。
  • 適切なdedupウィンドウを決める。 短すぎると遅い再試行を取りこぼし、長すぎると保存コストと正常な再利用との衝突リスクが増えます。24時間はよくある出発点です。

おわりに

二重課金はコードの明らかなバグではなく、「ネットワークは失敗し、失敗したら再試行する」という分散システムの根本的な性質から自然に流れ出る問題です。タイムアウトは決済が起きたのかどうかを曖昧にし、その曖昧さのなかで再試行が重複を生みます。

冪等性はこの結び目をほどく鍵です。クライアントが一意のキーで要求に名札をつけ、サーバーがそのキーで重複を取り除いて結果を再生すれば、何度再試行しても決済はきっかり一度だけ起こります。そこにユニーク制約で同時性をロックし、ステートマシンで流れを明確にし、非同期経路には冪等な消費者を置けば、「再試行しても安全」な決済システムが完成します。

核心を一文でまとめるとこうです。**再試行をなくそうとするな、再試行を安全にせよ。**分散システムで再試行は避けられません。だから再試行が害にならないよう、すべての決済を冪等に設計することが正解です。

参考資料