作成APIを呼び出した後、応答が届かないままタイムアウトしたとします。クライアントには失敗に見えても、サーバーではすでにデータの登録が完了しているかもしれません。そのまま再送すると、同じ注文や申請が二つ作られるおそれがあります。
この問題への対策が、同じ論理操作を識別する冪等キーです。この記事では、Idempotency-Keyを使うAPIの契約と、MySQLの一意制約、Springのトランザクション、保存済み結果の再返却を組み合わせる設計を説明します。対象はHTTPクライアントの再送であり、定期ジョブの起動制御や、異なる編集操作の競合検知とは分けて考えます。
HTTP再送で起きる二重処理と冪等キーの役割
処理が成功してもクライアントに応答が届かない場合がある
HTTP通信の成否と、DB更新の成否は別です。サーバーがコミットした直後に接続が切れると、DBには注文が存在する一方で、クライアントは注文IDを受け取れません。タイムアウトしたという事実だけでは、業務処理が実行されなかったと判断できません。
そこで、最初の要求と再送に同じキーを付けます。サーバーはそのキーに対応する処理結果を保持し、処理が完了済みなら新しい注文を作らず、保存した結果を返します。目的は、要求を一度しか受信しないことではなく、同じ操作の再送で業務結果が重複して増えないことです。
定期ジョブの排他制御・楽観ロックとの違い
| 仕組み | 主に判定すること | 今回の問題との関係 |
|---|---|---|
| 冪等キー | 同じ論理操作の再送か | 完了済みの操作を再実行せず、結果を返す |
| ジョブの排他制御 | 別の実行者が同時に動いているか | 時間をずらしたHTTP再送の識別には別の記録が必要 |
| 楽観ロック | 読み込み時のversionが現在も一致するか | 異なる操作による古い状態からの更新を検知する |
定期ジョブが複数台で重なって動く問題は、起動経路と実行権の管理から確認します。HTTP再送とは入口が異なるため、その対策を調べている場合は次の記事が対応します。

更新APIでは、冪等キーと楽観ロックを併用することもあります。同じ要求の再送には保存済み結果を返し、新しいキーで届いた別の更新にはversionを照合する、という役割分担です。

