プログラミング漫遊記

思ったことや、勉強したことをつらつらと。

ためして分かる、N+1問題とその解決方法

この記事はフィヨルドブートキャンプ Part 1 Advent Calendar 2022 の23日目の記事です。

昨日はpart1が penoさんのフルタイムで働く社会人が月に100時間の勉強時間を確保できるようになるまで。私なりの具体的なやり方と失敗事例 - ぺのめも、part2が uchihiroさんのVSCodeのパッケージ不適合によるエラー解決までの道のり(Byebug編)でした。

前振り

フィヨルドブートキャンプを今年の4月に卒業しました。卒業生なので、近況報告などしようかなと思ったんですがDiscordや日報などで日々わーわー言ってるので自重しておきます。

その代わり?最近友達とN + 1 問題の勉強会を行ったのでハンズオン形式で理解できるような記事を書いてみようと思います。(わからないところあったらフィードバックください)

Ruby on Rails でよく発生するN + 1 問題ですが、そもそもN + 1問題とはなんなのか? また、どうやって解決するの?ということが書かれています。(はずです)

今回は複数のモデルが絡んでくるような複雑な話は除いているので、比較的とっつきやすいと思います。ぜひ実際に手を動かして確かめてみてください。(需要があれば複雑バージョンも書くかも)

想定読者

  • フィヨルドブートキャンプ生
  • N + 1 問題 ってなんだろ?と思っている人
  • Railsで用いられるeager loading のメソッド(preload, eager_load, includes)の違いがよくわからない人

環境

Rails 6系、Ruby 2.7系でも動くのを確認しています。

本編

準備

まずはアプリケーションを用意します。N+1_appとでも名付けておきましょう。

$ bin/rails new N+1_app && cd N+1_app
# ファイルの生成が始まる
...
# アプリのルートディレクトリに移動

このアプリでは「ユーザーが何かを投稿するサービス」を模してUserモデルとPostモデルを作成します。話を簡単にするためカラムは最小限にしています。 以下、DB設計の図です。

UsersテーブルとPostsテーブルの関係

上記の通りにモデルを作成しマイグレーションファイルを適用していきます。

# Userモデルの作成
$ bin/rails g model user name:string age:integer

# Post モデルの作成 外部キーの設定をするためにuser:references型を指定
$ bin/rails g model post title:string body:text user:references

$ bin/rails db:migrate
== 20221220231549 CreateUsers: migrating ======================================
-- create_table(:users)
   -> 0.0008s
== 20221220231549 CreateUsers: migrated (0.0008s) =============================

== 20221220232153 CreatePosts: migrating ======================================
-- create_table(:posts)
   -> 0.0011s
== 20221220232153 CreatePosts: migrated (0.0011s) =============================

現在UserモデルとPostモデルは以下のようになっています。

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
end

# app/models/user.rb
class User < ApplicationRecord
end

モデルを作成したときにuser:referencesとしたのでPostモデルの関連付けがbelongs_to :userになっていることがわかります。 一方Userモデルの方には関連付けがまだないので設定していきます。

# app/models/user.rb
class User < ApplicationRecord
  has_many :posts # 追加
end

has_manyに渡す引数は複数形にするのに注意しましょう。

最後は初期データの作成です。rails consoleを使って作成します。 Userを5人、それぞれに対してPostを3件ずつ作成しています。

$ bin/rails c
# rails console (irb) 起動
5.times do |i|
  User.create!(name: "名前 #{i}", age: i + 18) # ここを18にしているのは意味があります
end

User.count
=> 5

User.all.each do |user|
  3.times do |i|
    Post.create!(title: "タイトル #{i}", body: "内容 #{i}", user: user)
  end
end

Post.count
=> 15

これで準備は整いました。

N + 1 問題とは

N + 1 問題とは、DBへの問い合わせ(クエリの実行)をたくさん行ってしまうことによってパフォーマンスが落ちてしまう問題のことです。 一般的にメモリ上にキャッシュされたデータを読み込むのとDBへ問い合わせてデータを読み込むのでは後者の方が時間がかかります。イメージとしては、机の上(メモリ上)にお菓子を置いておくか冷蔵庫(DB)にお菓子を置いておくかで取りに行くスピードが変わる感じです。(この例えで大丈夫かな?) また、「たくさん」とは具体的には N + 1 件ものクエリを発行してしまうことになります。

  • N件のデータをもつテーブルの情報を読み出すのに1件のクエリを発行する
  • 読み出したテーブルに紐づく各データを1件ずつ読み出すのに N 件のクエリを発行する

