代表的な面接トピック

冪等な注文作成APIの設計

バックエンド難しい
Offer.cc 編集チーム公開日 更新日

質問

POST /orders の冪等性コントラクトを設計してください。クライアントはタイムアウト後にリトライ可能であること、並行する重複リクエストで作成される注文は1件のみであること、異なるパラメータでのキーの再利用は拒否されること、そしてコミット後のレスポンス喪失、処理中の操作、下流への副作用に対応できる設計であることが求められます。

問題とスコープ

POST /orders の冪等性コントラクトを設計します。モバイルクライアントは不安定なネットワーク上でタイムアウトし、自動的にリトライする可能性があります。ゲートウェイも接続が切断された後にリクエストをリプレイすることがあります。1つの論理的操作が1回到着しようと複数回到着しようと、サーバーが作成する注文は1件のみでなければなりません。リクエストが成功した後は、同一のリトライに対して最初の結果をリプレイする必要があります。同じ冪等性キーが異なる注文パラメータを表している場合、サーバーはそれを拒否しなければなりません。

呼び出し元は1つの論理操作に対してランダムな識別子を Idempotency-Key で送信し、サーバーはPostgreSQLを使用していると仮定します。注文の書き込みと保留中イベントは、1つのローカルデータベーストランザクションを共有できます。外部の決済システムや在庫システムはそのトランザクションに参加できません。APIはマルチテナントであり、そのレスポンスには認可が必要な注文データが含まれる場合があります。

スコープには、リクエストの重複排除、並行競合、トランザクション境界、結果のリプレイ、保持期間、および下流への副作用が含まれます。クロスリージョンでのデータベースレプリケーション、決済ドメイン自体のステートマシン、メッセージブローカーの選定はスコープ外です。この問題は、広範なマルチコンポーネントシステムを設計することよりも、APIセマンティクスをデータベース制約、障害復旧、可観測性のあるリクエストフローへと落とし込むことが主なタスクであるため、バックエンドの設問となります。

面接官が評価するポイント

第一の評価ポイントは、候補者がリトライの同一性とビジネス上の同一性を区別できているかどうかです。冪等性キーは、呼び出し元の視点から1つの操作を識別するものです。認証、テナント分離、またはドメインレベルの注文キーの代わりにはなりません。サーバーは、少なくとも (tenant_id, endpoint, idempotency_key) でスコープを絞る必要があります。生のキーのみで検索すると、たまたま同じ文字列を選択した2つのテナントが互いの結果を読み取れてしまう可能性があります。

第二の評価ポイントは、並行処理の正当性がアプリケーションのタイミングではなく一意性制約に基づいているかどうかです。SELECT に続けて INSERT を行うと、2つのリクエストが共に「行が存在しない」と認識し、両方が最初のリクエストとして動作してしまう可能性があります。INSERT ... ON CONFLICT を備えたユニークインデックスを使用することで、データベースが1つのオーナーを選択できるようになります。また、比較にはリクエストのフィンガープリントも含める必要があります。そうでなければ、古いキーの誤った再利用によって、別の注文リクエストの結果として古い注文が返されてしまう可能性があります。

第三の評価ポイントは、正確なコミット境界です。注文、冪等性の結果、アウトボックスイベントは一緒にコミットされなければなりません。コミット前の障害では3つすべてがロールバックされます。コミットが成功したもののHTTPレスポンスが失われた場合、リトライによって完了済みのレコードが読み取られ、それがリプレイされます。決済や在庫の呼び出しは、アウトボックスコンシューマーを介してトランザクションの外部で行われ、派生した冪等性識別子が下流へ渡されます。

優れた回答では、保持期間、並行する重複リクエストへのレスポンス、どの障害を保存するか、長時間実行される操作の復旧方法、キーの競合や停滞した処理の監視方法についても定義されます。「Redisロックを使用する」だけでは、ロックの有効期限切れ、プロセスのクラッシュ、結果のリプレイ、データベースコミット後の競合への対処が未解決のままになります。

