代表的な面接トピック

システムデザイン面接:CDC を用いて検索インデックスを確実に同期する方法

システム設計難しい
Offer.cc 編集チーム公開日 更新日

質問

商品データベースが検索の信頼できる唯一の情報源(Source of Truth)です。Insert、Update、Delete を検索インデックスへ確実に同期する準リアルタイムの CDC パイプラインを設計してください。初期スナップショット、追いつき処理、重複配信、コンシューマーの中断、スキーマの進化、インデックスのリビルドをサポートする必要があります。イベントが欠落しないこと、および古いイベントが新しいイベントを上書きできないことをどのように証明するか説明してください。

出題と適用可能な職種

商品データベースが検索の信頼できる唯一の情報源(Source of Truth)です。Insert、Update、Delete を検索インデックスへ確実に同期する準リアルタイムの CDC パイプラインを設計してください。初期スナップショット、追いつき処理、重複配信、コンシューマーの中断、スキーマの進化、インデックスのリビルドをサポートする必要があります。イベントが欠落しないこと、および古いイベントが新しいイベントを上書きできないことをどのように証明するか説明してください。

この設問は、バックエンド、データインフラ、検索プラットフォーム、システムデザインの面接に適しています。ソース側でコミット順序または同等のログ位置を公開でき、検索インデックスはリビルド可能な派生システムであると想定します。Kafka、Debezium、Elasticsearch は選択肢の例であり必須要件ではありません。まずはセマンティクスと障害境界を定義してください。

面接官が評価しているポイント

面接官は、「データベースの書き込みがコミットされたこと」と「インデックスで最終的に可視化されること」を分離し、各段階で可観測なコントラクト(契約)を持っているかを見ています。優れた回答では、イベントキー、操作種別、トランザクションまたはログ位置、スキーマバージョンを定義し、安全ではないタイムスタンプのポーリングではなくログベースのキャプチャを選択します。また、スナップショットの重複、at-least-once(少なくとも1回)の再生、キーごとの順序保証、削除の廃棄マーカー(tombstone)、エイリアスの切り替えを適切に扱います。データベース、キュー、検索ボックスだけを描いた図では、チェックポイント、再生、照合ルールがないため、信頼性を証明できません。

回答前に確認すべき質問

  • 鮮度の目標(Freshness target)はどれくらいか? コミットから5秒後か、それとも数分か?これによりバッファリング、アラート、フォールバックの許容値が決まります。
  • どのような順序保証が必要か? 通常は単一商品がソースのコミット順序に従う必要があり、商品間のグローバルな順序は不要です。複数テーブルにまたがる不変条件には、集約イベントが必要になる場合があります。
  • 削除はハードデリートかソフトデリートか? ハードデリートには永続化された tombstone または削除イベントが必要であり、ソフトデリートにはインデックス付きドキュメント内での可視性ルールが必要です。
  • スナップショット取得中も書き込みは継続されるか? 継続される場合、スナップショット位置を記録し、スナップショット期間をカバーするためにその位置以降の変更を保持する必要があります。
  • スキーマはどのように進化するか? 古いコンシューマーは追加されたフィールドを無視できるか?削除または型変更されたフィールドには、二重読み込み/書き込み、新しいイベントバージョン、またはリビルドが必要か?
  • リビルドは無停止(ゼロダウンタイム)である必要があるか? 必要な場合は、新しいインデックスに書き込み、エイリアスをアトミックに切り替え、古いコンシューマー用の再生ポイントを保持します。

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

「プライマリデータベースを信頼できる情報源として扱い、コミットされた Insert、Update、Delete をそのログからキャプチャします。各イベントにはキー、操作種別、ソース LSN、トランザクション ID、スキーマバージョン、および変更前後の値を保持させます。初期ロードは整合性のあるスナップショットから開始し、そのログ位置を記録します。その位置以降のイベントは同じ再生可能なストリームを通じて継続し、コンシューマーは商品キー単位で冪等に書き込みます。