Idempotency-Keyの契約を決める
同じ論理操作の再送では同じキーを使う
クライアントは操作を開始するときにキーを生成し、その操作の入力と一緒に保持します。タイムアウト後の再送でも同じキーを使い、利用者が別の注文を作成するときには新しいキーを生成します。HTTP送信のたびにキーを作り直すと、サーバーからは別操作に見えてしまいます。
画面の二重クリックにも備えるなら、一つの送信操作に割り当てたキーを共有する必要があります。各クリックで新しいキーを発行する設計では、冪等キーだけで重複を防げません。クライアントの再起動後も再送できる仕様なら、キーと送信内容を必要な期間保存します。
この記事のIdempotency-Keyは、APIごとに契約を決めて使う設計パターンです。ヘッダー形式、応答コード、保持期間は、対象のAPIの契約に合わせます。この例ではキーを必須とし、英数字・ハイフン・アンダースコアからなる1〜128文字を受け入れる契約にします。
認証済みの利用者・テナント・操作種別でスコープを区切る
キーだけを全利用者で共有する識別子にすると、偶然同じキーを送った別の利用者まで衝突するおそれがあります。本稿では、tenant_id、actor_id、operation、idempotency_keyの組み合わせを同一操作の識別に使います。操作種別の例はorders.create.v1です。
テナントと利用者は認証・認可処理で確定し、操作種別はサーバー側の入口から決めます。本文や任意ヘッダーで受け取ったテナントIDを、そのまま信頼してはいけません。トークンの更新でスコープが変わらないよう、アクセストークン自体ではなく安定した利用者識別子を使います。
複数担当者が同じ操作を引き継ぐAPIでは、利用者単位のスコープが適さない場合もあります。その場合はテナント単位などへ設計を変え、保存済み結果を誰が閲覧できるかを別途認可します。キーを知っていることは、結果を閲覧できる権限の証明ではありません。
キーを入力payloadと結び付けて内容の違う再送を拒否する
同じキーで数量1の注文を作成した後、数量2の要求が届いた場合、その要求を最初の注文の再送として受け入れてはいけません。キーには入力の照合情報も保存し、内容が異なればキー再利用エラーとして拒否します。本文のハッシュは照合用であり、操作を識別するキーの代わりにはしません。同じ商品を同じ数量で二度注文する、別々の操作もあり得るためです。
受信したJSON文字列をそのままハッシュ化すると、空白やプロパティ順の違いでも不一致になります。反対に、安易に文字を小文字化したり配列を並べ替えたりすると、業務上異なる入力を同じものにしてしまいます。何を同じ入力とみなすかを先に決めます。
この注文APIの例では、必須項目をclientOrderRef、sku、quantityの三つに限定します。前二つは長さを制限したASCIIの英数字・ハイフン・アンダースコア、数量は1〜100の整数とし、未指定・null・未知の項目・重複したJSONプロパティ名を拒否する契約にします。文字列は大文字・小文字を区別し、空白除去などの変換はしません。
検証後の値を、操作種別、正規化規則の版、注文参照、SKU、数量の順に、改行で区切ってUTF-8へ変換し、SHA-256で32バイトの照合値を生成する設計例です。入力文字列には改行を許可せず、数量は先頭ゼロのない10進表記、最後にも改行を付けると規則を固定します。JSONのプロパティ順や空白には依存しません。業務処理にも、この照合に使った検証済みの値を渡します。
APIを拡張する場合は、結果に影響するパス、クエリ、通貨、更新対象のversionなども照合対象に含めます。規則を変更した後も保持期間内の古い要求を照合できるよう、規則の版を保存します。ハッシュは暗号化ではないため、値の候補が少ない機密情報を安全に隠せるものとは考えません。
MySQLの一意制約と処理記録を設計する
スコープとキーの複合UNIQUE制約で同時到着に備える
事前にSELECTして「記録なし」と分かっても、その直後に別の要求が同じキーを登録する可能性があります。重複防止の最終判定はDBの一意制約に任せます。次はInnoDBを前提とするDDL例です。
CREATE TABLE api_idempotency (
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
actor_id BIGINT NOT NULL,
operation VARBINARY(64) NOT NULL,
idempotency_key VARBINARY(128) NOT NULL,
fingerprint_version SMALLINT NOT NULL,
request_hash BINARY(32) NOT NULL,
state VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
response_status SMALLINT NULL,
response_content_type VARCHAR(128) NULL,
response_location VARCHAR(512) NULL,
response_body MEDIUMBLOB NULL,
created_at DATETIME(6) NOT NULL,
completed_at DATETIME(6) NULL,
expires_at DATETIME(6) NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_api_idempotency_scope_key
(tenant_id, actor_id, operation, idempotency_key),
KEY ix_api_idempotency_expiry (expires_at)
) ENGINE=InnoDB;複合UNIQUE制約の各列はNOT NULLです。NULLを含む組み合わせが重複排除の抜け道にならないようにします。操作種別とキーはASCII文字列をバイト列に変換して保存し、VARBINARYで比較する設計です。大文字・小文字や末尾空白の比較を文字列の照合順序に委ねず、許可文字と長さは登録前に検証します。範囲を超えた値を切り詰めて保存してはいけません。
文字列型で設計する場合は、採用する照合順序の比較規則まで確認してください。索引長の制限を含む採用環境での確認先は、MySQLのPRIMARY KEY・UNIQUE制約に関する資料です。
入力の照合情報・処理状態・保存済み結果・期限を保持する
このテーブルは、キーの存在だけでなく、再送に何を返すかまで保持します。主例では、登録時にPROCESSING、結果保存時にCOMPLETEDへ変更します。ただし両方を一つのトランザクションで行うため、未コミットのPROCESSING行が別の要求の通常のSELECTで見えることを前提にしません。
| 情報 | 用途・保存ルール |
|---|---|
| 照合規則の版と入力ハッシュ | 同じキーで入力が変わっていないかを調べる |
| 処理状態 | 保存済み結果を返せる状態かを判定する |
| HTTPステータス・本文 | 最初に確定した結果を再返却する |
| Content-Type・Location | このAPIで再返却に必要なヘッダーだけを保存する |
| 作成・完了・失効時刻 | 保持期間と削除対象を管理する |
例では、最初に返すJSON本文をバイト列にして保存します。再送時に現在の注文データからJSONを作り直すと、後日の更新やシリアライズ設定の変更で結果が変わるためです。ステータス201、注文IDを含む本文、Locationを保存するなら、再送にもその組み合わせを返す契約にします。
認証トークンやSet-Cookie、通信ごとに生成するトレース情報まで複製する必要はありません。保存するヘッダーを許可リストで限定し、本文の容量制限、閲覧権限、保存期間を決めます。DDLのNULL可否だけでは「完了なら結果が必須」という条件を表し切れていないため、保存処理と読み出し時に整合性を検証します。
業務更新と冪等性の記録を同じトランザクションで確定する
DB内で完結する短い処理なら、キー登録、注文作成、応答の生成と保存を同じトランザクションに含めます。途中で失敗した場合は全体をロールバックし、成功した場合だけ業務データと完了記録を一緒に確定します。
「注文だけコミットされて、再送を判定する記録がない」という隙間を作らないことが、この方式の中心です。キーの登録だけを別途確定する方式とは、障害後に残る状態が異なります。
各Repositoryが同じデータソースとトランザクション管理に参加することが前提です。途中の注文保存だけを別トランザクションにしたり、非同期スレッドへ逃がしたりすると、この一体性は失われます。応答本文の生成が失敗した場合もコミットしないようにし、HTTPへの書き出しはコミット完了後に行います。
Springのトランザクション境界やロールバックの基礎を先に整理したい場合は、次の記事も参考になります。

