アソシエーションとN+1問題について

目次

アソシエーションとは

簡単にいうと、複数のテーブルを紐付けること。

例えば、ユーザー(users)のテーブルには「鈴木」と「高橋」というデータがある。

投稿(posts)のテーブルには「好きな食べ物は寿司」「趣味は車」「趣味はピアノ」「趣味は車」「よくジムで筋トレする」みたいなデータがある。

この時に「鈴木」が「投稿したもの」と、「高橋」が「投稿したもの」に分けたい、これが紐付け。

例えば今回のケースだと、「鈴木」が投稿したのは「好きな食べ物は寿司」「趣味はピアノ」、「高橋」が投稿したのは「趣味は車」「よくジムで筋トレする」みたいな感じ。

具体的にどうする?

上記の例を考えるとき。

「鈴木」が投稿したのは「好きな食べ物は寿司」「趣味はピアノ」みたいに、1人のユーザー対し、複数の投稿が存在するという関係になる。

端的にいうと、1対多数(親と子)の関係であり、これを定義してあげればよい。

そのためには、親のモデル(今回はUserモデル)に以下を記述。

 has_many :posts, dependent: :destroy
# 紐づく子のモデル名については複数形で
# また、dependent: :destroyとすることで、親のデータが消えると、一緒に子のデータも消えるようになる。

子のモデル(今回はPostモデル)に以下を記述。

 belongs_to :user
# 紐づく親のモデル名については単数形で。

ただし、データベース上での紐付けも必要

上記だとモデル同士が紐づいているイメージ?なので、データベース上でテーブル同士の紐付けをする必要がある。

最初の例を考えると、鈴木の投稿がuser_id:1、高橋の投稿がuser_id:2みたいにしてあげれば良い。

すると、「好きな食べ物は寿司」のuser_id:1、だからこれは鈴木の投稿!みたいな判別ができる。

これを言い換えると、紐付けの関係を作るためには多数(子)に、親モデル_idを付与してあげると良いということ。

そのためには以下2つの方法が考えられる。

1. そもそも、モデルを作る際に定義してあげる。

今回の場合はPostモデルを作る際に、以下のような記述を行う。

rails generate model posts(モデル名) title:string body:text user:references

上記の記載例で、Postモデルのほか、postsテーブルを作成するマイグレーションファイルも作成される。

そしてマイグレーション ファイルに、titleカラム、bodyカラムだけでなく、user:referencesとすることで、user_idカラムが作成される記述が出来上がる。

2. 後から定義する

その場合は関連付けを規定する、マイグレーションファイルを作成する。以下、マイグレーションファイル作成の記載例。

rails generate migration AddUserIdToPosts 

作成されたマイグレーションファイルに以下を記述し、実行

 class AddUserIdToPosts < ActiveRecord::Migration[5.2]
  def change
    add_reference :posts, :user, foreign_key: true
  end
end

以下、結果はモデルを作る際に定義した場合と同様。

N+1問題とは?

アソシエーションを行った際、必ず考慮するべきなのが、N+1問題。

例えば、投稿を繰り返しで全部表示したい時。

一旦、@posts = Post.all みたいに投稿データをインスタンス変数に代入する。

そして、繰り返しの処理をして、表示するためには以下の通り。

<%= @posts.each do |post| %>
    post
<% end %>

これで、見た目は問題ないけど、実は中身ではとんでもないことが起きているって話!

実は@posts = Post.all の時点。

ここでは、当然postsテーブルからデータを引っ張ってきている(1回目、これが+1の部分。

しかし、SQLさんは「あれ、postsテーブルの中にuser_id: 1 がある…。あっ、そういえばusersテーブルと紐づいてたじゃん!よし、usersテーブルのuser_idが1のレコードを確認しよう!」となる(2回目)。

そして、SQLさんは「あれ、よく見ると postsテーブルの中にuser_id: 2 もある!?user_idが2のレコードも確認しなきゃ…」となる(3回目)。

つまり、余計なお世話だよ!みたいなことをしている。(もちろん、投稿に紐づいている投稿者の名前を表示したい時などはusersテーブルの参照も必要。とはいえ、user_idごとに参照するんじゃなくて、usersテーブルをまとめて見てくれよ…、見たいな感じ)

なので、なんと @posts = Post.all では3回もSQLが動いているということ!

言い換えると、たった1回の処理なのに、postsテーブルに1回、usersテーブルに2回、計3回アクセスしていて、余計な動作をしているということ。

まとめると、テーブルとテーブルが紐づいているとき。そのテーブルを参照する際、そのテーブルを1回、もう片方のテーブルを紐づいているid数(n個)だけ参照しているということ。

これの何が問題かというと、必要以上にSQLが発行され、特にもデータの量が増えるほど、めちゃくちゃアプリの動作が重くなってしまう可能性がある。

N+1問題の解決方法

解決するにはincludesメソッドを使用すればOK。

先ほどの例だと以下のように記述。

@posts = Post.all.includes(:user)