😑

【Rails】N+1問題はincludesで万事OKと思っていた。 

2022/07/18に公開

こんにちは。Webエンジニアとなって2週間経ったオクトと申します。

本記事では、N+1問題をincludesさえ付けておけば完璧じゃない?と思っている方を対象に他にもやり方はあるんだよ、もっと良い方法があるよ、ということをシャアしていきます。

かくいう私も上記の対象に先ほどまで入ってました。
これはそんな私向けの記事でもあるんですね。

バリバリにRailsを書いてきた人には、当たり前に感じると思いますが、
ご指摘箇所がありましたらご教授いただけますと幸いです。

前提

Ruby 3.1.1
Rails 6.1.4.6
テーブル構造は以下の通りです。

includesの他に何があるの?

「ねぇねぇ、先に何があるか教えてよ」という声が聞こえてきたので、先に伝えます。
includesの他に主に2つの方法で解消することができます。

クエリ数 関連先での絞り込み 関連先の情報
includes 1 or N ○ or x ○ or x
preload N x x
eager_load 1

* Nは関連テーブル毎にクエリを発行する
includesはデフォルトでpreloadと同じ挙動となり、絞り込みなどを行なっている場合はeager_loadと同じ挙動になります。

なんだincludes最強じゃん

と思ったそこの あなた 私。

確かに、様々な使い方ができるincludesですが、
クエリが定義次第で変わってくる(クエリを制御しにくい)ため、極力使わない方が良いということになります。

他はどんな感じなのよ

なぜincludesを極力使わない方が良いか見てきたところで、実際に他のメソッドを使ってみようと思います。

N+1が起きているパターン

まずはN+1問題が発生しているパターンを見ていきます。

posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.all
  end
  # ==== 省略 =====
end

以下のようにひとつひとつクエリを発行していることがわかります。

ターミナル
  Post Load (1.3ms)  SELECT "posts".* FROM "posts"
  ↳ app/views/posts/index.html.erb:2
  User Load (0.7ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 2], ["LIMIT", 1]]
  ↳ app/views/posts/_post.html.erb:2
  User Load (0.7ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/posts/_post.html.erb:2
  User Load (0.7ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 3], ["LIMIT", 1]]
  ↳ app/views/posts/_post.html.erb:2

preloadを使用する場合

preloadの使い方は案外簡単で、以下の通りです。

posts_controller.rb
class PostsController < ApplicationController
  def index
    # ===== モデル.preload(引数..) =====
    @posts = Post.preload(:user)
  end
  # 省略
end

以下のように、IN句を使って抽出してキャッシュします。

ターミナル
Post Load (2.3ms)  SELECT "posts".* FROM "posts"
  ↳ app/views/posts/index.html.erb:2