回答前に確認すべき質問

  • 何をもって重複とみなすか? ここでは、同じテナント、エンドポイント、および冪等性キーが1つの操作を識別します。ドメインにすでに再利用不可能な checkout_id がある場合、ordersテーブルにもその値に対する独立した一意性制約を設ける必要があります。
  • 呼び出し元はリソースIDを選択できるか? 呼び出し元が安定した注文IDを保持している場合、HTTPでPUTが冪等と定義されているため、PUT /orders/{clientOrderId} も選択肢になります。冪等性キーを伴うPOSTは、サーバー生成の注文IDに適しています。
  • 注文作成は完全にデータベース内の処理で完結するか? 1つの短いトランザクションに収まる場合は、単一トランザクション設計を使用します。数十秒かかるワークフローの場合は、長時間トランザクションを維持するのではなく、可視化された IN_PROGRESS レコード、リース、およびテイクオーバートークンが必要になります。
  • リプレイはどこまで正確である必要があるか? この設計では、最初のHTTPステータスと、リプレイしても安全なレスポンスボディを保存します。レスポンスに短期間の署名や動的なフィールドが含まれる場合は、リソースIDを保存し、明示的なコントラクトに基づいてレスポンスを再構築します。
  • キーはどのくらいの期間保持されるか? 保持期間は、クライアントの最長リトライ期間またはオフラインリプレイウィンドウをカバーする必要があります。その期間を過ぎても重複を絶対に防ぐ必要がある場合は、永続的なドメインキーを追加します。キャッシュのTTLを延ばすことはビジネス制約の代替にはなりません。
  • どの障害を記憶すべきか? オーナーシップを獲得する前に失敗したバリデーションは保存されません。トランザクション内の決定論的なビジネス拒否は保存してリプレイできます。トランザクションをロールバックするインフラ障害は完了結果を残さないため、クライアントはリトライできます。

30秒の回答

「各冪等性キーをテナントと POST /orders にスコープ付けし、正規化した注文パラメータのフィンガープリントを作成します。(tenant_id, endpoint, idempotency_key) に対する一意性制約によって並行リクエストを調停します。最初のリクエストが1つのトランザクション内で冪等性レコード、注文、アウトボックスイベントを書き込み、コミット前にステータスとレスポンスボディを保存します。同じフィンガープリントでのリトライはその結果をリプレイし、異なるフィンガープリントの場合は競合(コンフリクト)を返します。したがって、コミット後にレスポンスが失われても別の注文が作成されることはありません。決済と在庫は、派生した冪等性キーを用いてアウトボックスから非同期に実行します。リクエストレコードは最大リトライウィンドウの間保持し、永続的な重複排除には注文ドメイン専用の別の一意性制約を使用します。」

ステップごとの詳細解説

まずはプロトコルから始めます。認証された呼び出し元は、1つの「注文作成」の意図に対してエントロピーの高いキーを生成し、すべてのリトライでそれを再利用します。新しい注文には新しいキーが必要です。キーにメールアドレス、電話番号、その他の機密データを含めてはなりません。サーバーはその長さと文字セットを制限しますが、ランダムに見えるキーであっても認可資格情報には決してなりません。POSTにはデフォルトでPUTのような冪等セマンティクスがないため、安全なリトライはこのアプリケーションコントラクトによって実現されます。

リクエストのフィンガープリントは、生のJSONバイト列ではなく、サーバーでバリデーション済みのビジネスフィールドから構築します。フィールドの順序、意味のない空白、デフォルト値の違いによって、同じリクエストが異なるフィンガープリントになってはなりません。トレースID、タイムスタンプ、その他の非ビジネスフィールドは除外します。実用的な入力は、正規化されたDTO、エンドポイントのバージョン、および注文結果に影響を与えるすべてのフィールドの安定したエンコーディングです。そのエンコーディングをハッシュ化します。このダイジェストはキーの誤用を検知するためのものであり、署名や認証メカニズムではありません。

この最小限のスキーマは必要な制約を表現しています(SQLは例示です):

sql
CREATE TABLE idempotency_requests (
  tenant_id text NOT NULL,
  endpoint text NOT NULL,
  idempotency_key text NOT NULL,
  request_fingerprint text NOT NULL,
  response_status integer,
  response_body jsonb,
  resource_id uuid,
  created_at timestamptz NOT NULL,
  expires_at timestamptz NOT NULL,
  PRIMARY KEY (tenant_id, endpoint, idempotency_key)
);

