N+1問題
N+1 Query Problem ・ えぬぷらすわんもんだい
一覧の関連データを1件ずつ問い合わせてしまい、DBとの往復が件数に比例して増える性能問題。
✦ 30秒で試す — 航海1本ずつは一瞬のSQLなのに、一覧画面だけが異様に遅い記事と著者をまとめて1回で取るように変えると、表示時間は?予想してみる概要
N+1問題は、「一覧を1回のクエリで取得したあと、その各行に紐づく関連データを1件ずつ別のクエリで取りに行ってしまう」ことで、合計 1+N 本の SQL が発行されてしまう性能問題です。たとえばブログ記事を100件表示する画面で、記事一覧の取得に1本、各記事の著者名の取得に100本、合わせて101本のクエリが飛ぶ — これが典型的なN+1です。
厄介なのは、1本1本のクエリはごく軽いため、開発中のデータ件数が少ない環境ではまったく問題に見えないことです。遅さの正体はクエリの重さではなく、アプリとデータベースの間を行き来する往復の回数です。本番でデータが増えた途端、往復の回数がデータ件数に比例して膨れ上がり、「一覧画面だけ異様に遅い」「DBの接続が足りなくなる」といった形で表面化します。Webアプリケーションの性能問題の中でも、もっとも頻繁に踏まれる落とし穴のひとつです。
なぜ生まれたか
N+1問題は特定の技術の欠陥というより、ORM の便利さの裏側で生まれた副作用です。ORMはデータベースの行をオブジェクトとして扱わせてくれるため、article.author.name のようにプロパティを辿るだけで関連データにアクセスできます。このとき多くのORMは「遅延ロード(lazy loading)」、つまり実際にアクセスされた瞬間に初めてクエリを発行する戦略を採ります。使わないデータを読まずに済む合理的な既定値ですが、ループの中で関連を辿ると、1周ごとに1本のクエリが発行されてしまいます。
つまりN+1は、「SQL を書かなくてよい」という抽象化によって、クエリの発行タイミングがコードの見た目から消えたことで生まれた問題です。生SQLを書いていた時代なら JOIN を1本書いて終わりだった処理が、オブジェクトを自然に辿るコードでは無数のクエリに化ける。だからこそ対策も「抽象化を捨てる」のではなく、「これから関連データを使う」とORMに事前に伝えるイーガーロード(eager loading)という形で整備されてきました。同じ構造は ORM の外でも繰り返し現れます。フィールドごとに独立した関数(リゾルバ)でデータを取りに行く GraphQL のサーバでは、何も工夫しなければリゾルバの呼び出しがそのままN+1になり、その対策として DataLoader というバッチ化の仕組みが生まれました。
詳細
1+N本のクエリが飛ぶ様子
記事100件と著者を表示するページを時系列で追うと、次のようになります。ブラウザがページを要求してから HTML を受け取るまで、アプリはデータベースとの往復を何度も繰り返し、その間ブラウザはずっと待たされます。
1本のクエリにかかる時間は、DB の中での実行時間と、アプリと DB の間をパケットが行って帰るネットワークの往復時間(ラウンドトリップ)の和です。主キーで1件引くクエリの実行は数十マイクロ秒で終わることも多く、実際には往復のほうが支配的になります。仮に1往復が2ミリ秒でも、101往復なら約200ミリ秒。記事が1万件なら20秒です。レイテンシが件数 N に比例して伸びるため、開発環境の数件のデータでは気づけず、本番で顕在化するという経過をたどります。さらに、ページを返し終えるまでの間、細かいクエリの列がコネクションプールの接続を1本占有し続けるので、同時に多くの利用者が一覧を開くと接続の空き待ちが起き、遅延が他の画面にまで広がります。
解決策1: JOIN で1本にまとめる
もっとも直接的な解決は、関連テーブルを JOIN して1本のクエリで取り切ることです。ORMでは Hibernate の JOIN FETCH、Django の select_related、Rails の eager_load のように、関連を結合込みで取得する指定が用意されています。1回の往復で全データが揃うため往復の回数は最小になりますが、1対多の関連を JOIN すると親側の列が子の件数ぶん繰り返して返るため、多段の関連を一度に結合すると結果の行数が掛け算で膨らむ点には注意が必要です。
解決策2: IN でまとめて2本にする
もうひとつの定番は、まず記事の一覧を取得し、そこに含まれる著者の id を集めて、著者のテーブルを SELECT * FROM authors WHERE id IN (3, 7, 9) のようにまとめて1本で取る方法です。クエリは2本になりますが、本数は N に依存しません。同じ著者が何度も登場しても id は1回だけ並べればよく、1対多の関連でも結果が重複しません。Rails の preload、Django の prefetch_related、Hibernate の batch fetching(@BatchSize)などが内部でこの戦略を使います。Rails の includes や Prisma の include のように、状況に応じてどちらかを自動で選ぶものもあります。JOIN と IN のどちらが速いかは関連の形やデータ量次第で、迷ったら実行計画を確認して判断します。
一覧の件数と読み込み方を変えて確かめてください。一覧画面の「+ 記事」を押すたびにページを読み込み直し、アプリと DB の間を飛ぶ粒と、かかった時間の帯が伸びていきます。
左の一覧画面の 「+ 記事」を押して、記事の行を足してください。行を足すたびにページを読み込み直し、アプリと DB の間を往復する粒と、かかった時間の帯が見えます。
時間は単純化した値です(片道 1 ms、主キーで1件引くクエリの実行 0.2 ms)。実際の比は環境で変わりますが、往復の回数が効くことは同じです。
GraphQL と DataLoader
GraphQL のサーバでは、posts { author { name } } のような問い合わせに対して、記事ごとに author のリゾルバが呼ばれます。素朴に書けば、リゾルバの中で1件ずつ著者を引くことになり、ORM の遅延ロードと同じ形のN+1が起きます。しかもクエリの形はクライアントが決めるので、サーバ側であらかじめ JOIN を仕込んでおくのが難しいことがあります(問い合わせを先読みして JOIN を組む実装もあります)。
そこで広く使われているのが、Facebook が2015年に公開した DataLoader です。リゾルバは「著者 7 を読みたい」と DataLoader に頼むだけで、すぐには問い合わせません。DataLoader は同じイベントループの1周の間に集まった依頼をためておき、まとめて1回のバッチ関数(中身は WHERE id IN (...))に渡します。同じ id の依頼は1つにまとめ、1リクエストの間は結果を覚えておくので、解決策2の IN を、リゾルバを書く人が意識しなくても自動で行う仕組みだといえます。
見つけ方と実務の勘所
N+1はコードレビューで見抜くより、観測で見つけるほうが確実です。ループの中の article.author は、コードの上ではただのプロパティ参照にしか見えないからです。開発環境で SQL のログを出して「同じ形のクエリが id だけ変えて連発されていないか」を見る、Rails の bullet や Django の django-debug-toolbar のような検出ツールを入れる、APM で1リクエストあたりのクエリ本数を監視する、といった方法が定番です。Rails の strict_loading のように、遅延ロードが起きた時点で例外にして開発中に気づかせる機能もあります。「1画面あたりのクエリ本数はデータ件数に依存しない」を目安にすると判断しやすくなります。
一方で、すべての関連を常にイーガーロードするのも誤りです。使わない関連まで毎回結合すれば、今度は1本のクエリが不必要に重くなり、転送量も増えます。遅延ロードは「使うかどうか分からないデータを読まない」ための最適化であり、「この画面では確実に使う」と分かっている関連だけを事前ロードに切り替えるのが正しい使い分けです。ORM の抽象の裏で実際にどんな SQL が何回流れているかを常に意識する — N+1問題は、データベースプログラミングのその基本姿勢を教えてくれる語彙でもあります。
試問
ブログの記事一覧を表示するアプリと DB があります。「+ 記事」で一覧の件数を増やすたびにページを読み込み直し、アプリと DB の間を往復する問い合わせと、ページを返すまでの時間が見えます。上の切り替えで、著者名の読み込み方(1件ずつ・JOIN・IN)を選べます。
左の一覧画面の 「+ 記事」を押して、記事の行を足してください。行を足すたびにページを読み込み直し、アプリと DB の間を往復する粒と、かかった時間の帯が見えます。
時間は単純化した値です(片道 1 ms、主キーで1件引くクエリの実行 0.2 ms)。実際の比は環境で変わりますが、往復の回数が効くことは同じです。
