代表的な面接トピック

バックエンド面接:N+1クエリ問題をどのように診断し、修正するか?

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

質問

注文一覧のエンドポイントが50件の注文を取得し、レスポンスのシリアライズ中に各注文の顧客情報を読み取った結果、51件のSQL文が発行されています。各SQL文は高速ですが、ページサイズが大きくなるにつれてリクエストのレイテンシが増加します。N+1が原因であることをどのように証明し、解決策を選択し、再発を防止しますか?

問題と適用可能なコンテキスト

注文一覧エンドポイントが、まず50件の注文の1ページを取得します。レスポンスのシリアライズ中、ORMは注文ごとに個別にorder.customerをロードします。本番環境のトレースには、1件の注文クエリと50件の顧客クエリが含まれています。個別のステートメントはいずれも遅く見えませんが、1回のリクエストで51回のデータベース往復(ラウンドトリップ)が発生しています。ページサイズが2倍になると、ステートメント数とリクエストレイテンシもそれに伴って増加します。

各注文は1人の顧客に属し、レスポンスには顧客の表示名のみが必要であり、エンドポイントは現在の順序、認可フィルター、ページネーションのセマンティクスを維持する必要があると仮定します。これらの数値は診断をテスト可能にするための面接上の前提条件です。核心となる問いは、単一の低速なSQL実行計画をチューニングする方法ではなく、アプリケーションのアクセスパターンの問題をどのように検出するかです。

対象となる役割は、リレーショナルデータベースとORMを扱うバックエンドエンジニアです。完全な回答では、単一の結合(JOIN)クエリ、2クエリによるselect-inバッチ、ロードのデフォルト設定の変更を比較する必要があります。また、1対多のリレーションシップ、トランザクションの一貫性、可観測性(オブザーバビリティ)、およびタイミングに依存しないクエリ数のリグレッションチェックも網羅する必要があります。

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

第1の評価基準は、候補者がリクエスト境界で作業量を測定しているかどうかです。N+1問題は、個々には効率的なインデックス付きクエリが多数集まって構成されることがあります。スロークエリログのみを確認したり、1回の顧客検索に対してEXPLAINを実行したりするだけでは、この乗数効果を見落とす可能性があります。有用な証拠となるのは、ステートメントをリクエストごとにグループ化し、同じ呼び出し元から同じ正規化された検索が繰り返されていることを示すトレースまたはクエリログです。

第2の評価基準は、正しい増加モデルです。N行の親行に対して、ナイーブなパスでは1つの親クエリに加えて親ごとに1つの関連行クエリが実行されます。

text
Q(N) = 1 + N
Q(50) = 51

select-inバッチを使用すると、通常は1つの親クエリと1つの関連行クエリに変更されるため、テスト対象のページサイズではカウントが2のまま維持されます。IDリストをB個のバッチに分割する必要がある場合、カウントは1 + Bになりますが、親1件ごとに1回増加することは依然としてありません。

第3の評価基準は、リレーションシップのカーディナリティとレスポンスの要件に基づいてロード形状を選択できることです。少数の狭いカラムを持つ多対1の顧客検索は、多くの場合JOINに適しています。大規模な1対多のコレクションは、結果行を増殖させ、親カラムを重複させる可能性があるため、親ページクエリの後にバッチ化された子クエリを実行する方が安全です。「すべての場所でEager loadingを有効にする」というのは設計ではありません。一部のORMはEagerな関連付けに対してもセカンダリセレクトを発行する場合があり、グローバルなEager loadingはこのエンドポイントが決して返さないデータまで取得してしまう可能性があります。