です。この2つの合計が 1 + N なので N + 1問題といいます。 実際に N + 1 件のクエリを発行してみましょう。

N + 1 に出会う

ここから先はrails consoleを使って確認していきます。

posts = Post.all  # (1)
posts.each do |post|
  puts post.user.name #  (2)
  puts post.title
end

(1)でpostオブジェクトを全件取得しています。

(2)でpost に紐づくuserオブジェクトを取得し、そのnameを表示しています。 post.userを読み込むたびに関連するuserオブジェクトを取得するためクエリが発行されるのがわかります。

Post Load (0.1ms)  SELECT "posts".* FROM "posts" # (1)

 # ----------------------↓以下 (2)  15件SQL文が続く -------------------------------
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
名前 0                                                                                
タイトル 0                                                                            
  User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
名前 0                                                                                
タイトル 1                                                                            
  User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
                                        ......... 
                                       ... 略  ...
                                        .........

  User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 5], ["LIMIT", 1]]
名前 4
タイトル 1
  User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 5], ["LIMIT", 1]]
名前 4
タイトル 2

(1)で1件、(2)で15件、全部で16件のクエリがあります。 一般に、postオブジェクトの数が N 個 ある場合 1 + N 件のクエリが発行されます。(つまりは N + 1 ) オブジェクトの数が少ないときはいいのですが、何万件もに膨れ上がると発行されるクエリの数が膨大になりパフォーマンスに影響をきたします。

これを解決するにはクエリの発行回数を減らす必要があります。

解決編

あらかじめUsersテーブルからもデータを取得しておけば、クエリの発行回数を減らすことができます。この、「あらかじめデータを取得しておく」ことを eager loading というらしいです。 Railsにはeager loadingを実現する方法として3つのメソッドが使用できます。この3つのメソッドは微妙に挙動が違うのでそれぞれ順に確かめていきましょう。

preload

preloadというメソッドがあるので試してみます。 rails consoleで試してみます。

posts = Post.preload(:user)
  Post Load (0.1ms)  SELECT "posts".* FROM "posts" # (1)
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?, ?, ?)  [["id", 1], ["id", 2], ["id", 3], ["id", 4], ["id", 5]] # (2)

posts.each do |post|
  puts post.user.name
  puts post.title
end

名前 0
タイトル 0
名前 0
タイトル 1
名前 0
タイトル 2
 ... 略 ...

SQL文に着目すると全部で2件のクエリが発行されていることがわかります。

Post Load (0.1ms)  SELECT "posts".* FROM "posts" # (1)
User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?, ?, ?)  [["id", 1], ["id", 2], ["id", 3], ["id", 4], ["id", 5]] # (2)

(1)でPostテーブルを全件読み込んでいます。(2)ではUserテーブルからあらかじめ必要なデータを1回のクエリでまとめて取得しています。( IN句の部分)

あらかじめ必要なデータとはpostオブジェクトに紐づいているユーザという意味で、例えば投稿が一件もないユーザーがいたとした場合、そのユーザーのデータは取得されません。

$ bin/rails c

User.create!(name: '投稿なし', age: 100)
User.count #=> 6

Post.preload(:user)

Post Load (0.1ms)  SELECT "posts".* FROM "posts"
User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?, ?, ?)  [["id", 1], ["id", 2], ["id", 3], ["id", 4], ["id", 5]]

IDが6である投稿なしのユーザはpostオブジェクトに紐づいてないので取得できていないことがわかります。

このようにpreloadを使うとpostオブジェクトの件数が増えてもクエリが2つに抑えられるのでN + 1問題が解決します。