短いトランザクションにおける通常のパスは次のとおりです。

  1. トランザクションの外部で、認証を行い、キーの形式とリクエストをバリデーションし、フィンガープリントを計算します。
  2. トランザクションを開始し、INSERT ... ON CONFLICT DO NOTHING RETURNING ... でキーの獲得を試みます。
  3. 行を挿入したリクエストがオーナーとなります。注文を作成し、アウトボックスイベントを追加し、ステータス、レスポンスボディ、注文IDで冪等性行を更新して、1回でコミットします。
  4. 挿入しなかったリクエストは既存の行を読み取ります。フィンガープリントが異なる場合は 409 idempotency_key_reused を返します。同じフィンガープリントの場合は、最初に保存されたステータスとボディを返し、必要に応じてAPI定義のリプレイマーカーを付与します。
  5. HTTPレスポンスはコミット後にのみ送信します。コミット後に接続が切断された場合、次のリクエストは注文を作成する代わりにステップ4に従います。

PostgreSQLのユニークインデックスにより、同じキーの並行インサートは待機させられ、最終的に1つのインサートまたは1つの競合として解決されます。その待機時間には上限を設けます。最初のトランザクションが迅速にコミットされれば、2番目のリクエストは結果を読み取ってリプレイできます。待機が制限に達した場合は、リトライのガイダンスとともに明示的な idempotency_in_progress の結果を返します。インサートを決定する前にアプリケーションレベルの SELECT を使用してはならず、競合時に保存済みのフィンガープリントや結果を決して上書きしてはなりません。

中心となる不変条件は、注文行が可視であるときは常にその冪等性結果とアウトボックスイベントも可視であり、トランザクションがロールバックされたときはいずれも不可視であるということです。障害ケースはここから直接導かれます。注文作成前のプロセスクラッシュはロールバックされるため、リトライによって再度競合に参加できます。注文コミット後のレスポンス喪失はリプレイされます。異なるパラメータを持つ同一キーが実行されることはありません。同時に到着した2つの同一リクエストのうち、一意性制約を通過して書き込みを完了できるのは1つだけです。クーポンの期限切れなど決定論的なドメイン拒否は、トランザクション内の安定したエラーレスポンスとして保存できます。データベース接続の喪失など未コミットの障害は保存されません。

注文作成を短いトランザクション内に収められない場合は、2フェーズのステートマシンを使用します。最初の短いトランザクションで IN_PROGRESSlease_expires_at、および単調増加する attempt_token をコミットします。APIは 202 を返すか、同じキーを持つ別のリクエストがステータスエンドポイントを読み取ります。リースの期限切れ後、新しいワーカーはcompare-and-swapを使用してより大きなトークンを獲得します。完了時のすべての更新でそのトークンが検証されるため、古いワーカーが処理を再開した後に新しい結果を上書きするのを防ぎます。注文には引き続きビジネス操作IDに対する一意性制約が必要であり、副作用には引き続きアウトボックスまたは下流の冪等性キーを使用します。TTLはあるがテイクオーバートークンのないステータス行では、古いワーカーと新しいワーカーが同時に実行されてしまい、重複の競合を単に先送りするだけになります。

外部の決済、在庫、通知の呼び出しは、ローカルデータベーストランザクション内に含めるべきではありません。オーナーは注文と共に OrderCreated のアウトボックス行を書き込み、コンシューマーがコミット後にそれを配信します。コンシューマーはイベントIDによって重複排除を行います。決済または在庫APIの呼び出しには、注文IDと操作タイプから導出された安定したキーを使用します。これにより、少なくとも1回のメッセージ配信(at-least-once delivery)が少なくとも1回の請求(at-least-once charging)になってしまうのを防ぎます。下流のサービスに冪等なインターフェースがない場合は、ローカルステートマシン、照合クエリ、または手動の補償処理を使用します。アウトボックス単体では外部の重複を排除できません。

保持期間はリトライコントラクトに従います。Stripeの公開実装では、保持期間が過ぎたキーの削除を許可しています。これはキーにライフサイクルがあり得ることを示していますが、その期間は一律ではありません。モバイルの最長オフラインキュー、ゲートウェイのリトライ、および手動リプレイウィンドウをカバーするように expires_at を設定します。クリーンアップ後に古いキーを再利用することは新規の操作となります。1つの checkout_id から永続的に最大1つの注文しか生成してはならない場合は、orders に永続的な一意性制約を設定し、冪等性レコードが削除された後でも重複を阻止できるようにします。

