代表的な面接トピック

バックエンド面接:データベースとメッセージブローカーの二重書き込み(Dual-Write)問題をどう解決するか?

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

質問

注文サービスがPostgreSQLに注文を書き込み、メッセージブローカーにOrderCreatedを発行します。分散2相コミットを使用せずに、ロールバックされた注文からイベントが発行されず、コミットされた注文からは最終的に少なくとも1つのイベントが発行され、リレーやコンシューマーのクラッシュによってビジネス上の処理が重複しないようにするにはどうすればよいでしょうか。順序保証、運用、検証についても説明してください。

プロンプトと適用コンテキスト

注文サービスは、1つのコマンドに対して次の2つの処理を実行する必要があります。

  1. PostgreSQLに注文を書き込む
  2. メッセージブローカーにOrderCreatedイベントを発行する

ビジネス上の規約は、単に「両方の呼び出しを試みる」ことよりも厳格です。データベーストランザクションがロールバックされた場合、存在しない注文を表すイベントを発行してはなりません。トランザクションがコミットされた場合、発行の意図はプロセスクラッシュを生き延び、最終的にブローカーに届く必要があります。同一の注文に対するイベントは順序が維持されている必要がありますが、グローバルな順序保証は不要です。リレーおよびコンシューマーはどの時点でもクラッシュする可能性があり、ブローカーは再配信を行う可能性があります。分散2相コミット(2PC)は利用できません。

これはTransactional Outbox問題です。1つのリクエストがトランザクション状態を変更し、別のシステム(在庫引当、請求、検索インデックス作成、メール送信、Webhook、アナリティクスなど)で確実に処理をトリガーする必要がある場合に必ず発生します。目標は魔法のような厳密に1回(exactly-once)の配信を行うことではありません。利用可能なアトミック性の境界を特定し、境界を越える意図を永続化し、リトライを安全にすることです。

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

第1のシグナルは、障害ウィンドウ(failure window)に関する推論力です。「DBに書き込んでから発行する」方式は、コミット後にプロセスがクラッシュするとイベントが失われます。「発行してからコミットする」方式は、後でDBがロールバックされてもイベントが公開されてしまいます。メモリ内のコミット後コールバックも、プロセスが落ちれば消滅します。優れた回答は、パターンを提示する前にこれらのウィンドウを明確に指摘します。

第2のシグナルは、正確な保証内容の提示です。ビジネスデータ行とアウトボックス(Outbox)行は、1つのローカルデータベーストランザクション内でアトミックにコミットできます。ブローカーへの発行は後から行われます。これにより、コミットされたすべての変更に対して永続的なイベント発行意図が保証されますが、DBとブローカーが単一のトランザクションになるわけではなく、厳密に1回(exactly-once)の配信を約束するわけでもありません。

第3のシグナルは、エンドツーエンドのリトライ安全性です。ブローカーがイベントを受け付け、リレーが成功を記録する前にクラッシュした場合、そのイベントは再発行されます。したがって、リレーは少なくとも1回(at-least-once)の発行を提供し、各コンシューマーは自身のビジネス効果を冪等(idempotent)にする必要があります。優れた回答では、コンシューマーのローカルDBへの変更と、クレジットカード決済のような外部の副作用を明確に区別します。

最後のシグナルは、順序制御と運用性です。集約ごとのシーケンス番号、パーティションキー、並行リレーの所有権制御、ポイズンイベント、リトライポリシー、クリーンアップ、リプレイ保持期間、遅延メトリクス、フォールトインジェクションテストなどが含まれます。これらの境界に触れずにパターン名だけを挙げる回答は不完全です。