最後に、面接官は動作が維持されていることの証明を求めています。クエリ数の改善は、テナントフィルターの欠落、ページ境界の変更、不安定な順序付け、一貫性のない読み取り、またはJOINによって生じる余分な行を正当化しません。優れた回答は、データベースの作業量とレスポンスの等価性の両方を検証します。

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

  • リレーションシップへのアクセスはどこで発生しているか? リポジトリから返された後にシリアライゼーション、テンプレート、ロギング、またはマッパーがプロパティにアクセスしている場合、クエリの発生源は見かけのループの外側にあります。修正は実際のアクセスパスに対応する必要があります。
  • リレーションシップのカーディナリティは何か? 多対1のデータは、1つの注文を複数の行に増殖させることなくJOINできます。1対多の子コレクションは、ページネーションとペイロードサイズのリスクを変化させます。
  • どの関連フィールドが必要か? 表示名のみであれば、狭いプロジェクションで対応できます。顧客エンティティ全体とすべての関連付けをロードすると、クエリ数が減ってもオーバーフェッチが発生します。
  • 関連IDは重複しているか? リクエストスコープのアイデンティティマップによって重複する検索を減らせる可能性がありますが、ほとんどのIDが一意である場合、カウントを制限できません。キャッシュが解決すると仮定するのではなく、測定してください。
  • ページネーションはどのように適用されているか? 1対多のJOINまたは子のロードを行う前に、親の行を決定論的な順序で選択する必要があります。そうしないと、行の増殖によって表示される親が変化する可能性があります。
  • 2回の読み取りは1つのスナップショットを共有する必要があるか? JOINは1つのステートメントです。親クエリに続く子クエリは、デフォルトの分離レベルの動作下では同時実行された変更を観測する可能性があります。ポイントインタイムの一貫性が重要である場合は、適切なトランザクションスナップショットまたは単一ステートメントの形式を使用してください。
  • ORMは実際に何を生成しているか? eager、include、prefetch、split queryなどの名前は、特定のステートメント数を保証するものではありません。デプロイされたバージョンで発行されるSQLを検査してください。

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

「データベースのスパンをリクエストごとにグループ化し、1回のページクエリの後に同じ正規化された顧客検索が50回行われていることを確認します。次にページサイズを変更します。10件、20件、40件のページに対して11回、21回、41回とカウントが増加すれば、各検索が高速であってもクエリの線形増幅が証明されます。この多対1の表示名フィールドに対しては、狭いJOINと2クエリのselect-inバッチを比較します。グローバルなEagerのデフォルト設定は、オーバーフェッチの原因となり単一ステートメントを保証しないため避けます。大規模な1対多のリレーションに対しては、行の増殖を避けるために親を先にページネーションし、子をバッチ処理します。最後に、クエリバジェットが一定であることをアサートし、レスポンスのIDと順序を比較し、ロールアウト後にリクエストレベルのクエリ数とレイテンシを監視します。」

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

ステップ1:リクエスト境界で増幅を証明する

データベーススパンにリクエストIDまたはトレースIDを付与し、パラメータ値を置換してSQLを正規化し、呼び出し元ごとにグループ化します。疑わしいトレースは構造的に次のようになります。

text
1 × SELECT id, customer_id, created_at, total_cents FROM orders ... LIMIT ?
50 × SELECT id, display_name FROM customers WHERE id = ?

繰り返されるフィンガープリントと線形スケーリングにより、N+1は単一の高コストなステートメント、ロック待ち、コネクションプールの待機キュー、または遅いシリアライザーと区別されます。ステートメントの実行時間だけでなく、データベースの合計所要時間とラウンドトリップ数も記録します。1ミリ秒のクエリが50回実行されることは、51ミリ秒のクエリが1回実行されることと同等ではありません。各ラウンドトリップは接続、プロトコル処理、スケジューラ時間も消費するためです。

10、20、40の制御されたページサイズでリクエストを繰り返します。11、21、41というカウントは強力な因果関係のシグネチャです。リレーションシップフィールドを一時的に削除して余分なクエリが消滅することを確認できれば、どのプロパティアクセスがロードをトリガーしているかが確定します。この実験は、すでにインデックスが作成されている主キー検索にさらにインデックスを追加するよりも有用です。

