背景
支払い処理などで問題になりがちな二重サブミット問題(Double Posting Problem)ですが、主に以下のようなケースで発生します。
- ボタンのダブルクリック
- 完了ページでリロードしてしまう
- Pull-to-Refreshしてしまう
- 以前開いていたページをフォアグラウンド復帰時にChromeが勝手にリロードしてしまう
- 戻るボタンで戻って再度ボタンを押す
- サービス設計におけるリトライ処理が不適切に行われた
今回はその防止方法について説明します。
対応方法
大まかな方針としてはユニークなトークンを発行してそれをチェックする方法です。
サーバサイドで行う場合
サーバサイドでトークンを発行するパターンです。
シーケンス図
シーケンス図はこちらです。
まず購入要求処理にてサーバ側でトークンを払い出します。
トークンにはもちろんユニーク性が求められます。詳細が知りたい方は以下をご参考ください。
ユースケースに応じたユニークなIDの生成 - Carpe Diem
また場合によってはここでレートリミットであったり在庫管理チェック処理などを挟むことで、決済確定時の処理を少なくできます。
次に確定処理でトークンをクライアントから投げてもらい、それがDBに存在するかどうかで実行済みなのかを判断します。
DBに保存されていれば過去に実行済なので、
- エラーを返す
- 何も処理を実行せずに成功を返す
のどちらかが良いでしょう。
CheckとStoreの間の整合性に気をつける
Check(read)してStore(write)する場合、その間に別の並行処理によって状態が変わっている(先にwriteされてしまう)ケースがあります。
なのでシーケンス図では分けていますが、実装ではinsert
処理によるエラー(存在しないなら成功、存在するなら失敗)でハンドリングするのが良いでしょう。
もちろんSELECT * FOR UPDATE
でreadに対する悲観ロックをかけることもできますが、それによるギャップロックの影響など考慮しないといけないことも増えるのでシンプルにinsert
による処理が良いでしょう。
クライアントサイドで行う場合(Idempotency-Key Header)
トークン(Idempotency-Key)の払い出しをクライアントサイドで行う場合はRFCのドラフトがあります。
https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
Stripe、PayPalなどの決済SaaSがこの問題を解決するための手法を提案しており、それが標準化されてきている状態です。
シーケンス図
シーケンス図はこちらです。サーバサイドと大きくは変わりません。
異なる点としてはPayload(Request Body)が同じかどうかをチェックするためにfingerprintを生成してトークンと一緒に確認する点です。
もし2回目以降のリクエストでfingerprintが異なる場合は422 Unprocessable Content
を返します。
また同一リクエストが来た際のレスポンスの挙動も次のように定義されてます。
※ドラフトなので変更される可能性があります
最初のリクエストが完了後リトライされた場合
このケースでは2回目以降のリクエストは処理自体は何もせず同じレスポンスを返すようになります。
同時リクエストだった場合
最初のリクエストが完了する前に来た場合はConflictエラーを返します。
この場合1つ目のリクエストが処理中かどうかを判定するためにIdempotency-KeyとFingerprintのレコードに状態(処理中、完了)を持たせる必要があるでしょう。
まとめ
二重サブミットを防ぐ方法をシーケンス図を交えながら解説しました。
サーバサイドで行うかクライアントサイドで行うかについては、基本的にはRFCが整って来ているクライアントサイドのIdempotency-Keyが良いでしょう。
ただし決済処理をできるだけシンプルに保ちたいようなケースでは購入要求処理に事前処理を詰め込む形でサーバサイドの実装にするのが良さそうです。