冪等性が必要な処理の見分け方と、Idempotency Keyによる実装
この記事で扱う問題
同じリクエストが2回届くことは、珍しいことではありません。
- モバイル回線が不安定で、クライアントがタイムアウト後に再送した(実はサーバーは1回目を処理済み)
- 利用者がボタンを連打した
- キューのジョブが失敗扱いになり、リトライされた(実は途中まで処理が進んでいた)
「購入する」「ポイントを付与する」「メールを送る」といった処理が2回実行されると、二重課金や二重付与になります。
判断
まず、その処理に冪等性(何回実行しても結果が1回と同じになる性質)が必要かを見分けます。
| 処理の種類 | 冪等性 |
|---|---|
値を指定した内容で上書きする(PUT で名前を変更) |
もともと冪等 |
| 削除する | ほぼ冪等(2回目は「すでにない」で済む) |
| 作成する・加算する・送信する・課金する | 冪等ではない。対策が必要 |
対策が必要な処理には、次の方法を組み合わせます。
1. Idempotency Key:クライアントが操作ごとに一意なキーを生成して送り、サーバーはまずそのキーを「処理中」として確保します。確保できたリクエストだけが処理に進み、同じキーの2回目以降は保存済みの結果を返します。
public function handle(PurchaseRequest $request): PurchaseResult
{
// キーは「誰の・どの操作の」キーかで区別する(一意制約は user_id・operation・key の組み合わせ)
$key = new IdempotencyKey($request->user()->id, 'purchase', $request->idempotencyKey());
$requestHash = hash('sha256', json_encode($request->validated()));
// 1. キーを「処理中」として先に確保する。一意制約があるので、
// 同時に来た同じキーのリクエストは、どちらか一方しか INSERT に成功しない
if (! $this->idempotency->reserve($key, $requestHash)) {
$existing = $this->idempotency->find($key);
if ($existing->requestHash() !== $requestHash) {
throw new IdempotencyKeyReused($key); // 同じキーで別の内容:422 を返す
}
return $existing->isCompleted()
? $existing->result() // 処理済み:保存した結果を返す
: throw new RequestInProgress($key); // 処理中:409 を返し、少し待って再送してもらう
}
// 2. キーを確保できたリクエストだけが処理に進む。業務の更新と結果の保存は同じトランザクションにする
try {
return $this->transaction->run(function () use ($key, $request) {
$result = $this->purchase->execute($request->toCommand());
$this->idempotency->complete($key, $result);
return $result;
});
} catch (Throwable $e) {
$this->idempotency->releaseIfPending($key); // まだ「処理中」のときだけ解放し、再送でやり直せるようにする
throw $e;
}
}
業務の更新と complete() を同じトランザクションに入れているのは、両者が別々に保存されると、その間でプロセスが止まったときに「購入は済んだのに、キーは処理中のまま」という状態が残るからです。再送は 409 を返し続け、キーを期限で消したあとに再送されると二重に処理されます。同じトランザクションなら、購入と結果の保存は必ずそろいます。
失敗時に releaseIfPending() としているのは、例外が返ってきても、実は DB 側でコミットが済んでいることがあるからです(コミットの応答を受け取る前に通信が切れた場合など)。そのときキーは complete() によって「完了」になっているので、「処理中のときだけ解放する」条件にしておけば、完了したキーを誤って消すことはありません。
なお、reserve() はこのトランザクションの外で先にコミットしておきます。キーの確保をコミットしておかないと、同時に来た別のリクエストから「処理中」が見えないからです。
キーは、クライアントが送ってきた文字列だけで区別してはいけません。別のユーザーが偶然(または意図的に)同じキーを送ると、他人の処理結果を返してしまいます。利用者と操作の種類で範囲を区切り、あわせてリクエストの内容のハッシュを保存します。同じキーで内容が違うリクエストは、再送ではなくクライアントの誤りなので、エラーにします。
ここでよくある誤りが、「キーの行を SELECT ... FOR UPDATE でロックしてから処理する」書き方です。初めて来たキーには、ロックする行がまだ存在しません。存在しない行に対するロックの振る舞いは、DB と分離レベルによって違います。
- MySQL(InnoDB)の READ COMMITTED や、PostgreSQL の READ COMMITTED / REPEATABLE READ では、何もロックされません。同時に来た2つのリクエストがどちらも「行がない」と判断し、両方が処理に進んでしまいます
- MySQL(InnoDB)の REPEATABLE READ では、行の「すき間」をロックするギャップロックがかかります。ただしギャップロックは同時に何人でも取れるので、2つのリクエストが両方ロックを取ったあと、どちらも INSERT しようとしてデッドロックになります
- PostgreSQL の SERIALIZABLE では、処理を待たせない「述語ロック」が取られます。2つのリクエストが競合すると、片方のトランザクションが serialization failure で失敗します。重複は防げますが、アプリ側がこの失敗をリトライする作りになっている必要があります
どの場合も、重複を防ぐ仕組みとしては頼りにくいか、分離レベルに依存した前提を持ち込むことになります。先に行を INSERT して一意制約で競合させる、という順序が要点です。この方法なら、DB や分離レベルに関係なく、同じキーを確保できるのは1つだけになります。
2. DB の一意制約:「同じ注文に対する付与は1回」のように業務上の一意性がある場合は、一意制約で二重登録を DB に拒否させます。アプリのチェックをすり抜けた同時実行も止められます。
3. 状態遷移のチェック:「未払い → 支払済み」の遷移は、未払いのときだけ成功させます。2回目は「すでに支払済み」として扱えます。
なぜそう判断したか
「重複をアプリで事前にチェックする」だけでは不十分です。チェックと登録の間に同じリクエストが割り込めば、両方がチェックを通過します。**最後の砦は DB の制約かロック**に置く必要があります。
Idempotency Key の確保を処理の前に行うのも同じ理由です。処理の後に結果を保存するだけでは、処理中に届いた再送を止められません。
Idempotency Key は、クライアントが「同じ操作の再送」と「新しい操作」を区別できる唯一の方法です。サーバー側だけでは、同じ内容の購入が2回来たとき、それが再送なのか、本当に2回買いたいのかを判断できません。
どこから設計過剰になるか
- 管理画面の設定変更のように、もともと上書きで冪等な処理に Idempotency Key は不要です
- 課金やメール送信のように外部サービスを呼ぶ処理は、自分の DB だけでは完全な冪等性を保証できません。外部サービスの呼び出しは成功したのに、その直後に自分の処理が失敗してキーを解放すると、再送で二重に呼び出されます。外部サービス側が Idempotency Key に対応していれば、同じキーを渡して二重実行を防ぎます。そのうえで、「処理中」のまま一定時間たったキーを定期的に拾い、外部サービスに同じキーで結果を問い合わせて、完了か失敗かを確定させる復旧の手順を用意します。外部サービスが Idempotency Key に対応していなければ、失敗時にキーを解放せず「要確認」として残し、人が確認する、という現実的な落としどころを選びます
- キーと結果の保存期間を無期限にすると、テーブルが肥大化します。再送が起こり得る期間(数時間〜数日)で消します
まとめ
- 作成・加算・送信・課金は冪等ではない。再送・連打・リトライで2回実行される前提で設計する
- Idempotency Key、DB の一意制約、状態遷移のチェックを組み合わせる
- アプリの事前チェックだけに頼らず、最後の砦は DB の制約かロックに置く