Redisは完了した結果をキャッシュできますが、正当性を保証する唯一の境界にすべきではありません。SET NX が成功した後、注文のコミット前またはコミット後のいずれかでプロセスがクラッシュする可能性があり、低速なリクエストが完了する前にロックのTTLが切れることもあります。また、キャッシュでは注文とアウトボックスをアトミックにコミットできません。「最大1回作成」の判断はデータベースの一意性制約に委ねてください。キャッシュは、データベースレコードを信頼できる唯一の情報源(Source of Truth)とした上で、頻繁なリプレイ時の読み取り負荷を軽減するためだけに使用します。

テストには、単なる2回のシーケンシャルなリクエスト以上の検証が必要です。少なくとも次のケースをカバーしてください。1つのキーによる50件の並行リクエストが1件の注文と1件のアウトボックスイベントのみを生成すること、同じキーで異なるパラメータの場合はコンフリクトを返すこと、コミット後かつレスポンス前の強制切断でも元の注文がリプレイされること、トランザクションロールバック後に同じキーでリトライできること、キーの有効期限切れ後の動作がコントラクト通りであること、同じ生のキーを使用する2つのテナントが分離されたままであること、コンシューマーへの重複配信が重複請求を引き起こさないこと。新規獲得、リプレイ、パラメータの競合、並行待機のタイムアウト、最も古い未完了の操作、トランザクションのロールバック、アウトボックスの遅延、および期限切れキーのクリーンアップを監視します。ログにはキーに関連付けられた機密性の高いリクエストボディを含めてはなりません。

優れた回答例

「私は、1つの冪等性キーを POST /orders における1つのテナントの1つの作成意図として定義します。クライアントは最初の呼び出しでそれを生成し、タイムアウト時のリトライのために保持します。サーバーはバリデーション済みの注文フィールドとAPIバージョンを組み合わせてフィンガープリントを作成します。同じキーとフィンガープリントであればリプレイ可能であり、同じキーで異なるフィンガープリントの場合はコンフリクトとなります。

PostgreSQLでは、(tenant_id, endpoint, idempotency_key) を主キーにします。リクエストのバリデーション後、各呼び出しは INSERT ... ON CONFLICT DO NOTHING でオーナーシップを競い合います。勝者は注文を作成し、アウトボックス行を書き込み、コミット前の1つの短いトランザクションで最初のステータスとレスポンスボディを保存します。敗者は別の注文を作成することはできません。敗者は最初のトランザクションを待機し、レコードを読み取って、フィンガープリントが一致していれば元の結果を返します。内部の待機制限に達した場合は、呼び出し元に操作がまだ進行中であることを伝えます。したがって、コミット後かつHTTPレスポンス前のクラッシュは、リトライ時に同一の注文として復旧されます。

そのトランザクション内で決済や在庫を同期的に呼び出すことはしません。アウトボックスコンシューマーがそれらのアクションを配信し、注文IDから派生したキーを各下流操作に渡します。冪等性レコードは最大リトライウィンドウの間だけ存在するため、チェックアウトセッションが2つ目の注文を絶対に作成してはならない場合は、注文テーブルの checkout_id にも永続的な一意性制約を設定します。

最後に、並行処理、切断、重複メッセージのフォールトインジェクションを行います。単にHTTP 200を確認するだけでなく、注文数、冪等性結果数、アウトボックスイベント数をアサートします。本番環境では、リプレイ率、同一キーのパラメータ競合、並行待機タイムアウト、トランザクションロールバック、アウトボックスの遅延を監視します。」

