LoginSignup
95
64

More than 3 years have passed since last update.

[Rails]N+1は悪!発生したらとりあえず解消せよ!!という考えは危険

Last updated at Posted at 2020-05-28

N+1

それは諸悪の根源!パフォーマンスの敵!!
見つけたらすぐに撃退すべき悪しき存在です!!!

と思われている方が多いと思います。

実際その通りで、ネットで調べてもN+1を解消するノウハウが溢れています。

基本的にはノウハウ通りに修正すれば良いのですが、まれにN+1は解消しない方が良いパターンもあるので具体例を交えて紹介します。

この記事で話したいこと

この記事で話したいことは、なぜN+1は直すべきなのか?ということです。
N+1はあまりに有名すぎて直すノウハウはたくさんありますが、なぜ直すべきなのかが忘れられている感があります。
1つ言っておきたいのは、N+1を直すのはクエリー発行数を減らしたいからではないです。パフォーマンスを改善したいからです!
言い換えるとクエリー数が減ってもはフォーマンスが改善しないのであればN+1を直す必要はないのです。

N+1とは

最初に典型的なN+1を復習しておきましょう。

下記のモデルを使って説明します。

  • ユーザー(user)は複数の記事(articles)を持っている
  • 記事には複数の写真(images)を添付することができる
class User
  has_many :articles
end

class Article
  belongs_to :user
  has_many :images
end

class Image
  belongs_to :article
end

このモデルを使って特定ユーザーの記事の一覧を取得するAPIを考えてみます。
レスポンスは下記の通り

{
  articles: [
    id: 1
    body: "hogehoge"
    images: [
      {
        id: 1
        alt: "alt"
        src: "https://example.com/hoge1.img"
      }
    ]
  ]
}

これをみんな大好き(?)Jbuilderを使うと下記のような実装になると思います。

class ArticlesController
  def index
    # 更新日の降順に10件取得(using kaminari)
    @articles = Articles.where(user_id: params[:user_id]).order(updated_at: :desc).page(params[:page]).per(10)
  end
end

# articles/index.json.jbuilder
json.articles do
  json.array!(@articles) do |article|
    json.id article.id
    json.body article.body
    json.images do
      json.array!(article.images) do |image|
        json.id image.id
        json.alt image.alt
        json.src image.src
    end
  end
end

上記を実行するとどうなるでしょうか?
下記のようにimagesテーブルを取得するSQLがarticlesの数だけ発行されます。

-- articles取得は1クエリー
SELECT `articles` FROM `articles` WHERE `articles`.`user_id` = 1 ORDER BY `articles`.`updated_at` DESC LIMIT 10

-- articlesの数だけimages取得クエリーが発行される
SELECT `images`.* FROM `images` WHERE `images`.`article_id` = 1
SELECT `images`.* FROM `images` WHERE `images`.`article_id` = 2
SELECT `images`.* FROM `images` WHERE `images`.`article_id` = 3
SELECT `images`.* FROM `images` WHERE `images`.`article_id` = 4
SELECT `images`.* FROM `images` WHERE `images`.`article_id` = 5
...
SELECT `images`.* FROM `images` WHERE `images`.`article_id` = 10

これを解消するための機能がRailsには備わっています。
それがpreload, eager_load, includesですね。
これらの細かい使い方はこの記事では省略しますが、今回はpreloadを使ってN+1を解消します。

class ArticlesController
  def index
    # preload(:images)追加
    @articles = Articles.where(user_id: params[:user_id]).preload(:images).order(updated_at: :desc, id: :desc).page(params[:page]).per(10)
  end
end
-- articles取得は1クエリー
SELECT `articles` FROM `articles` WHERE `articles`.`user_id` = 1 ORDER BY `articles`.`updated_at` DESC LIMIT 10

-- images取得も1クエリー
SELECT `images`.* FROM `images` WHERE `images`.`article_id` in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

クエリーは発行するコストが高いのでクエリー数を減らすことでパフォーマンスを大幅に改善することができます。
今回の例でも11クエリーが2クエリーと大幅に減っています。

N+1撃退完了!!

N+1を解消させない方が良いパターン

上記の例で無事にN+1が解消しましたが、もしimageを大量に持っているユーザーがたくさんいるとしたらこの対応は本当に良かったのでしょうか?
極端な例になってしまいますが、1つのarticlesあたり平均1,000枚のimageが紐づいている場合を考えてみましょう。