回答前に確認すべき明確化のための質問

  • 要求される保証は何か? 少なくとも1回(at-least-once)の発行と厳密に1回(exactly-once)のビジネス効果で十分か、それとも呼び出し元へ応答する前に同期的な確認が必要か?
  • どの変更とイベントが対応しているか? 1回の注文変更で1つのイベントを作成するのか、それとも1回のトランザクションで注文ごとの連続したシーケンス番号を必要とする複数のイベントを作成するのか?
  • どのような順序付けが必要か? この設計では、全注文をまたぐ1つのグローバルな全順序ではなく、注文ごとの順序付けを想定しているか?
  • ブローカーは何を保証できるか? ACK、再配信、パーティション順序、保持期間、プロデューサーの冪等性について確認します。ただし、それら単体ではDBからブローカーへのハンドオフギャップは解消されません。
  • イベントはどの程度の速さで届く必要があるか? レイテンシ目標は、ポーリング間隔、DB負荷、Change Data Capture(CDC)の採用可否に影響します。
  • コンシューマーは何を行うか? ローカルDBの更新であればInbox行と同一トランザクションを共有できます。外部決済やメール送信の場合は、ダウンストリームの冪等性キーや別の永続的なハンドオフが必要です。
  • リプレイはどのくらいの期間可能である必要があるか? Outboxおよびコンシューマーの重複排除レコードのクリーンアップは、必要なリトライおよびリプレイの保持期間を満たす必要があります。
  • 両方のリソースが2相コミットに参加できるか? プロンプトでは不可とされています。もし実システムでリソース間の同期アトミック性が真に必要であり、両リソースがサポートしているなら、頭ごなしに不可能と決めつけるのではなく、可用性と結合度のトレードオフを評価すべきです。

30秒の回答フレームワーク

「同一のPostgreSQLトランザクション内で注文とイミュータブルなOutboxイベントを書き込みます。独立したリレーがコミットされたOutbox行を取得して発行し、ブローカーからのACKを受け取った後にのみ発行済みとしてマークします。発行前にリレーが停止した場合は保留状態のまま残り、ブローカー受付後マーク前に停止した場合は再発行されるため、配信はat-least-onceとなります。すべてのイベントは不変のIDを持ち、コンシューマーはそのIDをビジネス更新と同じトランザクションで重複排除テーブルに挿入します。注文ごとのシーケンスを割り当て、注文IDをブローカーのパーティションキーとして使用し、未完了の古いイベントを新しいイベントが追い越さないように制御し、最古の保留中イベントの滞留時間を監視します。そして、コミット、発行、ACK、コンシューマーの各境界でクラッシュを注入して不変条件を検証します。」

ステップバイステップの詳細解説

まず、直感的な呼び出し順序がなぜ失敗するかを証明します。DB先行フローでは、時刻T1でDBがコミットされ、T2でブローカーが受け付ける前にプロセスが停止する可能性があり、注文は存在するのにイベントが存在しない状態になります。クライアントがリトライしない可能性があり、またコマンド自体が冪等でない限り注文が重複する可能性があるため、HTTPリクエストのリトライは完全な解決策になりません。ブローカー先行フローでは、注文トランザクションが失敗する前にコンシューマーがイベントを処理してしまう可能性があります。呼び出し順序を逆にしても不整合の向きが変わるだけです。

永続的な発行意図を、サービスが制御できる唯一のアトミックな境界内に移動させます。単一のPostgreSQLトランザクション内で、コマンドを検証し、注文を変更し、その注文の次のシーケンスを割り当て、イミュータブルなOutbox行を挿入します。両方の行がコミットされるか、両方ともコミットされないかのどちらかです。代表的なスキーマは以下の通りです。

sql
CREATE TABLE outbox_events (
  event_id uuid PRIMARY KEY,
  aggregate_type text NOT NULL,
  aggregate_id text NOT NULL,
  aggregate_sequence bigint NOT NULL,
  event_type text NOT NULL,
  schema_version integer NOT NULL,
  payload jsonb NOT NULL,
  occurred_at timestamptz NOT NULL DEFAULT now(),
  available_at timestamptz NOT NULL DEFAULT now(),
  claimed_by text,
  claim_until timestamptz,
  published_at timestamptz,
  attempt_count integer NOT NULL DEFAULT 0,
  last_error text,
  UNIQUE (aggregate_type, aggregate_id, aggregate_sequence)
);

CREATE INDEX outbox_dispatch_idx
ON outbox_events (available_at, occurred_at)
WHERE published_at IS NULL;

event_idはすべてのリトライを通じて不変のまま維持されます。schema_versionにより、ペイロードのスキーマ進化を明示的にします。集約シーケンスの一意性制約により、2つのイベントが同じ論理位置を占めるのを防ぎます。シーケンスは集約と同じトランザクションおよびロック規則の下で割り当てる必要があり、タイムスタンプやリレーの処理順序を代替として使うのは安全ではありません。1つのトランザクションで複数のイベントを発行する場合は、意図した順序で連続したシーケンス値を割り当てます。