配信セマンティクスは at-least-once です。チェックポイントはインデックスへの副作用(書き込み)が成功した後にのみ進められ、重複イベントはキー+バージョンまたは LSN の条件によって拒絶されます。停止したコンシューマーはチェックポイントから再開します。遅延(lag)、最も古いイベントの経過時間、スロットの保持状況、データベースとインデックス間のバージョンサンプリングをメトリクスとして公開します。リビルド時は同じストリームを新しいインデックスに書き込み、追いつくのを待ってからエイリアスをアトミックに切り替えます。」

ステップごとの詳細解説

ステップ 1: イベントコントラクトとキャプチャ境界の定義

ログベースの CDC は、コミットされたデータベースの変更を読み取り、ソースの順序またはログ位置を保持します。PostgreSQL では、ロジカルデコーディングが WAL から変更を抽出し、レプリケーションスロットが元の順序で再生可能なストリームを表します。スロットは必要な WAL を保持するため、スロットの保持状況を監視する必要があります。コネクタが停止するとプライマリのディスクを消費し尽くす恐れがあります。

各イベントには entity_idoperationsource_positiontransaction_idschema_versionbeforeafter を含める必要があります。メッセージの到着時刻をビジネス上の順序として使うのではなく、監査と重複排除のために source_position を使用します。1つのトランザクションが複数の商品を変更する場合、インデックスがそれらを1つずつ公開してよいか、ストリームがトランザクション境界で集約する必要があるかを決定します。

ステップ 2: 単一の位置情報でスナップショットとストリームを接続する

危険な重複は、ストリームが同一キーに対してより新しいイベントを配信している最中に、スナップショットが古い行を読み取ってしまうことです。スナップショット開始時にログ位置 P0 を記録します。スナップショットのドキュメントは開始時点の状態を表し、P0 以降のイベントは保持され、スナップショット結果の適用後に適用されます。

text
P0 = captureSourcePosition()
startStreaming(after=P0)
for row in consistentSnapshot():
  indexUpsert(row, version=P0)

for event in stream:
  if event.position > indexedVersion[event.key]:
    applyIdempotently(event)
  checkpoint(event.position)  # only after index write succeeds

実際のコネクタでは、READ イベントと UPDATE イベントの衝突を解決するために、スナップショットウィンドウ、主キーのチャンク分割、バッファを使用する場合があります。面接では、これにより古いスナップショットの行がコミット済みの更新を上書きするのを防げることを説明してください。「スナップショット完了後にストリームを開始する」だけでは不十分です。

ステップ 3: 順序保証、冪等性、リカバリを明示する

異なる商品は並行して処理しつつ、単一商品のイベントがソースの順序を維持できるように、entity_id でパーティショニングします。イベントがより古い位置のものしか上書きできないように、外部バージョン、条件付き書き込み、またはバージョン付きドキュメントを使用します。DELETE は tombstone またはバージョン付き削除を書き込み、遅れて到着した UPDATE がドキュメントを復活(resurrect)させないよう十分なメタデータを保持します。

チェックポイントは「このイベントの副作用が永続的に完了した」ことを意味します。メッセージを取得した直後や HTTP リクエストを送信した直後にコミットしてはなりません。インデックス書き込み後かつチェックポイント更新前にクラッシュすると重複が発生するため、ターゲットへの書き込みは冪等でなければなりません。インデックス書き込みの前にチェックポイントが進むとデータが失われます。検証可能なコミット境界を定義するか、再生可能なインデックス処理と照合(reconciliation)を組み合わせて使用してください。

ステップ 4: 再生、スキーマの進化、リビルドへの対応