Spring Bootで受付から保存済み結果の返却までを組み立てる
Controllerで入力を検証しServiceへ認可済みスコープを渡す
Controllerでは認証、操作の認可、キーの形式、JSONの型と業務上の入力制約を確認します。その後、認可済みスコープと検証済み入力をServiceへ渡します。キー未指定、複数のキー値、空文字、上限超過なども、曖昧に受け入れず契約に沿って拒否します。
次は呼び出し順を示すJavaの抜粋です。認可処理、入力型、保存済み応答型、HTTP変換、例外ハンドラーは、アプリケーション側で別途実装します。
@PostMapping("/api/orders")
public ResponseEntity<byte[]> create(
@RequestHeader("Idempotency-Key") String key,
@RequestBody CreateOrderRequest request,
Authentication authentication) {
AuthorizedScope scope = authorization.requireOrderCreation(authentication);
keyPolicy.validate(key);
ValidatedOrder command = inputPolicy.validate(request);
StoredResponse response = coordinator.execute(scope, key, command);
return response.toHttpResponse();
}JSONの重複プロパティ名や型変換の許可は、DTOの検証だけでは扱い切れないため、デシリアライザー側の設定も契約に合わせます。Serviceを別の入口から使う場合も、同じ検証・認可を通る構成にします。
保存済み結果を返す場合も、現在の認証・認可を確認します。権限を失った利用者に、過去に成功したという理由だけで注文の詳細を返してはいけません。結果固有の閲覧制限があるなら、その確認も再返却の前に行います。
新規キーの登録に成功した要求だけが業務処理へ進む
Serviceは最初に処理記録をINSERTします。INSERTが成功した要求だけが注文作成へ進みます。事前SELECTを高速化のために追加する場合でも、記録がなかったことを実行権の獲得とみなさず、INSERTによる一意性の判定を残します。
以下は、別Beanのメソッドで書き込み境界を作る例です。HTTP入口のCoordinatorはトランザクションを持たず、このメソッドをSpringのプロキシ経由で呼び出す前提です。Repositoryや応答生成処理の実装は省略しています。
@Transactional(rollbackFor = Exception.class)
public StoredResponse createOnce(
AuthorizedScope scope,
String key,
ValidatedOrder command,
RequestFingerprint fingerprint) {
long recordId = records.insertProcessing(scope, key, fingerprint);
long orderId = orders.insert(scope, command);
StoredResponse response = responses.created(orderId);
int updated = records.complete(recordId, response, retentionPolicy);
if (updated != 1) {
throw new IllegalStateException("Unexpected completion count");
}
return response;
}completeは記録IDとPROCESSING状態を条件に、完了状態・応答・完了時刻・失効時刻を保存する想定です。主例の保持期限は完了時刻から計算し、期間そのものはAPI要件で決めます。正常にメソッドが戻っても、プロキシによるコミットで失敗する可能性があるため、呼び出し元へ正常復帰したところまでを成功判定の境界にします。
同一クラス内の直接呼び出しや、外側のトランザクションへ意図せず参加する構成では、想定した境界にならない場合があります。入口からの呼び出し経路を確認し、Springのトランザクション資料で採用構成の適用条件とロールバック設定を確認してください。
重複キー例外はトランザクション境界の外で扱う
キー登録の重複は書き込みトランザクションから例外として外へ出し、ロールバックが完了した後で保存済み記録を取得します。重複例外をメソッド内で握りつぶし、同じトランザクションでSELECTして処理を続ける構成にはしません。
// Coordinatorには@Transactionalを付けず、外側のTXもない前提
public StoredResponse execute(
AuthorizedScope scope, String key, ValidatedOrder command) {
RequestFingerprint fingerprint = fingerprints.create(command);
try {
return writer.createOnce(scope, key, command, fingerprint);
} catch (IdempotencyKeyAlreadyRegistered ex) {
StoredRecord saved = resultReader.findInFreshTransaction(scope, key);
if (saved == null) {
throw new OutcomeUnresolvedException();
}
fingerprints.requireMatch(saved, command);
authorization.requireReplayAccess(scope, saved);
return replayPolicy.requireCompletedResponse(saved);
}
}IdempotencyKeyAlreadyRegisteredは独自の実行時例外です。insertProcessingで発生した、対象のuq_api_idempotency_scope_key違反だと確認できた場合だけ変換する契約にします。注文側の一意制約違反、外部キー違反、デッドロック、ロック待機タイムアウトまで同じ例外へ変換してはいけません。識別できないエラーは通常の障害として扱います。
DB例外から制約を識別する方法は、利用する永続化方式とドライバに合わせて実装・検証します。例外クラス名だけで全制約違反を重複再送と決めつけないことが重要です。また、InnoDBで失敗した文が取り消される範囲と、Springがトランザクション全体をロールバックするかどうかは別の判断です。確認先はInnoDBのエラー処理資料です。
findInFreshTransactionは、書き込み側の失敗したトランザクションを再利用せず、別の参照境界で権威あるDBから読む想定です。遅延するレプリカや古いキャッシュだけで判定しません。削除との競合などで行が見つからなくても、このcatch分岐から注文作成へ進まず、結果未確認として照会や同じキーでの後続確認へつなぎます。
同時到着時の待機・処理中応答・完了結果の再返却を分ける
同じキーが同時に届いた場合、後続のINSERTは先行トランザクションの終了を待つことがあります。先行がコミットすれば重複として完了結果を取得し、先行がロールバックすれば後続がINSERTに成功して実行担当になる場合があります。実際にはデッドロックなどもあり得るため、すべてがこの二つの正常経路に収まるとは限りません。
主例では未コミットのPROCESSING行を通常のSELECTで取得できるとは考えません。したがって、「行が見えないので処理中ではない」「ロック待ちになったので必ず同じ要求が処理中だ」とも断定できません。
| 状況 | 設計例での扱い |
|---|---|
| 完了記録があり、入力も一致 | 保存したステータス・本文・必要なヘッダーを返す |
| 同じキーだが入力が不一致 | 409など、契約で定めたキー再利用エラーを返す |
| DB待機が上限を超え、完了を確認できない | 503などと専用のエラーコードで一時的な結果未確認を示す |
| 別方式で永続的な処理中記録を確認できた | 処理中の応答と、結果確認方法を契約に従って返す |
表のHTTPステータスは選択例です。ロック待機時間、トランザクションの時間制限、HTTP応答の制限、クライアントの再送間隔を合わせて決めます。待機が終わらないまま要求を増やし続ける設計を避け、再確認する場合も同じキーを使うよう案内します。202を使うなら、操作を受け付けたことと結果確認先を説明できる仕組みが必要です。
失敗・結果不明・保持期限切れの扱いを決める
入力エラー・ロールバック・コミット結果不明を区別する
主例は成功結果を保存し、業務処理の例外では全体をロールバックする方針です。ただし、クライアントにエラーを返したという事実だけで、ロールバック済みと判定してはいけません。
| 分類 | 記録・業務更新 | 次の対応 |
|---|---|---|
| 認証・認可・入力のエラー | キー登録前に拒否する | 原因を修正する。入力を変える要求を再送と混同しない |
| 全体のロールバックを確認できた失敗 | 主例ではキー記録と業務更新が残らない | 同じキー・同じ入力で再試行できるかを契約で決める |
| コミット中の切断などによる結果不明 | 確定している可能性と未確定の可能性がある | DBの状態を照会・照合し、新しいキーで作り直さない |
| コミット後のHTTP応答喪失 | 業務更新と完了記録が残る | 同じキーの再送に保存済み結果を返す |
結果不明の場合は、DBが再び確認可能になってから完了記録と業務データを照合します。同じキーで通常の受付経路へ戻す場合も、同じDBの一意制約による判定を通します。照会が一度空だっただけで、別のキーによる新規操作へ切り替えてはいけません。
在庫不足などの確定した業務上の拒否を保存する方式もあります。その場合は、業務更新を行わず拒否結果を記録してコミットし、同じキーでは状況が変わっても同じ拒否結果を返す、といった契約が必要です。インフラ障害まで一律に永久保存する方針とは分けて決めます。
処理中状態を先にコミットする方式では復旧条件を設ける
長い処理では、受付とPROCESSING状態を先にコミットし、後から業務処理を行う設計もあります。他の要求から処理中状態を参照できますが、担当プロセスが停止すると、その状態だけが残ります。主例に単にコミットを一つ追加するだけでは、安全な復旧方式になりません。
この方式では、所有者ID、リース期限、世代番号、更新時刻、結果照会に必要な業務識別子などを持たせます。引き継ぎは期限と現在の世代を条件にした原子的な更新で行い、獲得できた担当だけが処理を進めます。延長と引き継ぎが同時に起きる場合も、更新件数で取得の成否を判定します。
期限が切れても古い実行者が止まるとは限りません。完了記録だけでなく、業務更新側でも世代番号などを検証し、古い担当者の書き込みを拒否する必要があります。処理途中の確定内容を照合し、再開可能な地点を判断する手順も用意します。外部APIへの送信はDB内の世代比較だけでは止められないため、下流側の冪等性も必要です。
保持期限後の再送とキー削除による再実行を考える
キー記録を削除すると、同じキーを再びINSERTできるようになります。つまり保持期限は、再送を同一操作として認識できる期間に関わります。クライアントが再送し得る期間、オフラインからの復帰、障害復旧に要する時間を含めて決めます。
期限を過ぎた記録を残したまま拒否するのか、削除後は新規操作として受け入れるのかを明示します。削除済みなら、そのキーが過去に使われたかどうかをキー記録だけでは判定できません。期限後の再利用を拒否したい場合は、別の拒否用記録や業務識別子を保持する設計が必要です。主例の削除対象は期限を過ぎた完了記録に限定し、復旧待ちの処理中記録を同じ規則で消しません。
注文を二つ作ってはいけない条件がキー保持期間を超えて続くなら、注文側にも業務上の一意性を設けます。例のclientOrderRefをテナント内の注文参照として一意にする方法です。これにより、キーを変えた再送や期限後の再送にも備えられます。ただし、別の冪等キーで注文参照が衝突した場合に既存注文を返すか拒否するかは、入力照合と認可を含めて別途決めます。
外部APIの副作用とアウトボックスの保証範囲を整理する
DBトランザクションだけでは外部処理の一度限り実行を保証できない
注文作成の途中で決済APIを呼び出すと、DB内で完結する主例の前提から外れます。外部決済が成功した後にDBがロールバックしても、決済まで取り消されるとは限りません。外部サービスからの応答が失われれば、決済済みかどうかも呼び出し側だけでは分かりません。
@Transactionalで囲むことは、外部APIの一度限り実行の保証にはなりません。結果不明の外部操作を新しいキーで繰り返す前に、下流の冪等キー、結果照会、業務識別子による照合で確認する必要があります。
下流が冪等キーに対応する場合は、同じ外部操作の再試行で同じ下流キーを使います。一つの注文から決済と通知を行うなら、それぞれの操作を区別したキーを管理します。下流での保持期間を超えた結果不明の操作は、同じキーなら安全だと決めつけず、照会や人による確認へ回す条件を設けます。
アウトボックスと下流側の重複排除を組み合わせる
アウトボックス方式では、注文の保存と「外部へ送るべき要求」の保存を同じDBトランザクションに含めます。別の配送処理がその記録を読み、外部APIやメッセージ基盤へ送ります。業務更新が確定したのに送信要求が残らない、という隙間を防ぐための設計です。
ただし、送信成功後、送信済みの記録を保存する前に配送処理が停止すると、同じ要求が再送されます。送信前に送信済みへ変更すると、今度は停止時に未送信の要求を失うおそれがあります。アウトボックスだけでは、一度限りの配送を保証できません。
配送の再試行では固定のイベントIDや下流キーを使い、受信側で重複を排除します。受信側がDBを持つなら、受信済みIDの登録と業務更新を同じトランザクションに含める設計が候補です。送信試行回数ではなく、下流で確定した業務結果が重複していないかを確認します。
非同期処理へ変える場合は、HTTP応答の意味も変わります。「外部決済完了」と「決済要求を保存して受付完了」を区別し、保存済み応答がどちらを表すかを決めます。必要なら、受付時の応答とは別に現在の処理状態を照会するAPIを用意します。
Stripeの冪等リクエストをサービス固有の契約例として読む
外部サービスの契約を確認する資料として、StripeのIdempotent requestsがあります。自分のAPIへそのまま規則を移すのではなく、対象の操作でどの要求が保存・再返却されるかを確認するために読みます。
確認したいのは、キーの形式と長さ、対象のリクエスト、保持期間と削除条件、同じキーで異なるパラメータを送った場合、同時要求の場合、入力検証で拒否された場合、処理開始後の失敗の場合の扱いです。特に「エラー応答も保存されるか」と「保存処理が始まる条件」は分けて確認します。
保持時間や失敗応答の保存条件は、導入時に確認する必要があります。これらはStripe固有の契約として扱い、一般的なIdempotency-Keyの必須仕様とは区別します。
再送・同時到着・障害時の検証観点
以下の観点で、HTTP経由の確認と、採用するMySQL・ドライバ・Spring構成での結合テストを用意します。Repositoryのモックだけでは、一意制約の競合やコミット時の挙動は確認できません。
同じキーの再送・異なるpayload・別スコープを確認する
| 条件 | 主例で確認すること |
|---|---|
| 同じスコープ・キー・入力を順に送る | 注文は一つで、保存したステータス・本文・Locationが返る |
| 同じキーで数量を変える | 入力不一致となり、追加の注文や更新が発生しない |
| JSONの空白・プロパティ順だけを変える | 同じ照合値になり、保存済み結果が返る |
| 同じキーを別テナント・別利用者・別操作で使う | 契約どおり独立し、他スコープの結果が返らない |
| 完了後に利用者の権限を取り消す | 再返却でも認可されず、保存本文が漏れない |
| キーを変え、注文参照を同じにする | 業務側の一意制約と、別途決めた応答契約が働く |
同時到着は、先行要求をキー登録後・コミット前で一時停止させてから後続を送ると、競合を作りやすくなります。先行をコミットする場合とロールバックする場合を分け、待機上限を超えるケースも確認します。複数プロセスから同じDBへ接続し、JVM内の制御だけに依存していないことも調べます。
コミット前後の停止・応答喪失・期限切れを確認する
| 障害を入れる位置 | 確認する状態 |
|---|---|
| キー登録後、業務更新前 | ロールバック後にキー記録と注文が残らない |
| 業務更新後、完了結果保存前 | 注文だけが確定しない |
| 完了結果保存後、コミット前 | 接続終了後の回復を待ち、全体が確定したか取り消されたかを確認する |
| コミット時の通信切断 | 即座に失敗と断定せず、復旧後の記録と注文を照合できる |
| コミット後、HTTP応答の受信前 | 同じキーの再送で注文が増えず、保存済み結果が返る |
| 保持期限の直前・直後、削除の前後 | 返却・拒否・新規扱いが定めた契約と一致する |
処理中状態を先に確定する別方式では、担当停止、リース延長失敗、引き継ぎ後の古い担当者の再開も試します。古い担当者が完了記録を更新できないだけでなく、業務データにも追加の変更を残せないことを確認します。
保存済み記録の参照に失敗した場合や、削除との競合で記録が見つからない場合も対象です。想定外の状態を成功扱いにせず、例外処理から業務処理へ抜けて二重登録しないことを確かめます。
HTTP応答と業務データ・外部副作用の件数を照合する
HTTPが201になったことだけでは、重複防止を検証できません。同じ論理操作に対する注文の件数、完了記録の件数、保存本文の注文ID、実際に返された注文IDを照合します。例外が起きた要求についても、業務データが残っていないか確認します。
外部連携を追加した場合は、アウトボックスの記録数、配送試行数、下流で確定した決済や通知の件数を分けて測ります。再送により配送試行が複数回になっても、下流の業務結果を一つにできているかが確認点です。外部成功後・送信済み保存前の停止も再現します。
検証記録には、利用バージョン、分離レベル、トランザクション設定、待機時間の設定、障害を入れた位置、期待値と実際の結果を残します。通常のログは要求ID、処理記録ID、結果分類などに絞り、認証情報や入力本文を無条件に出力しないようにします。新規実行、保存済み結果の返却、入力不一致、DB障害、結果不明を区別できると、復旧時の判断にも使えます。
まとめ
HTTP再送による二重処理を防ぐには、同じ論理操作で同じ冪等キーを使い、認証済みスコープと入力の照合情報を結び付けます。同時到着の判定はDBの一意制約で行い、DB内で完結する短い処理では、キー登録・業務更新・完了結果の保存を同じトランザクションで確定する設計が基本になります。
重複キーはトランザクション境界の外で扱い、別の参照境界から保存済み結果を取得します。入力不一致、待機上限、ロールバック、コミット結果不明を区別し、再返却でも認可を確認します。キーの削除や変更で再実行される可能性には、保持期限の契約と業務側の一意性で備えます。
外部APIを伴う場合は、DBトランザクションやアウトボックスだけで一度限りの実行を保証しません。下流の重複排除と結果照会を組み合わせ、同時到着や応答喪失を再現して、HTTP応答と最終的な業務結果の両方を確認してください。


コメント