Goでの冪等エンドポイント:キー、重複防止テーブル、再試行対応
Goで冪等なエンドポイントを設計する方法:冪等キー、重複防止テーブル、支払い・インポート・Webhook向けの再試行に安全なハンドラ。

再試行が重複を生む理由(そして冪等性が重要な理由)
再試行は「何かが壊れた」からだけ起こるわけではありません。クライアントがタイムアウトしている間にサーバーはまだ処理を続けていることがあります。モバイルの接続が切れてアプリが再送することもあります。ジョブランナーが502を受けて自動で同じリクエストを送り直すこともあります。キューやWebhookでよくある「少なくとも一度配信(at-least-once)」の世界では、重複は普通です。
だからこそ冪等性が重要です: 繰り返し呼び出しても単一の呼び出しと同じ最終結果になるべきです。
いくつか混同しやすい用語:
- Safe(安全): 呼び出しても状態が変わらない(読み取りのようなもの)。
- Idempotent(冪等): 何度呼んでも一度呼んだのと同じ効果になる。
- At-least-once: 送信側が“成功するまで”再送するので、受信側は重複を扱う必要がある。
冪等性がないと、再試行は実害を生みます。支払いエンドポイントは、最初の課金が成功してもレスポンスが届かなければ二重に請求してしまうかもしれません。インポートエンドポイントはワーカーがタイムアウトして再試行すると重複行を作ることがあります。Webhookハンドラが同じイベントを二度処理して二通メールを送ることもあります。
重要な点: 冪等性はAPI契約であって内部の実装の細部ではありません。クライアントは何を再試行できるのか、どのキーを送るべきか、重複が検出されたときにどんなレスポンスを期待できるかを知らなければなりません。動作を黙って変えると再試行ロジックが壊れ、新しい障害モードを生みます。
また冪等性は監視と突合(reconciliation)を置き換えません。重複率を追跡し、“replay”の判断をログに残し、定期的に外部システム(支払いプロバイダなど)と自データベースを比較してください。
各エンドポイントで冪等のスコープとルールを決める
テーブルやミドルウェアを追加する前に、「同じリクエスト」とは何か、クライアントが再試行したときにサーバーが何を約束するのかを決めてください。
多くの問題はPOSTに出ます。POSTは何かを作成したり副作用を起こすことが多いためです(カードを課金する、メッセージを送る、インポートを開始するなど)。PATCHも副作用を伴う場合は冪等が必要です。GETは状態を変えてはいけません。
スコープを定義する: どこでキーを一意にするか
ビジネスルールに合うスコープを選んでください。広すぎると正当な処理を止めてしまい、狭すぎると重複を許してしまいます。
よく使われるスコープ:
- エンドポイントごと+顧客
- エンドポイントごと+外部オブジェクト(invoice_idやorder_idなど)
- エンドポイントごと+テナント(マルチテナント環境)
- エンドポイントごと+支払い手段+金額(製品ルールが許す場合のみ)
例: 「支払い作成」ではキーを顧客ごとに一意にする。Webhookイベントの取り込みなら、支払いプロバイダが付与するイベントID(プロバイダ側でのグローバル一意)をスコープにする。
重複時に何を返すか決める
重複が来たら最初の成功時と同じ結果を返します。実務では同じHTTPステータスコードと同じレスポンスボディをリプレイする(あるいは少なくとも同じリソースIDと状態を返す)ことを意味します。
クライアントはこれに依存しています。最初の試行が成功したがネットワークが落ちたなら、再試行で二重課金や二重ジョブ作成をしてはなりません。
保持期間(retention window)を選ぶ
キーには有効期限を設けます。現実的な再試行や遅延ジョブをカバーする長さを確保してください。
- 支払い: 24〜72時間が一般的
- インポート: ユーザーが遅れて再試行する可能性があるなら1週間程度
- Webhook: 送信プロバイダの再試行ポリシーに合わせる
「同じリクエスト」をどう定義するか: 明示的キーかボディハッシュか
明示的な冪等キー(ヘッダかフィールド)は通常もっとも扱いやすいルールです。
ボディハッシュはバックストップとして役立ちますが、無害な変更(フィールド順、空白、タイムスタンプ)で壊れやすいです。ハッシュを使うなら入力を正規化し、どのフィールドを含めるか厳格に決めてください。
冪等キー: 実務での使い方
冪等キーはクライアントとサーバーのシンプルな約束事です: 「このキーを見たら同じリクエストとして扱ってください」。再試行に強いAPIを作る現実的な道具の一つです。
キーはどちらかの側が生成できますが、ほとんどのAPIではクライアント生成が適します。クライアントはいつ同じ操作を再試行しているかを知っているので、試行間で同じキーを再利用できます。サーバー生成のキーは「ドラフト」リソース(例: インポートジョブ)を最初に作っておき、後でそのジョブIDを参照して再試行を許す用途には便利ですが、最初のリクエストそのものには役立ちません。
ランダムで推測されにくい文字列を使ってください。少なくとも128ビットのランダム性を目安に(例: 32文字の16進文字列やUUID)。タイムスタンプやユーザーIDからキーを作らないでください。
サーバー側では、誤用を検出して元の結果をリプレイできるよう十分なコンテキストと共にキーを保存します:
- 誰が呼んだか(アカウントやユーザーID)
- どのエンドポイントや操作に対するものか
- 重要なリクエストフィールドのハッシュ
- 現在のステータス(進行中、成功、失敗)
- リプレイするためのレスポンス(ステータスコードとボディ)
キーはスコープ化すべきで、典型的にはユーザー(またはAPIトークン)+エンドポイントです。同じキーが異なるペイロードで再利用されたら明確なエラーで拒否してください。これによりバグのあるクライアントが古いキーを使って違う金額の支払いを送る、といった事故を防げます。
リプレイ時は最初の成功時と同じ結果を返します。つまり同じHTTPステータスコードと同じレスポンスボディを返し、変化した最新状態を新たに読み直して返すのではなく「最初に返したもの」を返すことが望ましいです。
PostgreSQLでの重複防止テーブル: シンプルで信頼できるパターン
専用の重複防止テーブルは冪等性を実装する最もシンプルな方法の一つです。最初のリクエストがキーの行を作成し、以降の再試行は同じ行を読み出して保存された結果を返します。
保存するもの
テーブルは小さく焦点を絞ってください。一般的な構成:
key: 冪等キー(テキスト)owner: キーの所有者(user_id, account_id, または APIクライアントID)request_hash: 重要なリクエストフィールドのハッシュresponse: 最終レスポンスペイロード(多くはJSON)または保存された結果へのポインタcreated_at: キーが初めて見られた時刻
ユニーク制約がこのパターンの核です。(owner, key)に対して一意性を強制し、同じクライアントが重複を作れないように、異なるクライアント同士で衝突しないようにします。
またrequest_hashを保存しておくとキーの誤用を検出できます。再試行で同じキーなのにハッシュが異なる場合は、異なる操作が混ざらないようエラーを返してください。
保持とインデックス
重複防止行は永遠に残してはいけません。実際の再試行ウィンドウだけ保持し、その後はクリーンアップしましょう。
負荷の下で高速に動かすために:
- 高速な挿入/ルックアップのために
(owner, key)にユニークインデックス - クリーンアップを安価にするために
created_atに対するインデックス(任意)
レスポンスが大きい場合はポインタ(例: result ID)を保存してフルペイロードは別に置くと、テーブル肥大を抑えつつ再試行時の挙動を一貫させられます。
手順: Goでの再試行に安全なハンドラのフロー
再試行に強いハンドラには2つの要素が必要です: 「同じリクエストを識別する安定した方法」と「最初の結果を保存してリプレイできる永続的な場所」です。
支払い、インポート、Webhook取り込みの実務的なフロー:
-
リクエストを検証し、次の3つの値を導出する: 冪等キー(ヘッダかクライアントフィールドから)、オーナー(テナントやユーザーID)、重要フィールドのリクエストハッシュ。
-
データベーストランザクションを開始して、重複防止レコードの作成を試みます。
(owner, key)でユニークにします。request_hash、ステータス(started, completed)、レスポンスのプレースホルダを保存します。 -
挿入が競合したら既存行を読みます。もし
completedなら保存されたレスポンスを返します。もしstartedなら短時間待つ(シンプルなポーリング)か、409/202を返してクライアントに後で再試行させます。 -
重複防止行を“所有”できたときだけビジネスロジックを一度だけ実行します。可能なら副作用は同じトランザクション内に書き込みます。ビジネス結果とHTTPレスポンス(ステータスコードとボディ)を永続化します。
-
コミットし、サポートが重複を追跡できるように冪等キーとオーナーでログを残します。
最小限のテーブルパターン例:
create table idempotency_keys (
owner_id text not null,
idem_key text not null,
request_hash text not null,
status text not null,
response_code int,
response_body jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (owner_id, idem_key)
);
例: "Create payout"エンドポイントが課金後にタイムアウトしたとします。クライアントが同じキーで再試行すると、ハンドラは競合に当たり、completedのレコードを見つけて元のペイアウトIDを返し、再度課金はしません。
支払い: タイムアウトがあっても一度だけ課金する
支払いは冪等性が必須になるケースです。ネットワークは壊れ、モバイルは再試行し、ゲートウェイは既に課金を作成した後にタイムアウトを返すことがあります。
実務的ルール: 冪等キーが課金作成をガードし、支払いプロバイダのID(charge/intent ID)がその後の信頼できる事実源になります。プロバイダIDを保存したら、同じリクエストで新しい課金を作らないでください。
再試行とゲートウェイの不確実性に対処するパターン:
- 冪等キーを読み検証する。
- データベーストランザクションで
(merchant_id, idempotency_key)をキーに支払い行を作成または取得する。もしすでにprovider_idがあれば保存された結果を返す。 provider_idがなければゲートウェイにPaymentIntent/Chargeを作成する。- ゲートウェイが成功したら
provider_idを永続化し、支払いを“succeeded”または“requires_action”にする。 - ゲートウェイがタイムアウトや不明な結果を返したら、状態を
pendingとして保存し、クライアントに再試行して安全であることを示す一貫したレスポンスを返す。
重要な点はタイムアウトの扱いです: 単に失敗扱いにしないこと。pendingとして記録し、後でプロバイダに照会するかWebhookで確認して確定させます。
エラー応答は予測可能であるべきです。クライアントは返り値に基づいて再試行ロジックを作るので、ステータスコードとエラーの形を安定させてください。
インポートとバッチエンドポイント: 進捗を失わずに重複排除
インポートでは重複が特に厄介です。ユーザーがCSVをアップロードして95%でサーバーがタイムアウトし、ユーザーが再試行すると何重にも行が作られるか、または最初からやり直しを強いられることがあります。
バッチ処理ではジョブとその中のアイテムという二層で考えます。ジョブレベルの冪等性は同一リクエストが複数ジョブを作らないようにし、アイテムレベルの冪等性は同じ行が二度適用されないようにします。
ジョブレベルのパターンは、インポートリクエストごとに冪等キーを要求する(または安定したリクエストハッシュ+ユーザーIDから派生する)ことです。import_jobレコードに保存し、再試行時には同じジョブIDを返します。ハンドラは「このジョブは見た、現在の状態はこうだ」と答えられるべきで、「また始めて」とはしないでください。
アイテムレベルの重複排除では、データに既にある自然キーを頼ります。例: 各行にソースシステム由来の external_id が含まれている、または (account_id, email) のような安定した組み合わせです。PostgreSQLで一意制約を強制し、UPSERTを使えば再試行で重複を作りません。
リプレイが既に存在する行に出会ったらどうするかは事前に決めておいてください。明示的に: スキップする、特定フィールドだけ更新する、または失敗にする。ルールが明確でない限り“マージ”は避けてください。
部分成功は普通です。一つの大きな「ok/failed」ではなく、行ごとの結果をジョブに紐付けて保存しましょう: 行番号、自然キー、ステータス(created, updated, skipped, error)、エラーメッセージなど。再試行時にはすでに終わっている行を再利用しつつ安全に再実行できます。
再開可能にするにはチェックポイントを追加します。ページ単位(例: 500行ずつ)で処理し、各ページコミット後に最後に処理したカーソル(行インデックスやソースカーソル)を保存します。クラッシュしても次回は最後のチェックポイントから再開できます。
Webhook取り込み: 重複排除、検証、その後に安全に処理
Webhook送信者は再試行します。順序が前後することもあります。ハンドラが毎回状態を更新すると、最終的にレコードが二重作成されたり、二重にメールを送ったり、二重に課金したりします。
まず最良の重複キーを選びます。プロバイダが一意のイベントIDを提供するならそれを使ってください。Webhookエンドポイントの冪等キーとして扱います。イベントIDがない場合にのみペイロードのハッシュを代替手段として使います。
セキュリティが最優先です: 署名を検証してから受け入れてください。署名が失敗したらリクエストを拒否して重複防止レコードを書き込まないでください。さもないと攻撃者がイベントIDを「予約」して後から本物のイベントをブロックできてしまいます。
再試行下での安全なフロー:
- 署名と基本的な形(必須ヘッダ、イベントID)を検証する。
- イベントIDをユニーク制約付きの重複防止テーブルに挿入する。
- 挿入が重複で失敗したら 200 をすぐ返す(既に処理済みと見なす)。
- 監査やデバッグのために生のペイロード(とヘッダ)を保存する。
- 処理を非同期にエンキューして素早く 200 を返す。
素早くACKすることが重要です。多くのプロバイダはタイムアウトが短いです。リクエスト内で最小限・確実な作業(検証、重複排除、保存)だけ行い、その後は非同期で処理してください。非同期にできない場合でも、内部の副作用を同じイベントIDでキー化して冪等に保つべきです。
順序が前後するのは普通です。"created"が先に来るとは限りません。外部オブジェクトIDでUPSERTし、最後に処理したイベントのタイムスタンプやバージョンを追跡する方が安全です。
生のペイロードを保存しておくと、顧客から「更新が届いていない」と言われたときに、プロバイダに再送を頼まずに保存したボディから処理を再実行できます。
並行性: 並列リクエスト下でも正しく保つ
同じ冪等キーを持つ2つのリクエストが同時に来ると再試行は厄介になります。どちらのハンドラも結果を保存する前に“作業”を行うと、二重課金や二重インポート、二重エンキューが起きます。
最も単純な調整点はデータベーストランザクションです。最初のステップを「キーを主張する」ことにして、データベースに勝者を決めさせます。一般的な選択肢:
- 重複防止テーブルへのユニーク挿入(データベースが勝者を決める)
- 作成(または検索)後に
SELECT ... FOR UPDATEを使う - 冪等キーのハッシュでトランザクションレベルのアドバイザリロックを使う
- 最終的なバックストップとしてビジネスレコードにユニーク制約を置く
長時間かかる処理では外部システム呼び出しや数分かかるインポート中に行ロックを保持しないようにしてください。代わりに重複防止行に小さなステートマシンを保存し、他のリクエストは素早く抜けられるようにします。
実務的な状態の集合:
in_progressとstarted_atcompletedとキャッシュしたレスポンスfailedとエラーコード(再試行ポリシーに応じて任意)expires_at(クリーンアップ用)
例: 2台のアプリインスタンスが同じ支払いリクエストを受け取る。インスタンスAはキーを挿入して in_progress を設定しプロバイダを呼ぶ。インスタンスBは競合経路に入り、重複防止行を読み in_progress を見て短い"処理中"応答を返す(あるいは少し待って再チェックする)。Aが終わると行を completed に更新してレスポンスボディを保存するので、その後の再試行は同じ出力を正確に受け取れる。
冪等性を壊す一般的なミス
多くの冪等性バグは複雑なロックの問題ではなく、「ほぼ正しい」選択が再試行やタイムアウト、別のユーザーによる似た操作で失敗するケースです。
よくある落とし穴は冪等キーをグローバルに一意とみなすことです。スコープしないと別のクライアント同士が衝突し、一方が他方の結果を受け取ってしまいます。
別の問題は同じキーを異なるボディで受け入れてしまうことです。最初が$10で再試行が$100なら、最初の結果を黙って返すべきではありません。リクエストハッシュ(または重要フィールド)を保存して比較し、キー一致でペイロード不一致なら明確な競合エラーを返してください。
クライアントもリプレイが違うレスポンス形やステータスコードを返すと混乱します。最初が201とJSONボディを返したなら、リプレイは同じボディと同じステータスを返すべきです。リプレイ動作を変えるとクライアントは推測を強いられます。
重複を引き起こす頻出ミス:
- メモリ内マップやキャッシュだけに頼り、再起動で重複状態を失う
- スコープせずキーを使う(ユーザー間やエンドポイント間の衝突)
- 同じキーでのペイロード不一致を検証しない
- 副作用(課金、挿入、公開)を先に行い、重複防止レコードを後で書く
- 再試行のたびに新しい生成IDを返し、オリジナルをリプレイしない
キャッシュは読み取りを高速化できますが、真の信頼できる情報源は耐久性のあるストレージ(通常はPostgreSQL)にするべきです。さもないとデプロイ後の再試行で重複が発生します。
またクリーンアップ計画を立ててください。全てのキーを永遠に保存するとテーブルが肥大してインデックスが遅くなります。実際の再試行挙動に基づいて保持期間を決め、古い行を削除し、ユニークインデックスを小さく保ちましょう。
早見リストと次のステップ
冪等性をAPI契約の一部として扱ってください。クライアント、キュー、ゲートウェイによって再試行され得るエンドポイントごとに、「同じリクエストとは何か」と「同じ結果とは何か」を明確に定義する必要があります。
出荷前のチェックリスト:
- 再試行可能な各エンドポイントについて、冪等性のスコープ(ユーザーごと、アカウントごと、注文ごと、外部イベントごとなど)は定義され文書化されていますか?
- 重複排除はコード内のチェックだけでなくデータベースで強制されていますか(冪等キーとスコープに対するユニーク制約)?
- リプレイ時に同じステータスコードとレスポンスボディ(または文書化された安定したサブセット)を返していますか?新しいオブジェクトや新しいタイムスタンプを返していませんか?
- 支払いでは、送信後にタイムアウトやゲートウェイが「処理中」と言った場合でも二重課金しないようにしていますか?
- ログとメトリクスで初回検出とリプレイを区別できるようになっていますか?
どれかが「もしかしたら」なら今すぐ修正してください。ほとんどの障害はストレス下で現れます: 並列再試行、遅いネットワーク、部分的な障害時です。
AppMaster (appmaster.io) 上で内部ツールや顧客向けアプリを構築する場合は、早い段階で冪等キーとPostgreSQLの重複防止テーブルを設計しておくと便利です。そうすれば、プラットフォームが要件変更に応じてGoのバックエンドコードを再生成しても、再試行時の挙動は一貫して保てます。
よくある質問
再試行はネットワークやクライアントの障害として普通に起こります。サーバー側では処理が完了していても、レスポンスがクライアントに届かないとクライアントは再送を行い、サーバー側が元の結果を認識してリプレイできなければ同じ作業を二度行ってしまいます。
同じ操作の再試行では同じキーを送ってください。キーはクライアント側で生成するのが一般的で、ランダムで推測されにくい文字列(例: UUID)を使い、別の操作に使い回さないでください。
業務ルールに合わせてスコープを設定します。通常はエンドポイントごと+呼び出し元の識別子(ユーザーID、アカウントID、テナント、APIトークンなど)でスコープすると、別の顧客同士の衝突を防げます。
最初に成功した試行と同じ結果を返してください。実務上は同じHTTPステータスコードと同じレスポンスボディ(少なくとも同じリソースIDと状態)をリプレイすることを目指します。これによりクライアントは安全に再試行できます。
異なるリクエストボディで同じキーが再利用された場合は、推測して混ぜるのではなく明確な競合エラーで拒否してください。重要なフィールドのハッシュを保存して比較し、キーが一致してボディが違うなら早期に失敗させます。
現実的な再試行をカバーする期間だけキーを保持し、それ以降は削除します。一般的な目安は、支払いなら24〜72時間、インポートは1週間、Webhookは送信者の再試行ポリシーに合わせることです。
専用の重複防止テーブルがシンプルで堅牢です。データベースに一意制約を持たせることで、再起動やデプロイ後も状態を保持できます。所有者スコープ、キー、リクエストハッシュ、ステータス、リプレイ用レスポンスを保存し、(owner, key)をユニークにしましょう。
まずキーをデータベース内で“主張”してから副作用を行います。主張に失敗した並行リクエストは一意制約に当たり、in_progressやcompletedを見て待機・再試行・即時応答のどれかを返すべきです。これにより二重実行を防げます。
タイムアウトを単純に失敗と判断してはいけません。結果が不明な場合は“unknown”やpendingとして扱い、プロバイダIDがあれば後でプロバイダに問い合わせるかWebhookで確認して確定してください。これにより二重課金を防げます。
ジョブレベルとアイテムレベルの二段構えで重複を防ぎます。インポート要求にはジョブ用の冪等キーを必須にして同じジョブIDを返し、各行は外部IDや(account_id, email)のような自然キーで一意制約やUPSERTを使って重複挿入を防ぎます。


