代表的な面接トピック

データベースとメッセージの一貫性を保つトランザクショナルアウトボックスはどのように設計しますか?

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

質問

注文サービスは1つのリクエスト内でデータベースを更新し、イベントを発行する必要がありますが、データベースとブローカーは分散トランザクションを共有できません。トランザクショナルアウトボックスを設計し、リレーの動作、重複メッセージ、順序保証、リカバリ、およびクリーンアップについて説明してください。

1. プロンプト

注文が作成された際、注文サービスは状態をコミットし、在庫および通知コンシューマー向けに OrderCreated イベントを発行する必要があります。データベースとブローカーの間には共有された2相コミットがありません。プロセスクラッシュによってイベントが暗黙的に失われることがなく、かつ重複配信やリレーのバックログを管理可能な状態に保てるよう、トランザクショナルアウトボックスを設計してください。

2. 制約と確認事項

  • 注文データとアウトボックステーブルは同一のローカルトランザクションデータベースを共有します。
  • ブローカーはグローバルな順序付けやトランザクション送信なしで、at-least-once(少なくとも1回)配信を提供します。
  • 結果整合性は許容されます。在庫コンシューマーは冪等である必要があります。
  • 集約ごとの順序付け、集約間の順序付けが必要かどうか、および保持/削除ウィンドウについて説明してください。

3. コアアプローチ

注文の変更と1件のアウトボックス行を同一のデータベーストランザクション内で書き込みます。この行は一意の event_id、集約キー、イベントタイプ、シーケンス、ペイロード、作成日時、および発行状態を保持します。コミットが成功するとビジネスデータと保留中のイベントの双方が永続化されます。ロールバックされた場合はどちらも公開されず、アプリケーションレベルの二重書き込みウィンドウが解消されます。

独立したリレーがアウトボックスをポーリングまたはサブスクライブし、ブローカーに発行した後に、その行を送信済みとしてマークします。ブローカーのアクノリッジメント(確認応答)とステータス更新の間にプロセスがクラッシュした場合、イベントが再発行される可能性があります。そのため、コンシューマーは exactly-once(厳密に1回)配信を前提とするのではなく、event_id を使用して重複排除を行います。

4. 参照実装

text
createOrder(command):
  begin transaction
  order = insert orders(...)
  event = insert outbox(
    event_id=uuid(), aggregate_id=order.id,
    aggregate_version=order.version, type="OrderCreated",
    payload=serialize(order), status="pending"
  )
  commit
  return order.id

relayBatch():
  rows = select pending outbox rows
         order by aggregate_id, aggregate_version, created_at
         for update skip locked limit BATCH_SIZE
  for row in rows:
    try:
      broker.publish(key=row.aggregate_id, id=row.event_id, body=row.payload)
      mark_sent(row.event_id)  // conditional update
    except transient_error:
      increment_attempts_and_schedule_retry(row.event_id)

consume(message):
  begin transaction
  inserted = insert processed_messages(message.id) on conflict do nothing
  if inserted:
    apply_business_change(message)
  commit

5. 信頼性と正確性

ビジネストランザクションがコミットされたものの、発行前にリレーがクラッシュした場合、保留中の行は後続のスキャンによって検出されます。発行は成功したもののステータス更新時にクラッシュした場合、次のパスで再発行されます。したがって、エンドツーエンドのセマンティクスは at-least-once となります。コンシューマー側の重複排除テーブルまたはビジネス上の冪等性キーは、コンシューマーのビジネス更新とトランザクションを共有する必要があります。

集約ごとの順序付けには、単調増加するバージョンと集約キーによるパーティショニングを使用できます。集約をまたいだグローバルな順序付けを約束してはなりません。SELECT ... FOR UPDATE SKIP LOCKED またはリースフィールドによって複数のリレーが同一の行を取得するのを防ぎますが、これらはコンシューマー側の冪等性を代替するものではありません。状態、リトライ時刻、作成日時にインデックスを作成し、テーブルの肥大化を抑えるために古い行をアーカイブまたは安全に削除します。

6. フォローアップと落とし穴

  • 「正常」な発行の直後に行を削除すると、アクノリッジメントが失われていた場合に回復不能なギャップが生じる可能性があります。まず送信状態を永続化するか、監査レコードを保持してください。
  • ブローカーのアクノリッジメントタイムアウトは、ブローカーがメッセージを受け取れなかったことを証明するものではないため、リトライは重複を許容する必要があります。
  • 最初にデータベースへ書き込み、その後のアプリケーションエラーハンドリング内でブローカーを呼び出す構成には、依然として二重書き込みの競合が存在します。try/catch でこれをアトミックにすることはできません。
  • アウトボックスとビジネステーブルがトランザクション境界を共有できない場合は、CDC、トランザクショナルメッセージングを使用するか、整合性保証を再定義してください。

7. 発展資料

ポーリングリレーと CDC リレーを比較してください。ポーリングは導入がよりシンプルですがスキャンとレイテンシが増加します。一方、CDC はログキャプチャと運用上の依存関係というコストと引き換えにレイテンシを低減します。ポイズンメッセージ、指数バックオフ、デッドレターキュー、保留時間のモニタリング、およびコンシューマーのスキーマ互換性について議論してください。

8. 面接の採点ポイント

二重書き込みウィンドウを特定できるか

候補者は、なぜ通常のローカルトランザクションではデータベース更新とブローカー送信を一緒にコミットできないのかを説明し、注文の変更とアウトボックス行を1つのトランザクションにまとめる必要があります。

at-least-once と冪等性を説明できるか

リレーのクラッシュによって重複が発生するウィンドウについて説明し、コンシューマーがビジネス更新と同一のトランザクション内でイベント ID による重複排除を行うように設計する必要があります。

順序付けと並行性を処理できるか

集約ごとの順序とグローバルな順序を区別し、パーティションキー、バージョン、ロック、またはリースによって並行取得がどのように制約されるかを説明する必要があります。

運用上の境界を網羅できるか

テーブル定義で終わらせず、リトライバックオフ、デッドレター、バックログアラート、アーカイブクリーンアップ、およびスキーマの進化を提案する必要があります。

公開情報ソース

関連する質問