ステップ2:ロードを変更する前に必要な結果を定義する

レスポンス契約を明文化します。順序付けられた注文ID、カーソルまたはページ境界、許可されたテナント、および正確な顧客フィールドです。また、存在しない顧客や削除された顧客がどのように表示されるかも決定します。これにより、クエリの最適化がデータ契約の変更に暗黙的になってしまうのを防ぎます。

バッチ処理のパスにも認可および論理削除(ソフトデリート)の述語を保持してください。元のリレーションシップローダーがテナントスコープを強制していた場合、テナントスコープを省略した手書きのWHERE id = ANY(...)クエリはデータ漏洩の原因になる可能性があります。クエリ数は受け入れ基準の1つに過ぎません。

ステップ3:狭いJOINとselect-inバッチの選択

必須の多対1リレーションかつ狭いレスポンスの場合、単一の結合ステートメントがシンプルです。

sql
SELECT
  o.id,
  o.created_at,
  o.total_cents,
  c.id AS customer_id,
  c.display_name
FROM orders AS o
JOIN customers AS c
  ON c.id = o.customer_id
 AND c.tenant_id = o.tenant_id
WHERE o.tenant_id = $1
ORDER BY o.created_at DESC, o.id DESC
LIMIT $2;

o.idによるタイブレーカー(順序決定条件)により、順序付けが決定論的になります。注文がその顧客レコードよりも正当に長く存続する可能性があり、既存の契約がその注文を返す場合は、代わりにLEFT JOINを使用します。

2クエリのバッチは親のページネーションを独立して保持し、JOINの幅やコレクションのカーディナリティによって結果が肥大化する場合に適しています。以下のTypeScriptの例は、IDの重複を排除し、必要なカラムのみをロードして、メモリ内でマップします。

ts
interface OrderRow {
  id: string
  customerId: string
  createdAt: Date
  totalCents: number
}

interface CustomerRow {
  id: string
  displayName: string
}

const orders = await loadOrderPage(tenantId, limit)
const customerIds = [...new Set(orders.map((order) => order.customerId))]
const customers = await loadCustomersByIds(tenantId, customerIds)
const customerById = new Map(customers.map((customer) => [customer.id, customer]))

return orders.map((order) => ({
  ...order,
  customer: customerById.get(order.customerId) ?? null,
}))

リポジトリのloadCustomersByIdsは、通常のページに対しては1つの集合ベースの述語を使用し、異常に大きいIDリストは制限されたバッチに分割する必要があります。重複排除は転送されるパラメータを削減しますが、メインの修正ではありません。メインの修正は、関連データのロードを行ごとのアクセスパスの外側に移動することです。

ステップ4:ページネーションを壊さずに1対多リレーションを処理する

各注文が多数の明細行(line items)も返すと仮定します。注文、顧客、明細行を結合すると、明細行ごとに1行が出力され、注文のカラムが重複する可能性があります。そのJOINの後にLIMIT 50を適用すると、50件の個別な注文ではなく、結合された行が制限されてしまう可能性があります。1つのJOINで複数のコレクションをロードすると、それらが互いに掛け合わされる可能性があります。

まず安定した順序で50件の親注文を選択し、次にorder_idがその親IDセットに含まれるすべての明細行を取得します。明細行をorder_idでグループ化し、元の親の順序で紐付けます。これが、公式のORMドキュメントが単一のユニバーサルなEager loadingスイッチではなく、joined、subquery、select-in、split-query戦略を提供する実用的な理由です。

ステップ5:近道としてのグローバルなロード設定変更を拒否する

すべてのリレーションをlazyからeagerに変更することは、問題を解決するのではなく移動させるだけになる可能性があります。顧客情報を必要としないエンドポイントでもオーバーフェッチが発生するようになります。また、eagerな関連付けをJOINフェッチしないクエリは、一部のORMの動作では依然としてセカンダリセレクトを引き起こす可能性があります。幅の広いオブジェクトグラフは、予測が困難な大規模なJOINや循環参照を引き起こす可能性もあります。