単一コンシューマー用のカーソルを複数のコンシューマーで競合させるのではなく、各コンシューマーに独立したスロットまたは同等の進捗管理を割り当てます。再生を行う前に、ターゲットインデックスのバージョンポリシーを凍結またはラベル付けし、再生範囲を制限し、古いイベントが古いバージョンしか書き込めないようにします。互換性ルールを定義します。多くの場合、古いコンシューマーは追加されたオプショナルフィールドを無視できますが、削除または型変更されたフィールドには、新しいイベントバージョン、二重読み込み/書き込み、または再インデックスが必要になる場合があります。

リビルドのために稼働中のインデックスを空にしてはなりません。新しいインデックスを作成し、適用位置が切り替えゲートに達するまで同じスナップショット位置から再生します。エイリアスをアトミックに切り替え、同じストリームの消費を続けます。切り替えに失敗した場合は、古いエイリアスと新しいインデックスの進捗を維持したまま修復し、再度追いつかせます。新しい開始位置を推測して設定してはなりません。

ステップ 5: メトリクスと照合による信頼性の証明

CDC の読み取りレイテンシ、パーティションのバックログ、最も古いイベントの経過時間、スロットの WAL 保持量、各コンシューマーのチェックポイント、インデックス書き込みの失敗、リトライ、デッドレター、およびデータベースとインデックス間の主キーサンプリングによるバージョン差分を追跡します。削除操作については、独自の tombstone および残存ドキュメントのカウンターを設ける必要があります。

コンシューマーの停止、重複配信、パーティションをまたぐメッセージの並べ替え、スナップショット中のキー更新、遅延削除の配信、スキーマ変更、プライマリのフェイルオーバーをテストします。照合ツールは、現在のデータベースバージョンを再読み込みし、指定した位置までストリームを再生して、最小限の不整合サンプルを出力する必要があります。キューが空であることだけでは、イベントがスキップされていないことや、インデックス書き込みが失敗していないことの証明にはなりません。

質の高い模範解答

「まずコントラクトを定義します。データベースが正本(authoritative)であり、インデックスはリビルド可能です。目標はコミットから5秒以内に検索可能にすることとし、商品単位でソースの順序を保持し、商品間のグローバルな順序は保証しません。イベントにはキー、Insert/Update/Delete、LSN、トランザクション ID、スキーマバージョン、変更前後の値を保持します。

ログベースの CDC を使用します。スナップショット開始時に P0 を記録し、P0 以降の消費を継続します。スナップショットの READ はストリームの UPDATE と衝突する可能性があるため、スナップショットウィンドウまたは同等のキー・バージョンルールによって古い READ を破棄する必要があります。単にスナップショット終了後にストリームを開始するだけではギャップが生じます。コンシューマーはキーでパーティショニングし、外部バージョンまたは条件付き書き込みを使用します。削除は tombstone バージョンを保持し、遅れた更新によってドキュメントが復活しないようにします。

チェックポイントは、インデックスへの副作用が成功した後にのみ進めます。したがって、クラッシュ時には at-least-once の再生が発生しますが、ターゲット側で冪等に許容します。コネクタのダウン中はスロットが保持する WAL を監視し、復旧後は最後の安全な位置から再開します。リビルドの際は、同じストリームを新しいインデックスに書き込み、追いつくのを待ってからエイリアスをアトミックに切り替えます。

受け入れ基準はキューが空であること以上を求めます。スナップショット中の更新、重複や並べ替え、遅延削除、コンシューマーのクラッシュ、スキーマ変更、プライマリのフェイルオーバーを注入します。その上で、データベースのバージョン、インデックスのバージョン、チェックポイントをキー単位で比較します。主要なシグナルは、最古イベントの経過時間、WAL 保持量、インデックスの遅延、デッドレター、不整合サンプルであり、ギャップが発生した場合は保存されたログ位置から再生可能でなければなりません。」