ポーリングリレーは、短いトランザクションで小さなバッチを取得(クレーム)する必要があります。FOR UPDATE SKIP LOCKEDで行を選択し、claimed_byclaim_untilを更新してコミットします。永続化されたリースにより、行ロックが解放された後も他のワーカーが同じ行を意図して処理するのを防ぎます。長時間保持するDBロックの外側でブローカーへ発行し、ブローカーがACKを返した後にのみ発行済みとしてマークします。リースの期限切れにより、ワーカー停止時のリカバリが可能になります。バックオフとavailable_atにより、送信先障害時に過度なリトライループが発生するのを防ぎます。ネットワークを介した発行処理の間DBトランザクションを開いたままにすると、競合が増加し、それでもブローカーとのアトミック性は得られません。

不可避なACKギャップが存在します。ブローカーがイベントEを永続的に受け付けた後、リレーがpublished_atを設定する前にクラッシュする可能性があります。復旧時、Eは再発行されます。発行前にマークすると、逆にイベントが消失するギャップが生じます。したがって、リレーは安全側(重複の可能性)を選択し、コンシューマー側で重複排除を行う必要があります。

ビジネス効果がDB内にあるコンシューマーの場合、同じトランザクション内で処理済みイベントIDを保存します。

sql
CREATE TABLE processed_events (
  consumer_name text NOT NULL,
  event_id uuid NOT NULL,
  processed_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (consumer_name, event_id)
);

コンシューマーはトランザクションを開始し、(consumer_name, event_id)に対してINSERT ... ON CONFLICT DO NOTHING RETURNINGを使用します。挿入によって行が返された場合にのみビジネスの変更を適用し、コミットします。行が返されない場合はイベントがすでに適用済みであることを意味するため、変更を繰り返すことなく重複としてACKを返せます。重複排除行とビジネス効果を別々のトランザクションで書き込むと、新たな二重書き込み問題が発生するだけです。また、重複排除レコードは、古いイベントがリプレイされる可能性のある期間以上保持される必要があります。

このローカルInboxは、非トランザクショナルな外部処理をアトミックにカバーすることはできません。決済APIの場合、プロバイダーの冪等性キーとしてevent_idを渡します。送信先が冪等性をサポートしていない場合は、別の永続的なコマンド/Outboxと照合(reconciliation)処理を導入するか、ドキュメント化された重複リスクを許容します。メール送信、Webhook、その他の取り消し不可能な呼び出しにも同じ注意が適用されます。

順序付けはビジネスの境界と一致させる必要があります。注文ごとに単調増加するシーケンスを割り当て、未発行のkk + 1が追い越さないようにし、ブローカーのパーティションキーとしてaggregate_idを使用します。クレームクエリは各集約の未発行シーケンスの最小値のみを選択するか、aggregate_idの安定したハッシュによってリレーの担当をシャーディングします。どちらの選択肢も、集約ごとに順序付けられた1つの発行レーンを保証する必要があります。コンシューマーは、ドメインに応じてシーケンスのギャップを拒否、バッファリング、または調整できます。単一のグローバルシーケンスを要求すると、無関係な注文まで直列化され、注文ごとの不変条件を満たす役にも立たずに可用性が低下します。

ポーリングは最も移植性の高いシンプルなリレー方式であり、アプリケーションDB内で所有権を可視化できますが、ポーリング間隔によってレイテンシとクエリ負荷がトレードオフになります。データ量が増加するにつれて、保留中インデックス、小さなバッチサイズ、リース、制限付きクリーンアップが重要になります。Change Data Capture(CDC)はDBログを追跡し、ポーリング負荷を抑え、多くの場合より低いレイテンシで挿入されたOutbox行をルーティングできます。ただし、コネクタのオフセット管理、DBログ保持、デプロイ、リカバリといった運用境界の複雑さが増します。また、任意のビジネステーブルの変更をキャプチャすると、意図したドメインイベントではなくストレージの変更が公開されてしまいます。明示的なOutboxテーブルを使用することで規約を安定させることができます。

運用設計により完成度が高まります。保留中行数、最古の未発行行の経過時間、配信スループットとエラー率、試行回数、期限切れリース数、ブローカーのACKレイテンシ、コンシューマーの重複排除数、隔離されたイベント数、テーブル肥大化を監視します。発行済み行は、リプレイおよび監査の保持期間を過ぎた後にのみ、制限されたバッチサイズでアーカイブまたは削除します。ポイズンイベントには明確なポリシー(リトライ、隔離、修復)が必要です。ポイズンイベントをスキップすると注文ごとの順序が崩れる可能性があるため、その集約の後続イベントを黙って処理し続けることはできません。