エンドポイント固有のプロジェクションまたは明示的なロードプランを選択してください。開発およびテスト環境では、予期しないlazy SQLが発生した際に例外を発生させるORMオプションがあれば利用します。これにより、隠れたデータベースアクセスが、レスポンスが組み立てられる境界で目に見える障害に変わります。

ステップ6:クエリ形状、セマンティクス、および本番環境への影響を検証する

結果が空の場合、1行の場合、顧客IDが重複する場合、顧客IDがすべて一意の場合、オプションの顧客が存在しない場合、および許容される最大ページサイズの回帰テストマトリクスを構築します。レスポンスの順序、ID、nullの動作、テナントの分離、および一定のクエリバジェットをアサートします。2クエリプランの場合、選択したバッチ制限の下で、10件のページと40件のページの両方が2つのステートメントを使用する必要があります。

次に、本番相当の代表的なデータを使用して、リクエスト全体のレイテンシ、リクエストごとのデータベーススパン、返された行数とバイト数、コネクションプールの占有率、およびデータベース負荷を比較します。51のステートメントを1つに削減したとしても、巨大な重複ペイロードを返すJOINは、別の指標ではリグレッションになる可能性があります。エンドポイントごとにロールアウトし、クエリ数の分布を監視し、lazy loadingが再発した場合に呼び出し元を特定できるようにトレースサンプルを保持します。

質の高い模範回答

「証拠は、単一の低速な実行計画ではなく、クエリの増幅を示しています。まず1つのリクエストトレースから始め、正規化されたSQLを呼び出し元ごとにグループ化します。50件のページで1件の注文クエリと50件の顧客主キー検索が表示され、10件、20件、40件のページで11件、21件、41件のステートメントが生成される場合、データベースの作業が親行ごとに1回増加していることを示すことができます。

修正する前に、契約を維持します。テナント述語、注文ID、決定論的な順序付け、ページ境界、必要な顧客フィールド、および顧客が存在しない場合の動作です。これは狭い多対1の検索であるため、JOINが良い最初の候補になります。2クエリのselect-inロードも有効です。注文ページを取得し、顧客IDを重複排除し、それらの顧客を1つのセットクエリでロードして、IDでマッピングします。生成されたSQL、ペイロードの幅、一貫性のニーズから両者を比較して選択します。

リレーションが大規模な1対多のコレクションである場合は、まず注文をページネーションし、2番目のクエリで明細行をバッチロードします。これにより、結合行の増殖によってページネーションが変わってしまうのを防ぎます。すべての関連付けをグローバルにeagerにはしません。オーバーフェッチの原因となり、ORMのクエリ形状によっては依然としてセカンダリセレクトが発行されるためです。

リグレッションテストでは、空、ID重複、ID一意、リレーション欠落、最大ページの各ケースを実行します。同一のID、順序、認可、nullの動作に加えて、一定のステートメントバジェットをアサートします。ロールアウト後は、単一の低速ステートメントだけでなく、リクエストごとのデータベーススパンと合計レイテンシを監視します。これにより、パフォーマンスの修正と結果の不変性の両方が証明されます。」