よくある間違い

  • CDC として更新タイムスタンプをポーリングする → クロックの精度、クロックのずれ、長時間トランザクションによって変更が見落とされる可能性がある → コミットログを読み取るか、証明可能なカーソルを使用する。
  • スナップショットとストリームを独立して開始する → 古いスナップショットの行が新しいイベントを上書きする可能性がある → P0 を記録し、READ/UPDATE の衝突を解決する。
  • インデックスへのリクエスト送信直後にチェックポイントを進める → クラッシュ時にデータが失われる期間が生じる → 検証可能な副作用が発生した後にのみ進める。
  • at-least-once を exactly-once(厳密に1回)として扱う → 重複は依然として発生する → 条件付きバージョン書き込みと冪等な削除を使用する。
  • メッセージの到着順で順序付けする → リトライによってネットワーク上で順序が入れ替わる → キーでパーティショニングし、ソースの LSN またはバージョンを使用する。
  • バージョン付き tombstone なしでインデックスから削除する → 遅れて届いた更新によってドキュメントが復活する → 削除バージョンのメタデータを保持する。
  • 1つのレプリケーションスロットを独立したコンシューマー間で共有する → 1つのコンシューマーが変更を消費してしまい、他のコンシューマーが受信できなくなる可能性がある → コンシューマーごとに1つのスロットを使用するか、明示的なブロードキャスト層を使用する。
  • リビルドのために稼働中インデックスをクリアする → 再生が失敗した場合に大規模な検索障害を引き起こす → 新しいインデックスを追いつかせてから、エイリアスをアトミックに切り替える。
  • キューが空であることを正常性の証明とする → スキップされたイベントや書き込み失敗があってもキューは空になり得る → 位置、バージョン、プライマリのサンプルを照合する。

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

フォローアップ 1: インデックスは、商品とその在庫の両方を更新するトランザクションを1行ずつ公開してもよいですか?

はい、ビジネス側が中間的な検索状態を許容できるのであれば可能です。両方を同時に反映した結果が必要な場合は、トランザクション境界を保持してドキュメント更新前に集約するか、コミット済みの検索可能なプロジェクションを1つ構築します。インデックス間で『ほぼ同時』に書き込んだとしても、アトミックではありません。

フォローアップ 2: 長時間の停止により、レプリケーションスロットが過剰な WAL を保持してしまいました。どのように対処しますか?

まずプライマリを保護します。アラートを発報し、さらなる書き込みを制限するかコンシューマーを縮退させ、スロットにまだ使用可能な開始位置があるか確認します。スロットが無効化された場合、新しいスロットを作成して継続性があると思い込んではなりません。欠落した LSN はすでに失われている可能性があります。バックアップまたはフルスナップショットからリビルドし、ギャップを照合してください。

フォローアップ 3: ストリームはパーティション内でのみ順序付けされています。商品間で検索結果のランキングをどのように行いますか?

検索はインデックスの現在のバージョンを読み取るものであり、グローバルなイベント順序を前提とすべきではありません。ランキングフィールドに一貫した時間が必要な場合は、ソースのコミット時刻とバージョンルールを併用するか、アグリゲーターに明示的な一時的スキュー許容量を持たせて安定したランクキーを生成させます。パーティションをまたぐグローバルな順序付けはスループットを低下させるため、プロダクトの不変条件によって正当化される必要があります。

フォローアップ 4: スキーマのフィールドが削除された場合、古いインデックスはどうなりますか?

まず両方のイベントバージョンを読み取れるコンシューマーをデプロイし、次に古いフィールドの出力を停止し、バックログと再生ウィンドウがクリアされたことを確認した上で、マッピングの移行またはリビルドを行います。そのフィールドが認可や分析の意味を変更する場合、単に JSON プロパティを削除するだけでは不十分です。バージョン管理されたイベントとロールバックパスを保持してください。

公開情報ソース

関連する質問

関連面接ツール

システム設計の回答には「回答する」を使用

まず要件を明確にし、スケール、アーキテクチャ、コンポーネント選定、トレードオフの順に進めます。

ツールを見る