検証は正常系だけでなく境界部分を対象にする必要があります。DBコミット前、コミット後かつ応答前、リレーのクレーム中、発行前、ブローカー受付後かつpublished_at前、コンシューマーのビジネスコミット後かつACK前、クリーンアップ中に障害を注入します。テストでは次の4つの不変条件を検証する必要があります。

  1. コミットされたすべてのビジネス変更に対して、正確に1つの永続的なOutbox発行意図が存在すること
  2. ロールバックされたすべての変更に対して、Outbox発行意図が存在しないこと
  3. すべての永続化された発行意図は、リカバリ後に最終的に少なくとも1回発行されること
  4. 重複配信が発生しても、コンシューマーに見えるビジネス効果は1回のみ適用されること

また、リレーを十分な時間停止させてバックログを作成した後に再起動し、遅延の回復、注文ごとの順序性、制限されたDB負荷、アラート動作を検証します。ポイズンイベント、ペイロードバージョンの進化、古いイベントのリプレイ、保持期間境界でのクリーンアップもテストします。

質の高い模範解答

「データベースとブローカーはアトミックコミットを共有できないため、まずイベント発行意図をデータベーストランザクションの一部にします。注文行とイミュータブルなOutbox行を一緒にコミットします。トランザクションがロールバックされればどちらも存在しません。コミット直後にプロセスが停止しても、別のプロセスがOutbox行を参照できます。

リレーは短いDBトランザクションと期限付きリースを使用して保留中の行を取得し、発行を行い、ブローカーのACKを受け取った後にのみpublished_atを設定します。ネットワーク待ちの間DBロックを保持することはありません。ブローカーが受け付けた後、ステータス更新前にクラッシュするウィンドウが依然として存在するため、リレーが重複発行する可能性があります。しかし、これは正しい障害バイアスです。重複は回復可能ですが、イベントの喪失は回復できないためです。

各イベントには不変のUUIDが付与されます。DBを更新するコンシューマーは、ビジネス更新と同じトランザクション内で、コンシューマー名をキーとするテーブルにそのUUIDを挿入します。重複した場合は競合してno-op(何もしない)になります。コンシューマーが決済やメールのプロバイダーを呼び出す場合は、ローカルの重複排除トランザクションにそのリモート効果を含めることができないため、イベントUUIDを冪等性キーとして渡すか、別の永続的なハンドオフを使用する必要があります。

順序制御については、注文トランザクション内でシーケンスを割り当て、注文IDをパーティションキーとして発行し、その注文の未完了の保留イベントを後続シーケンスが追い越さないように制御します。グローバルな順序付けは強制しません。レイテンシと負荷の要件からCDCが妥当と判断されない限りはポーリングから始め、最古の保留滞留時間、リトライ、期限切れリース、重複率、ポイズンイベント、テーブルの肥大化を監視します。

最後に、すべての境界でプロセスを強制終了するテストを行います。要求される結果は、ロールバック時に意図が生成されないこと、コミット時に必ず意図が残ること、リカバリによってすべての意図が少なくとも1回発行されること、重複配信によって変更されるコンシューマー状態が1回のみであることです。Outboxは信頼性の高いハンドオフを解決します。リクエストの冪等性、コンシューマーの冪等性、スキーマの進化、照合処理は、引き続きシステム設計の明示的な要素として維持されます。」

