N+1問題の対処をした際の内容を記事にしてみました。
開発環境
Rails 6.1.4.3
ruby 3.0.3
どのようなN+1問題だったのか
ユーザー(user)が、イイね!(like)をした投稿(post)の一覧を表示する機能で、N+1問題が起きていいることが分かりました。 この問題はループ内で関連したテーブルで起きているものだったので、includes
を使用して対処したというのがこの記事に概要になります。
N+1問題について
参考記事:【Ruby on Rails】N+1問題ってなんだ?
上記の記事からの引用になりますが、ループ処理の中で都度SQLを発行してしまい大量のSQLが発行されてパフォーマンスが低下してしまう問題のことで、例えるならスーパーで商品を1点ずつ会計するイメージで、それだけ無駄な状態みたいです。
どう対処すればいいのか
要するに主キーが設定されているテーブルにアクセスする際に、関連するテーブルのレコードを取得する事が出来れば、発行されるSQLをまとめる事が出来ます。
<主な対策方法>
- joins:関連テーブルでの絞り込みで使用、キャッシュの生成無し
- left_outer_joins: 〃
- eager_load:ループ内で関連テーブルの値を用いる場合に使用、キャッシュの生成有り
- preload: 〃
- includes:SQL発行時の条件に合わせて、eager_loadかpreloadどちらかの挙動を行う(今回の対処)
N+1問題にどうやって気づくか
そもそも、私のように学習を開始して間もない人はこの問題の存在を知らないという人もいるのではないでしょうか。そういう人のために問題が起きている画面上で指摘してくれる便利なbullet
というGemを導入してみることをお勧めします。
実際にN+1問題が発生しているページを表示した際に下記のような通知が表示されます。
パッとみてどこでN+1問題が起きているか判断出来ない方には抜け漏れがなく、お勧めだと思います。 私はこちらを導入してどのページで問題が起きているのかコードと見比べながら理解することが出来たので、学習のきっかけと捉えていただいても良いかもしれません。
それでは本題に移ります。
実際のコード
それでは、実際に対処したコードの解説に移ります。
# app/views/users/likes.html.erb
<% @likes.each do |like| %>
<% post = Post.find_by(id: like.post_id) %> # viewでactive recordを触っているので、そもそも良くないです。
<div class="posts-index-item">
<div class="post-left">
<img src="<%= "/user_images/#{post.user.user_image}" %>">
</div>
<div class="post-right">
<div class="post-user-name">
<%= link_to(post.user.name, "/users/#{post.user.id}") %>
</div>
<%= link_to(post.content, "/posts/#{post.id}") %>
</div>
</div>
<% end %>
# app/controllers/users_controller.rb
def likes
@user = User.find_by(id: params[:id])
@likes = Like.where(user_id: @user.id)
end
何がN+1(1+N)かと言うと、例えばid=1のuserのlikeを取得したいときは次のようになり、これが1に該当します。
select * from likes where user_id = 1
そしてNの部分ですが、仮にこれに合致するlikesが3つあってそれぞれpost_id=2,3,4を持っていたとします。
さらにlikeをしたpostを取得することを考えると
select * from posts where id = 2
select * from posts where id = 3
select * from posts where id = 4
これで、N個(3)のクエリが発生し、結果として1+N(n=3)になり、該当のレコードが増えてくると処理が増えてくるのがイメージ出来るかと思います。 個人的にはN+1より、1+Nの方がイメージし易いかったです。
そして、今回こうなっているのはlikes3つぶんeachで回しているので、このようになります。
これを解消する方法として、次の2つの方法があります。
- join(railsではeager_load)
- preload
join
1つ目がjoin
で、Railsではeager_load
のことを指すようです。
select * from likes left join posts on posts.id = likes.post_id where likes.user_id = 1
こうすると1クエリでuser_idが1のlikesとそれに紐づくpostsが全て取得できます。
preload
2つ目がpreload
で、クエリを2つに分ける方法です。
select * from likes where user_id = 1
select * from posts where id in (2,3,4)
こちらもNの数によらずクエリは2つですみます。
そして、上記のjoin
とpreload
の2つの処理をSQL発行時の条件に合わせて、どちらかの挙動を行なってくれるのが、今回の対処で使用するincludes
になります。
下記のコードがincludes
を使用したものになります。
# app/views/users/likes.html.erb
<% @likes.each do |like| %>
<div class="posts-index-item">
<div class="post-left">
<img src="<%= "/user_images/#{like.post.user.user_image}" %>">
</div>
<div class="post-right">
<div class="post-user-name">
<%= link_to(like.post.user.name, "/users/#{like.post.user.id}") %>
</div>
<%= link_to(like.post.content, "/posts/#{like.post.id}") %>
</div>
</div>
<% end %>
# app/controllers/users_controller.rb
def likes
@user = User.find_by(id: params[:id])
@likes = @user.likes.includes(post: :user)
end
※一行ずつ分割してみます。
@user = User.find_by(id: params[:id])
仮にGET /users/1というHTTPリクエストをviewから受取った場合、id:'1'を取得し、@userは、User.find_by(id:1)となります。
参考:User.find_by(id: params[:id])
@likes = @user.likes.includes(post: :user)
次に、@userで取得したid情報に関連するlikesを取得し、likesに紐づくpost:(関連1)を紐付け、関連1に紐づく、:user(関連2)の情報を取得する。
引数 = @user.likes.includes(post: :user)
モデル名 = @user
関連名1: = post: # likes(モデル)の関連名である、post(アソシエーションで定義した関連名)
:関連名2 = :user # postモデルの関連名である、user( 〃 )
※関連1、2はテーブル名ではなく、モデルに書いたアソシエーション。
要するに、like - post - userをこの一行で関連付けているので、繰り返し処理をする必要がなくなり、ループ内で関連したテーブルのN+1問題なので、includes を使用して対処しているという流れになります。
簡単ではありますが、以上になります。
最後までお読み頂きありがとうございました。
初学者の記事なので、間違っている箇所があればご指摘頂ければ幸いです。