注意点としてはテーブル同士を結合しているわけではないので関連先のテーブルで絞り込むことはできません。 例えば、`user_id が3以下であるユーザーを取得してみようと絞り込みを行うとエラーが発生します。

Post.preload(:user).where(user: {id: [1, 2, 3]}).each do |post|
  puts post.user.name
end

 SQLite3::SQLException: no such column: user.id (ActiveRecord::StatementInvalid) # エラー1
 no such column: user.id (SQLite3::SQLException) # エラー2    

eager_load

prealodの他にeager_loadというメソッドがあります。eager_loadはテーブル同士を左外部結合し、一つの大きいテーブルにしてメモリ上にキャッシュします。

posts = Post.eager_load(:user)
SQL (0.1ms)  SELECT "posts"."id" AS t0_r0, "posts"."title" AS t0_r1, "posts"."body" AS t0_r2, "posts"."user_id" AS t0_r3, "posts"."created_at" AS t0_r4, "posts"."updated_at" AS t0_r5, "users"."id" AS t1_r0, "users"."name" AS t1_r1, "users"."age" AS t1_r2, "users"."created_at" AS t1_r3, "users"."updated_at" AS t1_r4 FROM "posts" LEFT OUTER JOIN "users" ON "users"."id" = "posts"."user_id" # (1)

posts.each do |post|
  puts post.user.name
  puts post.title
end

名前 0
タイトル 0                                                                                 
名前 0                                                                                     
タイトル 1  
 ... 略 ...

今回はクエリの発行は1件だけになっています。 SQLが少しややこしいので、ちょっと整形してみます。

SQL (0.1ms)
SELECT
  "posts"."id",
  "posts"."title",
  "posts"."body",
  "posts"."user_id",
  "users"."id",
  "users"."name",
  "users"."age",
FROM
  "posts"
  LEFT OUTER JOIN
  "users"
    ON "users"."id" = "posts"."user_id"

created_atupdated_atを省略し、SELECT句にあったAS は別名をつけているだけなので省略しました。これでかなり読みやすいのではないでしょうか? FROM句でデーブルの結合をしていることがわかります。 左外部結合なのでどのテーブル(左)を主としてどのテーブルをくっつけるのかを意識しないと意図しないデータを取得してしまうことがあるので気をつけましょう。

eager_loadの特徴はテーブルを結合しているので関連先のテーブルで絞り込みができることです。

Post.eager_load(:user).where(user: {id: [1, 2, 3]}).each do |post|
  p post.user.name
end

"名前 0" #user_id=1
"名前 0"
"名前 0"
"名前 1" #user_id=2
"名前 1"
"名前 1"
"名前 2" #user_id=3
"名前 2"
"名前 2"

また、mergeメソッドなどでスコープを使って絞り込むこともできます。 下は、20歳以上のユーザーが投稿した、postを取得しています。

# app/models/user.rb
class User < ApplicationRecord
  has_many :posts
  scope :adult, -> { where('age >= ?', 20) } # スコープを定義する
end

$ bin/rails c

Post.eager_load(:user).merge(User.adult)

includes

最後はincludesメソッドです。これはデフォルトの挙動がprealodなのですが、特定の条件を満たした時にeager_loadと同じ挙動になるメソッドです。

# preloadと同じ挙動
Post.includes(:user)
  Post Load (0.1ms)  SELECT "posts".* FROM "posts"
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?, ?, ?)  [["id", 1], ["id", 2], ["id", 3], ["id", 4], ["id", 5]]


# eager_loadと同じ挙動
Post.includes(:user).where(user: {id: [1, 2, 3]})
SQL (0.2ms)  SELECT "posts"."id" AS t0_r0, "posts"."title" AS t0_r1, "posts"."body" AS t0_r2, "posts"."user_id" AS t0_r3, "posts"."created_at" AS t0_r4, "posts"."updated_at" AS t0_r5, "user"."id" AS t1_r0, "user"."name" AS t1_r1, "user"."age" AS t1_r2, "user"."created_at" AS t1_r3, "user"."updated_at" AS t1_r4 FROM "posts" LEFT OUTER JOIN "users" "user" ON "user"."id" = "posts"."user_id" WHERE "user"."id" IN (?, ?, ?)  [["id", 1], ["id", 2], ["id", 3]]

基本的に関連先のテーブルで絞り込みを行った時はeager_loadと同じ挙動になるのですが、もう少し細かい条件については以下の記事を参考ください。

まとめ

  • N + 1 問題とはオブジェクトを取得する際に N + 1 件のクエリを発行してしまうという問題
  • 解決方法はクエリの発行回数を減らすことで、取得したいデータをあらかじめメモリ上へキャッシュすれば良い
  • データをキャッシュしておくためのメソッドは3つあり(preload, eager_load, includes)それぞれデータの取得方法が異なる

参考

いかがでしたでしょうか? N + 1 問題について少しでも身近に感じてもらえたらありがたいです。

アドベントカレンダーもいよいよ大詰め!明日はshuji watanabeさんです。楽しみ!