よくある間違い

  • DBとブローカーを順番に呼び出す → どちらか一方の呼び出しだけが成功する可能性があります → ビジネスの変更とイベント発行意図を1つのローカルDBトランザクションでコミットする。
  • コミット後にメモリ内のパブリッシャーを呼び出す → クラッシュによりコールバックとその状態が失われます → 復帰する前に発行意図を永続化する。
  • Outboxが厳密に1回(exactly-once)の配信を提供すると主張する → ブローカー受付済み/ステータス未記録のギャップにより重複が発生します → 少なくとも1回(at-least-once)の発行を明記し、厳密に1回(exactly-once)のビジネス効果を設計する。
  • ブローカーのACK前に発行済みとしてマークする → クラッシュによりイベントが恒久的に失われる可能性があります → ACK後にのみ成功を記録し、再発行を許容する。
  • コンシューマーの変更処理と重複排除状態を別々に書き込む → コンシューマー側で同様の二重書き込みギャップが再現されます → 両方を1つのローカルトランザクションにまとめる。
  • ローカルInboxでリモート決済処理を保護できると考える → リモートの副作用はローカルトランザクションに参加できません → ダウンストリームの冪等性キー、永続的なハンドオフ、照合処理を使用する。
  • タイムスタンプを順序付けに使用する → クロックと並行処理の挙動では一意な因果順序を割り当てられません → トランザクション内で集約ごとのシーケンスを割り当て、集約をパーティションキーとして使用する。
  • クレームやリースの仕組みなしで多数のポーラーを実行する → 複数のワーカーが同じ行を競合して処理してしまいます → 短いクレーム、有効期限、小さなバッチ、保留行インデックスを使用する。
  • 発行済み行や重複排除行を即座に削除する → 遅延したリトライやリプレイによって過去の処理が再実行される可能性があります → 文書化されたリプレイおよび監査の保持期間に基づいてクリーンアップを設定する。
  • 発行成功のケースのみをテストする → 設計の保証価値はクラッシュウィンドウにおいてこそ発揮されます → すべての永続化境界の前後で障害を注入し、不変条件をアサートする。

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

フォローアップ1:送信先がブローカーではなく外部APIの場合はどうなりますか?

送信元トランザクションでOutboxコマンドを書き込む構成は同じです。ワーカーはevent_idを冪等性キーとしてAPIを呼び出し、レスポンスを記録します。タイムアウト時はリモートサービス側で処理が完了しているか不明なため、必ず同一のキーでリトライします。APIが冪等性も処理ステータス照会も提供していない場合、exactly-onceの効果は保証できません。照合処理を追加するか、重複リスクをビジネス規約として明記します。

フォローアップ2:ワークフローが複数のサービスやデータベースにまたがる場合はどうしますか?

Outboxは各サービスのローカルな状態遷移を確実に発行するものであり、複数サービスにまたがるワークフロー全体をアトミックにコミットするものではありません。ワークフローは、明示的な前進ステップ、冪等性、永続化された状態、補償トランザクション(compensating actions)を備えたSagaパターンとしてモデル化します。各Sagaステップは、自身のローカルトランザクションとOutboxを使用できます。すべてのDBをロールバックすると表現するのではなく、補償処理自体が失敗した場合の挙動を定義します。

フォローアップ3:面接官が厳密なグローバルイベント順序を求めた場合はどうしますか?

なぜ独立した集約間で単一の順序が必要なのか、そのためにスループットや可用性をどこまで犠牲にできるかを明確にします。単一のシーケンサーや単一のブローカーパーティションで全順序を確立できますが、直列化と単一障害点(ボトルネック)になります。ほとんどの注文ワークフローでは同一注文内の因果関係順序のみが必要であり、トランザクション内の集約シーケンスと集約パーティションキーを使用する方がはるかに低コストで実現できます。

フォローアップ4:CDCコネクタの障害からどのように復旧しますか?

コミットされたOutbox行が信頼できる唯一の情報源(Source of Truth)であり続けます。コネクタの遅延とDBログ保持の余裕を監視・アラート設定し、コネクタのオフセットを永続化し、最後に確認されたオフセットからの再起動をテストします。DBは障害復旧目標に十分な期間ログを保持する必要があります。それができない場合はスナップショットや制御されたバックフィルが必要です。コンシューマー側の重複排除により、重複範囲のリプレイも安全に行われます。

フォローアップ5:Transactional Outboxを避けるべきケースはどのような場合ですか?

使い捨てのテレメトリなど、イベントが明示的にベストエフォートでよい場合や、ダウンストリームシステムが安全にSource of Truthをポーリングでき、レイテンシ目標がそれを許容する場合は、よりシンプルな設計を採用します。両方のリソースが真に2相コミットをサポートしており、同期的なアトミック性が必須である場合は、結合度と可用性のコストを考慮した上でその選択肢を評価します。イベントソーシングも選択肢の1つですが、Source of Truthのモデル自体が変わるため、単に1つのハンドオフを回避するためだけに導入すべきではありません。

公開情報ソース

関連する質問