よくある間違い

  • 繰り返される顧客検索にインデックスを追加する → 各検索はすでに主キーインデックスを使用している可能性があり、リクエストは依然として注文ごとに1回の往復を実行します → アクセスパターンを測定して変更してください。
  • グローバルなEager loadingを有効にする → 無関係なエンドポイントでオーバーフェッチが発生し、ORM固有のeager動作によって依然としてセカンダリステートメントが発行される可能性があります → エンドポイント固有のプロジェクションまたはロードプランを使用してください。
  • すべてのリレーションをJOINする → 1対多のコレクションは行を増殖させ、親データを重複させ、ページ境界を破損させる可能性があります → まず親をページネーションし、大きなコレクションはバッチ処理してください。
  • 修正手段としてプロセス全体のキャッシュを使用する → コールドIDや一意のIDは依然として線形クエリを生成し、古いデータやテナント間のデータ混入が新たなリスクになります → キャッシュヒットとは無関係にクエリ数を制限してください。
  • 低速なステートメントのみをカウントする → 何十もの高速なクエリは、往復と接続を消費しながら、閾値ベースのスロークエリログをすり抜けます → リクエストとフィンガープリントごとにスパンを集計してください。
  • バッチクエリでセキュリティ述語を削除する → 最適化によって別のテナントの関連行がロードされる可能性があります → 認可フィルターと論理削除フィルターを明示的に保持してください。
  • レイテンシの低下のみをアサートする → タイミングテストはノイズが多く、ウォームキャッシュでパスしてしまう可能性があります → 一定のクエリバジェットとレスポンスの等価性をアサートし、レイテンシは別途測定してください。
  • 2つのクエリが1つのスナップショットと等しいと仮定する → 一般的な分離動作では、ステートメント間で同時更新が発生する可能性があります → 契約がポイントインタイムの一貫性を要求する場合は、トランザクションスナップショットまたは単一ステートメントを選択してください。

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

フォローアップ1:どのような場合に2クエリのバッチよりもJOINが優れていますか?

JOINは、狭い多対1または1対1のリレーションで、単一ステートメントのスナップショットセマンティクスが重要であり、行の増殖が制限されている場合に魅力的です。バッチは、親のページネーションを独立して維持する必要がある場合、関連データがコレクションである場合、またはJOINによって幅の広い親カラムが重複する場合に魅力的です。発行されるSQLと返されるバイト数を検査してください。ステートメント数だけで決まるものではありません。

フォローアップ2:50件の注文が3人の顧客のみを参照している場合はどうなりますか?

リクエストスコープのアイデンティティマップによってナイーブなパスが4ステートメントに削減される可能性がありますが、それはデータ依存のままです。3つのIDを重複排除して1つのセットクエリを発行することで、計画されたカウントが2になるようにします。正確性やテナントの分離をリクエスト間のキャッシュに依存しないでください。

フォローアップ3:GraphQLスタイルのネストされたリゾルバーでN+1をどのように捕捉しますか?

1回のリクエスト実行中に関連キーを収集し、フィールドを解決する前にリクエストスコープのバッチを発行します。行を元のキーシーケンスにマッピングし直すことで結果の順序を保持し、欠落しているキーを明示的に表現します。回帰テストでは、複数の親に対してネストされたフィールドをリクエストし、制限されたステートメント数をアサートする必要があります。フィールドを省略した場合は関連クエリが回避されるべきです。

フォローアップ4:バッチに1つのクエリが保持すべき以上のIDが含まれている場合はどうなりますか?

重複排除されたIDを、データベースとドライバーの制約に基づいて選択された制限付きチャンクに分割します。モデルはB個のチャンクに対して1 + Bステートメントになるため、テストでは無条件の2ではなく期待される制限値をアサートする必要があります。通常のエンドポイントページで多くのチャンクが必要な場合は、ページサイズを縮小するか、データ構造を再検討してください。

フォローアップ5:クエリ数は固定されましたが、レイテンシがほとんど改善しません。次はどうしますか?

別の修正を提案する前に、データベース時間、ネットワーク時間、シリアライゼーション、返された行数とバイト数、ロック待ち、およびプールキューイングを比較します。新しく作成したJOINまたはバッチステートメント自体にインデックスが必要な場合や、データ量が多すぎる場合、あるいはエンドツーエンドのレイテンシを支配していない可能性があります。線形増幅を排除できるのであればN+1の修正は維持しつつ、新たな証拠に基づいて残りのボトルネックを診断します。

公開情報ソース

関連する質問