この場合、上記のレスポンスのままだと1,000 * 10 = 10,000のimageを返すことになってしまい、レスポンスが巨大になりすぎてパフォーマンスが劣化します。
そこで大抵の場合は記事一覧ではimageの一部(先頭5件だけとか)を返却する仕様に変更しようとかなるわけです。

では仕様変更してみましょう。
コントローラーはそのままでjbuilderの箇所を変更してみました。

# articles/index.json.jbuilder
json.articles do
  json.array!(@articles) do |article|
    json.id article.id
    json.body article.body
    json.images do
      # 最初の5件だけ取得
      json.array!(article.images.first(5)) do |image|
        json.id image.id
        json.alt image.alt
        json.src image.src
    end
  end
end

これで実行すると無事レスポンスのimageはarticleごとに5件までになります。
めでたしめでたし...とはなりません!!!!

どこが問題でしょうか?
クエリーを見ても1つ前の例と同じ2クエリーしか発行されていません。

-- articles取得は1クエリー
SELECT `articles` FROM `articles` WHERE `articles`.`user_id` = 1 ORDER BY `articles`.`updated_at` DESC LIMIT 10

-- images取得も1クエリー
SELECT `images`.* FROM `images` WHERE `images`.`article_id` in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

問題点は分かりましたか?
今回の例では前回N+1を解消したimagesのクエリーが問題になります。
今回の前提条件で1つのarticlesに1,000枚のimageが紐づいているとしています。
ということは、このimagesのクエリーでは10,000枚のimageオブジェクトが取得されていることになります。
ActiveRecordは便利な反面、オブジェクトのサイズがとても大きいです。そのオブジェクトを10,000個生成したらかなりのメモリを消費します。
しかも今回は先頭の5枚しか使いません。

ではpreloadを外してN+1解消前の状態に戻すとどうなるでしょうか?

-- articles取得は1クエリー
SELECT `articles` FROM `articles` WHERE `articles`.`user_id` = 1 ORDER BY `articles`.`updated_at` DESC LIMIT 10

-- articlesの数だけimages取得クエリーが発行される
SELECT `images`.* FROM `images` WHERE `images`.`article_id` = 1 limit 5
SELECT `images`.* FROM `images` WHERE `images`.`article_id` = 2 limit 5
SELECT `images`.* FROM `images` WHERE `images`.`article_id` = 3 limit 5
SELECT `images`.* FROM `images` WHERE `images`.`article_id` = 4 limit 5
SELECT `images`.* FROM `images` WHERE `images`.`article_id` = 5 limit 5
...
SELECT `images`.* FROM `images` WHERE `images`.`article_id` = 10 limit 5

imageのクエリーはarticlesの数だけ発行されるように戻ってしまいますが、それぞれ5件しか取得しないのでActiveRecordオブジェクトの生成数は10,000個から50個に大幅に減少します。

実行環境の性能に左右されるので絶対とは言えませんが、N+1を解消するよりメモリーを節約した方がパフォーマンスが上がることはよくあります。
(※実際にどちらの対応が適切かは実行環境と同等の環境でパフォーマンス検証しないと分かりません)
もしN+1は悪!絶対に直さないといけないと言う考えがあると、上記のようにimagesのクエリーが発行されているのをみるとpreloadをつけてしまい、メモリ使用量を大幅に上げてパフォーマンス劣化を招いてしまうかもしれません。

最後に

N+1はとても有名で目につきやすく、しかもRailsだとpreloadなどを付けるだけでさくっと解消させることができるため、深く考えずにとりあえず解消させるということがよくあると思います。
ただ今回の例のようにhas_manyを先読みしておく場合はどれくらいの件数が見込まれるかを考えてから実装するようにしましょう。
件数を考慮せず全部取得してしまうとメモリー不足を引き起こしてパフォーマンス劣化や最悪の場合にはサーバーをフリーズさせてしまう可能性があります。

あと、最初にも少し書きましたがN+1を直す目的をきちんと認識しましょう。
N+1を直した時にクエリーが減ったことを確認して対応完了にすることはありませんか?
N+1を解消するのは発行クエリーを減らすためではなくパフォーマンスを改善させるためです。
ただクエリーが減ったことだけを確認するのではなく、きちんとパフォーマンスが改善していることを確認するようにしましょう。

パフォーマンスを改善させるという観点で考えるとN+1の他にもメモリー利用量やループなど処理回数の削減など様々な観点があります。
N+1はあくまでパフォーマンス改善の1つの手であるということを忘れず、他の観点も頭に入れておくようにしましょう。

95
64
4

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
95
64