代表的な面接トピック

バックエンド面接:Node.js SQLiteのchangesetを用いた制御された同期をどのように構築しますか?

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

質問

オフラインファーストのNode.jsサービスが、ローカルのSQLiteの変更を別のデータベースと同期する必要があります。コンフリクト、型変換、トランザクション、リトライを処理しながら、changesetの生成、転送、適用をどのように行いますか?

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

Node.js 24のnode:sqliteは同期APIを公開しています。DatabaseSyncは1つのSQLite接続を表し、createSession()はセッション開始後の変更を追跡し、session.changeset()はバイナリのchangesetを返し、ターゲット側は除外・置換・アボートを行うコンフリクトハンドラを指定してapplyChangeset()を呼び出すことができます。オフラインクライアントとサーバー間のインクリメンタルな同期プロトコルを設計してください。

課題となるのは、データベース変更の安全な伝播です。具体的には、主キー、変更前値のチェック、コンフリクトポリシー、冪等性、トランザクション境界、権限などが挙げられます。changesetは実行可能なSQLではなく、データベース間の自動マージでもありません。

面接官がテストしていること

優れた回答では、ソース側の生成、信頼性の高い配信、ターゲット側のアトミックな適用、ビジネスロジックに基づくコンフリクト判定が明確に分離されています。Sessionのライフタイム、changesetとpatchsetの比較、SQLITE_CHANGESET_DATAおよび関連するコンフリクトタイプ、重複適用、スキーマの進化、そして同期的なDatabaseSyncの処理がNode.jsのイベントループに与える影響について質問されることが予想されます。

「シリアライズして上書きする」アプローチでは、ターゲット側での独立した更新が失われます。常にSQLITE_CHANGESET_REPLACEを返すと、ビジネスデータが暗黙的に上書きされ、正確性の保証が失われます。

最初に明確にすべき質問

トポロジと権限

同期が一方向、双方向、マルチクライアント集約のいずれであるか、また各エンティティに対してどちらのコピーが正本(authoritative)であるかを判断します。双方が同一行を編集した場合、バージョン、デバイスID、またはビジネスイベントを使用して仲裁します。SQLiteのデフォルトのコールバックはビジネスルールではありません。

バージョンとスキーマのライフサイクル

ソースとターゲットがスキーマ、主キー、カラム型を共有していることを確認します。changesetはテーブル構造に依存します。古いchangesetを再生する前に、マイグレーションを完了させ、プロトコルバージョンを付与する必要があります。

レイテンシとセキュリティ境界

ペイロードサイズ、リトライウィンドウ、オフライン期間、機密フィールドを明確にします。バイナリペイロードには整合性、認証、リプレイ保護、暗号化が必要です。これらは信頼できるSQLではありません。

30秒の回答

「すべてのバッチにIDとソースバージョンのカーソルを割り当てます。ソース側でSessionを作成し、ローカルトランザクションをコミットしてchangesetをエクスポートします。サーバー側はスキーマ、署名、順序、冪等性を検証し、ターゲットトランザクション内でapplyChangesetを呼び出します。コンフリクトハンドラはデフォルトでアボートし、行、列、理由を記録します。除外や置換は明示的なビジネスロジックのポリシーによってのみ許可されます。バッチが成功すると永続化された状態が進み、失敗時は上限付きバックオフでリトライし、重複したバッチには以前の結果を返します。Nodeのイベントループをブロックしないよう、大規模な同期的SQLite処理はワーカーやキューの背後で実行します。」

ステップバイステップの解決策

ステップ1: 追跡可能なバッチを作成する

Sessionを作成した後、バッチ内に1つの明示的なローカルトランザクションスコープを含めます。batchId、ソースデバイス、開始カーソル、スキーマバージョン、changesetのハッシュ値、作成日時を記録します。session.changeset()からUint8Arrayをエクスポートし、無制限な履歴の蓄積を防ぐためにSessionをクローズまたは再利用します。

ステップ2: changesetまたはpatchsetを選択する

changesetには変更前の値情報が含まれており、ターゲットの行が期待通りであるかを確認するのに役立ちます。patchsetはサイズが小さいものの、変更前の値に関するコンテキストが少なくなります。帯域幅と監査要件に基づいて選択します。ペイロードサイズだけを理由に診断情報を失うべきではありません。

ステップ3: 冪等に検証して配信する

TLS、デバイスID、およびペイロード署名を使用します。サイズ、ハッシュ、スキーマバージョン、送信元を検証します。batchIdとソースカーソルをキーとする一意のレコードを保存し、すでに適用されたバッチの重複には記録済みの結果を返します。以降のバッチはカーソル順に適用し、欠落しているバッチはスキップせず待機キューに入れます。

ステップ4: トランザクション内で適用し、コンフリクトを分類する

ターゲットトランザクションを開始し、applyChangesetを呼び出します。コンフリクトハンドラはテーブル、主キー、コンフリクトタイプ、ターゲット値を監査バッファに収集します。バッチがアトミックにロールバックされるよう、デフォルトでSQLITE_CHANGESET_ABORTを返します。ポリシーで承認されたフィールドのみがOMITまたはREPLACEを返すことができ、その決定は再生可能なログに記録します。

ステップ5: ビジネスロジックのマージを定義する

