7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめての記事投稿
Qiita Engineer Festa20242024年7月17日まで開催中!

実際に動かしてみてわかった、N+1問題の恐ろしさ

Last updated at Posted at 2024-07-11

はじめに

ご閲覧いただきありがとうございます!
Sun* という会社で Backend Engineer をやっている 24 卒の Campanule と申します。
普段は Ruby on Rails を使った Web アプリの開発を行っております。

今回が初投稿となります。どうぞお手柔らかにお願いいたします。

本記事ではデータベースを用いる際にいつも頭を悩まされる N+1 問題 について簡単に説明し、またパフォーマンスの簡易的な測定をしてみようと思います。

目的

  • N+1 問題 とは何なのかざっと理解したい
  • パフォーマンスの測定を行い、N+1 問題 の影響について考えたい

想定読者

  • N+1 問題 を知らない方、あるいは言葉だけ聞いたことがある方
  • N+1 問題 によるパフォーマンスの低下が気になっている方

N+1 問題とは

こちらがわかりやすいです。

簡単に言うと、あるテーブルのレコード N 件 に対応した別テーブルのレコードを引っ張るとき、別テーブルに対して N 回 クエリを発行してしまう問題です。(1 回 のクエリを発行したあとに N 回 のクエリを発行するため、直感的には 1+N 問題 とも言えなくもない…かも)

最適化すれば多くても数回のクエリ発行ですむため、これは大変な問題です。

対策

対策としては、関連するテーブルの要素をあらかじめ取得しておくとよいです(eager loading とよばれます)。これによりクエリの個数を N+1 個 から劇的に減らすことができます。Rails なら eager_load, preload, includes などを用いて実現できます。それぞれの違いについては以下の記事をご参考ください。

パフォーマンスの違い

eager loading を行ったとき、ランダウの記号を用いるとクエリ発行回数は $O(1)$ 回であり、アプリ側での処理件数は $O(N)$ 回となります。それに対し、N+1 問題 が発生したとき、クエリ発行回数が $O(N)$ 回と膨れ上がり、アプリ側での処理件数は同じく $O(N)$ 回です。要するに、クエリ発行回数に対する計算量が大きくなってしまう問題です。

ランダウの記号については以下を参照。

「あれ?総合したときの計算量変わらなくね?」と思った方がいらっしゃるかもしれません。しかし、アプリケーションにより 1 回の処理を行うのと比べて、DB に対して 1 回クエリを発行するほうが遥かに遅いです。
そのため、定数倍の増加といった形で考えてもよいでしょう。この定数倍が意外と馬鹿になりません。

計測してみた

今回は Rails と MySQL を用いてやってみました。

前提

前提として、company, user, pet の 3 モデル(名前は適当です)を用意し、conpany - user 間は 1 対 多、user - pet 間は 1 対 1 の関係にあるものとします。
また、DB に対するチューニングは一切行っていません。

計測の際には、Rails 標準の benchmark を用いています。

以下のコードを用いて、全ユーザが飼っているペットの名前を取得します。

N+1 problem
company = Company.last

###ここから計測###
users = company.users

users.each do |user|
  puts(user.pet.name)
end
###ここまで計測###
eager loading
company = Company.last

###ここから計測###
users = company.users.includes(:pet) # eager loading

users.each do |user|
  puts(user.pet.name)
end
###ここまで計測###

結果

N = 20

eager loading N+1問題 差(倍率)
15.5ms 100.2ms 6.46倍

データ数が小さいこともあり、まだまだ使える範囲内でしょう。

N = 100

eager loading N+1問題 差(倍率)
20.7ms 328.5ms 15.87倍

まだデータ数が小さいため、なんとか使えます。

N = 2000

eager loading N+1問題 差(倍率)
118.1ms 3518.7ms 29.79倍

この辺りから全件取得のためには eager loading が必須になってきます。N+1 問題 が発生していても使えないことはないですが、正直使いたくはないです。

N = 10000

eager loading N+1問題 差(倍率)
523.0ms 24340.9ms 46.54倍

この時点で eager loading をしないともはや使い物になりません。ユーザ数が多くなればそのぶんパフォーマンスに対する考慮の優先度が高くなるでしょう。

N = 200000

eager loading N+1問題 差(倍率)
13294.5ms 534503.6ms 40.20倍

もはや N+1 問題 が発生していると日が暮れてしまいますね…。読み込みだけで 9 分弱費やしています。eager loading をしても 10 秒ちょいと長いですが、20 万件のデータを全件取得しているのでデータ数から考えれば妥当…かもしれません。

まとめ

N+1 問題 って思ったより怖かった…!
データが増えてくると、特に気をつけなければいけない問題になってきます。ふとしたときに発生しうるので、Rails なら bullet を入れておくと注意喚起が入るのでおすすめです。

考えたくもないですが、N+1 問題 に N+1 問題 をネストしてしまうなんてことがあれば最悪クエリ発行回数が $O(N^2)$ 回になってしまう可能性があるので、DB 絡みのネストを行う際には特に気をつけましょう…!

パフォーマンスに関する記事としては他にも Index に関する記事を書くつもりなので、よければ気長にお待ちいただけると幸いです!

参考

PR

Sun*では、一緒に働く仲間を募集しています。
まずはカジュアル面談からでもお気軽にご応募ください!

▼会社情報はこちら

▼募集要項・ご応募はこちら

7
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?