よくある間違い

  • 行の存在を確認してから注文を挿入する → 2つのリクエストが共に「行が存在しない」と確認して両方とも書き込んでしまう可能性がある → データベースの一意性制約とアトミックな競合処理によってオーナーを決定する。
  • 冪等性キーのみで検索する → テナント間で衝突したり、別のテナントのレスポンスを受け取ってしまう可能性がある → 認証されたテナントとエンドポイントでスコープを絞り、リプレイ時にも再認可を行う。
  • パラメータを比較せずに古い結果を返す → キーの誤った再利用により、古い注文が新しい注文のように見えてしまう → 正規化されたリクエストフィンガープリントを保存し、不一致を拒否する。
  • 注文をコミットした後に冪等性の結果を書き込む → 書き込みの合間にクラッシュすると、重複排除レコードのない注文が残ってしまう → 注文、結果、アウトボックスを1つのデータベーストランザクションでコミットする。
  • データベーストランザクション内で決済サービスを呼び出す → ネットワークタイムアウトによってロック保持時間が延び、成功した決済をローカルでアトミックにロールバックすることができなくなる → トランザクション内ではアウトボックスエントリを書き込み、下流の冪等性はトランザクション外で使用する。
  • TTL付きのRedisロックのみを使用する → クラッシュや期限切れの後、注文がコミットされたかどうかを把握できず、結果をリプレイすることもできない → 正当性にはデータベースの制約を使用し、キャッシュは高速化のためだけに使用する。
  • すべてのエラーをキャッシュする → 修正されたリクエストが古いバリデーションエラーを受け取り続けたり、一時的な障害のリトライが不可能になったりする → オーナーシップ獲得前のバリデーション失敗は保存しない。安定したドメイン結果と未コミットの障害を区別する。
  • リクエストレコードの削除後も永続的な重複排除を約束する → 期限切れのキーは新しい操作として扱われる → 保持期間を公開し、永続的な不変条件にはドメインの一意キーを使用する。

フォローアップの質問と回答

フォローアップ1:最初のトランザクションがまだコミットされていない間、2番目のリクエストは何を受け取るべきですか?

短いトランザクションの設計では、ユニークインデックスの競合によって2番目のインサートが待機状態になります。データベースの待機時間とエンドポイントの実行時間の両方をクライアントのタイムアウト未満に制限します。最初のトランザクションが時間内にコミットされれば、2番目のリクエストはそれを読み取ってリプレイします。制限に達した場合は、注文を作成する代わりに、識別可能な idempotency_in_progress の結果とリトライのタイミングを返します。長時間のタスクでは、HTTP接続を占有し続けないように、コミット済みの IN_PROGRESS 状態とステータスエンドポイントを使用します。

フォローアップ2:コミット後、HTTPレスポンスを書き込む前にサービスがクラッシュした場合はどうなりますか?

そのために結果を保存しています。注文、アウトボックスイベント、レスポンスのスナップショットは一緒にコミットされています。リトライは一意キーで競合し、フィンガープリントを検証した上で、保存されているステータスとボディを返します。単なる「処理済み」というブール値フラグだけでは、サーバーはどの注文を返すべきか判断できず、呼び出し元も成功したのか不明な結果なのかを区別できません。

フォローアップ3:同じ冪等性キーで異なる金額が含まれている場合はどうなりますか?

409 idempotency_key_reused を返し、競合メトリクスを記録し、注文作成は実行しません。古いレコードを新しいパラメータで上書きしたり、古い注文をそのまま返したりしてはなりません。フィンガープリントには、金額、通貨、商品項目、配送先参照フィールド、その他ビジネス結果を変更するすべてのフィールドに加え、APIまたは正規化のバージョンを含める必要があります。

フォローアップ4:長時間実行されているワーカーが一時停止し、別のワーカーが期限切れのリースを引き継いだ後、古いワーカーが再開しました。古い書き込みを防ぐにはどうすればよいですか?

引き継ぎ(テイクオーバー)のたびに attempt_token をインクリメントします。注文状態の変更、完了の書き込み、制御可能な下流タスクはそのトークンを保持し、現在の値と一致する場合にのみ更新を行います。古いワーカーの書き込みは処理再開後に失敗するため、新しい状態を読み取って終了します。下流システムがトークンを検証できない場合でも、安定したビジネス操作IDをキーとする冪等API、あるいは照合および補償プロセスが依然として必要です。リース単体では、すでに送信された外部の副作用を取り消すことはできません。

フォローアップ5:orders.checkout_id の一意性制約だけを使用しないのはなぜですか?

その制約は2つ目の注文を防ぐものであり、永続的なセーフティネットとして優れています。しかし、1つの冪等性キー配下の異なるパラメータが競合するかどうか、最初のリクエストがどのステータスを返したか、並行処理中にどのように応答するか、あるいは checkout_id を持たない呼び出し元にどう対応するかは定義できません。冪等性レコードはリクエストレベルのプロトコルと結果のリプレイを提供し、ビジネスの一意キーはドメインの不変条件を保護します。これらは異なるレイヤーで機能します。

公開情報ソース

関連する質問