User Load (2.9ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN ($1, $2, $3)  [["id", 2], ["id", 1], ["id", 3]]
  ↳ app/views/posts/index.html.erb:2

↓ちなみにORDERを使って並び替えることはできます。

ターミナル
# @posts = Post.preload(:user).order(created_at: :desc)
Post Load (3.1ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC
  ↳ app/views/posts/index.html.erb:2
User Load (1.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN ($1, $2, $3)  [["id", 1], ["id", 3], ["id", 2]]

↓ちなみにちなみに関連先を絞り込むと以下のようなエラーが発生します。(注: DBはPostgresqlを使用)

ターミナル
# @posts = Post.preload(:user).where(users: { email: 'satou@example.com' })
ActionView::Template::Error (PG::UndefinedTable: ERROR:  missing FROM-clause entry for table "users"
LINE 1: SELECT "posts".* FROM "posts" WHERE "users"."email" = $1

eager_loadを使用する場合

eager_loadを使う場合も、ほとんど同じです。

posts_controller.rb
class PostsController < ApplicationController
  def index
    # ===== モデル.eager_load(属性..) =====
    @posts = Post.eager_load(:user)
  end
  # 省略
end

はい、以下のように 左外部結合( LEFT OUTER JOIN ) によって1つのクエリで取得してキャッシュしています。

ターミナル
SQL (7.6ms)  SELECT "posts"."id" AS t0_r0, "posts"."content" AS t0_r1, "posts"."user_id" AS t0_r2, "posts"."created_at" AS t0_r3, "posts"."updated_at" AS t0_r4, "users"."id" AS t1_r0, 
"users"."email" AS t1_r1, "users"."encrypted_password" AS t1_r2, "users"."reset_password_token" AS t1_r3, "users"."reset_password_sent_at" AS t1_r4, "users"."remember_created_at" AS t1_r5, "users"."created_at" AS t1_r6, "users"."updated_at" AS t1_r7 
FROM "posts" LEFT OUTER JOIN "users" ON "users"."id" = "posts"."user_id"
  ↳ app/views/posts/index.html.erb:2

ORDERを使うこともできます。

ターミナル
# @posts = Post.eager_load(:user).order(created_at: :desc)
SQL (2.8ms)  SELECT "posts"."id" AS t0_r0, "posts"."content" AS t0_r1, "posts"."user_id" AS t0_r2, "posts"."created_at" AS t0_r3, "posts"."updated_at" AS t0_r4, "users"."id" AS t1_r0, 
  "users"."email" AS t1_r1, "users"."encrypted_password" AS t1_r2, "users"."reset_password_token" AS t1_r3, "users"."reset_password_sent_at" AS t1_r4, "users"."remember_created_at" AS t1_r5, "users"."created_at" AS t1_r6, "users"."updated_at" AS t1_r7 
  FROM "posts" LEFT OUTER JOIN "users" ON "users"."id" = "posts"."user_id" ORDER BY "posts"."created_at" DESC
  ↳ app/views/posts/index.html.erb:2

↓関連先の絞り込みを行なっても正常にクエリを発行していることがわかります!素晴らしい!

ターミナル
# @posts = Post.eager_load(:user).where(users: { email: 'satou@example.com' }).order(created_at: :desc)
SQL (2.2ms)  SELECT "posts"."id" AS t0_r0, "posts"."content" AS t0_r1, "posts"."user_id" AS t0_r2, "posts"."created_at" AS t0_r3, "posts"."updated_at" AS t0_r4, "users"."id" AS t1_r0, "users"."email" AS t1_r1, 
"users"."encrypted_password" AS t1_r2, "users"."reset_password_token" AS t1_r3, "users"."reset_password_sent_at" AS t1_r4, "users"."remember_created_at" AS t1_r5, "users"."created_at" AS t1_r6, "users"."updated_at" AS t1_r7 
FROM "posts" LEFT OUTER JOIN "users" ON "users"."id" = "posts"."user_id" WHERE "users"."email" = $1 ORDER BY "posts"."created_at" DESC  [["email", "satou@example.com"]]
  ↳ app/views/posts/index.html.erb:2

結局どっち使えば良いのよ

結論、使い分けるということになります。この世界には、絶対的な真はないところが面白いところですね。

目安としては、以下のようです。

  • has_many-belongs to の関係であれば、preloadを使用してクエリ発行の負担を減らす。
  • has_one-belongs to の関係であれば、eager_loadを使用して1クエリでまとめて取得する。
  • 関連先で条件を絞り込みたいときはeager_loadを使用する。

しかし、両者には気にしておくポイントもあります。

  • eager_loadでは結合処理を行うため、データ量が大きい場合スロークエリが発生しやすい。
  • preloadでは、こちらもレコード数が多い場合にIN句が大きくなりすぎるためDB側の設定値を機にする必要があります。

まとめ

いかがだったでしょうか。
それぞれに実装する上での利点があったことがわかったと思います。
私も気にしておくポイントで紹介したことについて、もう少し深掘りしていきたいと思いました。

最後までお読みいただきありがとうございます!

参考

Discussion