結論: N+1問題とは、1回で済むはずのデータ取得が、件数(N)に比例して何度も実行され、処理が遅くなる問題です。関連データをまとめて取得するよう書き換えると、改善できます。
N+1問題とは何か
N+1問題とは、データベースからデータを取得する際に、本来1回(または少数回)のクエリで済むはずの処理が、データの件数(N)に比例してクエリの回数が増えてしまう、パフォーマンス上の問題です。ORM(データベースの操作をプログラミング言語のコードとして扱えるようにする仕組み)を使った開発で、特に起こりやすいとされています。
具体的な例で理解する
「著者の一覧と、それぞれの著者が書いた本のタイトルを表示したい」という場面を考えてみましょう。
- まず、著者の一覧を取得するクエリが1回発行されます
- 次に、一覧の中の著者ごとに「この著者の本を取得する」というクエリが、著者の人数分(N回)発行されます
結果として、合計で「1 + N」回のクエリが発行されることになります。著者が10人なら11回で済みますが、10,000人いれば10,001回のクエリが発行され、データベースとの通信回数が膨れ上がり、処理が急激に遅くなります。
図で見る、クエリ回数の違い
問題が起きている状態と、解決した状態を、クエリの回数で比較してみましょう。
なぜ気づきにくいのか
N+1問題が厄介なのは、開発環境ではデータ件数が少ないため、体感できるほどの遅さにならないことが多い点です。テスト用のデータが数件〜数十件程度であれば、クエリが多少増えても目立った影響は出ません。しかし本番環境でデータが数千、数万件と増えた途端、突然「重い」と感じるようになります。この「開発環境では速いのに、本番では遅い」という症状の裏には、高い確率でN+1問題が潜んでいます。
解決策:Eager Loading(先読み)
N+1問題への基本的な対処法は、「Eager Loading(先読み)」と呼ばれる手法です。関連するデータを、ループが始まる前にまとめて取得しておくことで、クエリの発行回数を大幅に減らせます。
多くのORMには、この目的のための専用の仕組みが用意されています。例えばDjango公式ドキュメントでは、関連オブジェクトをまとめて取得するselect_related(結合によって1回のクエリで取得する方式)と、prefetch_related(関連ごとに個別のクエリを発行し、Python側で結びつける方式)という、2つの手法が説明されています。RailsやLaravelなど、他の主要なORMにも同様の仕組みが用意されています。
実際の開発現場でよくあるつまずき
- リストの中身をループで回しながら、その都度関連データを呼び出すコードを何気なく書いてしまい、後から気づいて修正することになる
- Eager Loadingを導入したものの、使わない関連データまで大量に読み込んでしまい、今度はメモリ使用量の増加という別の問題を招いてしまう
- 開発中は快適に動いていたため誰も気づかず、リリース後にユーザーから「表示が遅い」と報告があって初めて発覚する
日頃からできる予防策
- 一覧画面のコードをレビューする際は、「ループの中でデータベースへのアクセスが発生していないか」を意識してチェックする
- 開発環境でも、実際の運用に近い件数のテストデータを用意し、クエリの発行回数を早い段階で確認する
- ORMが提供するクエリログや、N+1検知ツールを活用し、気づかないうちに問題が紛れ込んでいないか定期的に確認する
まとめると
N+1問題は、ORMの手軽さの裏側にある代表的な落とし穴です。「関連データにアクセスするループを書いたら、まずEager Loadingを検討する」という意識を持っておくだけで、多くのケースを未然に防ぐことができます。
参考
※ 具体的な実装方法は使用しているORM・フレームワークによって異なるため、詳しくは各公式ドキュメントもあわせてご確認ください。