フィールド単位でマージ可能なデータには、タイムスタンプ、単調増加バージョン、または集合の和集合を使用できます。金銭、在庫、権限は無条件のマージには適していません。これらは手動確認キューまたは補償イベントに送ります。決定後に元のchangesetを変更してはなりません。監査チェーンを維持するため、親バッチと決定理由を含む子バッチを生成します。

ステップ6: 型と整数の精度を処理する

Node.jsとSQLiteは異なる型セットをサポートしています。SQLiteのINTEGERがJavaScriptの安全な整数範囲を超え、readBigIntsが無効化されている場合、読み取り時にERR_OUT_OF_RANGEがスローされる可能性があります。BigInt、文字列、または明示的な範囲に標準化します。BLOB値にはUint8Arrayを使用し、任意のオブジェクトは拒否します。

ステップ7: 同期コストを制御する

DatabaseSyncの呼び出しは同期的に実行されます。長時間のトランザクション、巨大なchangeset、または頻繁なapplyChangesetの呼び出しは、イベントループをブロックする可能性があります。同期処理をワーカー、別プロセス、または制御されたキューに移動し、バッチあたりのペイロードと行数を制限し、適用の所要時間、コンフリクト、ロールバック、キューの滞留時間を監視します。

高品質な回答例

私は各同期バッチをイミュータブルなイベントとして扱います。デバイスはID、カーソル、スキーマバージョン、ハッシュを生成し、Sessionはコミットされたローカルトランザクションのみを対象とし、changesetは認証およびリプレイ保護されたチャネル経由で転送されます。サーバーは構造と順序を検証し、バッチIDによって重複を排除し、ターゲットSQLiteトランザクション内でapplyChangesetを呼び出します。

コンフリクトコールバックはデフォルトでアボートし、アトミックにロールバックした上で主キー、コンフリクトタイプ、ターゲット値を記録します。在庫、金銭、権限は一般的な置換ではなく、ビジネスロジックによるマージまたは補償に送られます。明示的に安全なフィールドのみが除外または置換されます。永続化されたバッチ状態はコミット後にのみカーソルを進めます。DatabaseSyncは同期的にブロックするため、大規模なバッチはワーカーで実行し、重複配信、欠落バッチ、スキーマ移行、整数オーバーフロー、プロセスクラッシュからの復旧をテストします。

よくある間違い

  • 間違い: 同期のたびにターゲットデータベースを上書きする。 → なぜ失敗するか: ターゲット側で行われた独立した更新が失われます。 → 対策: changesetを送信し、変更前の値を比較し、決定を記録します。
  • 間違い: すべてのコンフリクトに対してREPLACEを返す。 → なぜ失敗するか: ビジネスデータが暗黙的に上書きされます。 → 対策: デフォルトでアボートし、フィールドおよび不変条件ごとに置換を許可します。
  • 間違い: 冪等性キーなしでリトライごとに新しいバッチを生成する。 → なぜ失敗するか: 重複適用やカーソルのスキップにより、不要な副作用が繰り返し発生します。 → 対策: バッチID、ソースカーソル、アプリケーション状態を用いて重複を排除します。
  • 間違い: メインイベントループ上で巨大なchangesetを適用する。 → なぜ失敗するか: 同期的なDatabaseSync処理がHTTPやスケジュールされた処理をブロックします。 → 対策: ワーカーやキューに分離し、バッチサイズに上限を設けます。

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

フォローアップ1: どのような場合にpatchsetを使用しますか?

帯域幅が制限されており、ターゲット側に十分なコンテキストがあり、コンフリクトの監査要件が控えめな場合にpatchsetを使用します。コンフリクトの理由を説明したり、監査可能なマージを実行したりするために変更前値が必要な場合は、ペイロードが大きくなることを許容してchangesetを使用します。

フォローアップ2: ターゲットスキーマにカラムが存在しない場合はどうしますか?

互換性エラーとしてバッチを拒否します。アプリケーションにマッピングを推測させてはなりません。ターゲットのマイグレーションを実行し、スキーマバージョンと主キーを検証した上で再試行します。複数のバージョンが共存する必要がある場合は、元のペイロードを保持したままプロトコルバージョンに従って変換します。

フォローアップ3: コンフリクトコールバック内での副作用をどのように防ぎますか?

コールバックは構造化されたコンフリクトデータを収集し、定数の判定結果を返すのみにとどめます。外部サービスを呼び出したり、他のテーブルを変更したりしません。ロールバックによって外部状態の不整合が生じないよう、監査レコードの書き込みや通知はコミット後に行います。

フォローアップ4: なぜSQLを直接送信しないのですか?

SQLにはソース側の変更前値、スキーマコンテキスト、バッチ境界が含まれていません。リトライ時にそれが適用済みであるかを確実に判断できず、不正なステートメントがターゲットに到達するリスクもあります。SQLiteが生成したchangesetはターゲット側で各コンフリクトを処理できるため、制御された同期のプリミティブとしてより優れています。

フォローアップ5: 整数およびBLOBのプロトコル整合性をどのように検証しますか?

最大の安全な整数、範囲外の整数、負の数、NULL、UTF-8テキスト、バイナリペイロードに対するクロスランゲージのテストデータ(フィクスチャ)を作成します。それぞれについてSQLiteの型、Nodeの読み書きオプション、シリアライズされた表現を記録し、再生の前後でバイト列と値が一致することを検証します。

公開情報ソース

関連する質問