SQLの発行を抑えて関連するテーブルのレコード数を取得する方法
はじめに
Micropotモデルと関連をもつUserモデルがあるとします。
User.all.each do |user| # …①
p "#{user.name}さんの投稿数:#{user.microposts.count}" # …②
end
これを実行すると、発行されるSQL文章は以下のようになります。
SELECT "users".* FROM "users"
SELECT COUNT(*) FROM "microposts" WHERE "microposts"."user_id" = ? [["user_id", 1]]
.
.
.
SELECT COUNT(*) FROM "microposts" WHERE "microposts"."user_id" = ? [["user_id", 5]]
まずは①でUserに関するSQLが発行され、次に呼び出された個々のUserインスタンスに対してMicropostsを呼び出すためのSQLが発行されます。
俗に言うN + 1問題が起こっていることがわかります。
この問題に対して、
- 解決できないダメな方法
- counter_cacheを使う方法
- counter_cacheを使わずに解決する方法
を説明したいと思います。
incldueを使っても…?
「お、N + 1だったらincludesやな!」と嬉々としてincludesを使ってみましょう。
User.all.includes(:microposts).each do |user|
p "#{user.name}さんの投稿数:#{user.microposts.count}"…③
end
発行されるコードは以下となります。
SELECT "users".* FROM "users"
SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" IN (?, ?, ?, ?, ?) [["user_id", 1], ["user_id", 2], ["user_id", 3], ["user_id", 4], ["user_id", 5]]
SELECT COUNT(*) FROM "microposts" WHERE "microposts"."user_id" = ? [["user_id", 1]]
.
.
.
SELECT COUNT(*) FROM "microposts" WHERE "microposts"."user_id" = ? [["user_id", 5]]
全く変わらずにN + 1回SQLが発行されていますね。なんなら無駄に一回Micropostsテーブルのレコードが呼ばれているのでN + 2ですね。
なぜなら③の部分の user.microposts.count
は必ずSQL文が発行されるためです。詳しく説明すると
includeは関連するテーブルを取得しているだけでSELECT COUNT(*)~
以下のSQLをあらかじめ発行しているわけではありません。
そのためuser.microposts
箇所のSQL文の発行を抑えることはできても.count
の部分では必ずSQL文が発行されると言うわけです。
実際に以下のようなコードであればincludesで、N + 1問題を解決することができます。
User.all.incldues(:microposts).each do |user|
p "#{user.name}さんの投稿"
user.microposts.each do |micropost|…④
p "タイトル:#{micropost.content}"…⑤
end
end
なぜなら、先に関連するMicropostsテーブルをまとめて取得することで④でSQLを毎回発行する必要がなくなり、また⑤の部分でSQL文章は発行されていないためです。
counter_cacheを使う方法
- 関連先のモデル(今回であればMicroposts)に
counter_cache: true
を追加 - 関連元のテーブルにカウント用カラムを追加
します。
# micropost.rb
class Micropost < ApplicationRecord
belongs_to :user, counter_cache: true
end
# migration file
class AddColumnToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :microposts_count, :integer, default: 0
end
end
すると以下のコードで関連先テーブルのレコード数を取得できます。
User.all.each do |user|
p "#{user.name}さんの投稿数:#{user.microposts_count}"
end
# 発行されるSQL文
User Load (0.1ms) SELECT "users".* FROM "users"
関連先のテーブルのレコードが追加・削除される度に以下のようなSQLが実行され、カウントが常に更新されていることがわかります。
User.first.microposts.create!
# 発行されるSQL文
SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
INSERT INTO "microposts" ("user_id", "created_at", "updated_at") VALUES (?, ?, ?) [["user_id", 1], ["created_at", "2021-08-22 05:39:23.683764"], ["updated_at", "2021-08-22 05:39:23.683764"]]
UPDATE "users" SET "microposts_count" = COALESCE("microposts_count", 0) + ? WHERE "users"."id" = ? [["microposts_count", 1], ["id", 1]] …microposts_countの更新が行われている
問題点として、カラムを途中から追加した場合はカウンターと実際の個数のズレが生じるため修正する必要があるという点です。
INNER JOINを使う方法
最後にcounter_cacheを用いずにSQLの発行を抑えます。
User.all.joins(:microposts).select("users.name, count('microposts.*') as microposts_size").group("users.id").each do |user|
p "#{user.name}さんの投稿数:#{user.microposts_size}"
end
# 発行されるSQL文
SELECT users.name, count('microposts.*') as microposts_size FROM "users" INNER JOIN "microposts" ON "microposts"."user_id" = "users"."id" GROUP BY users.id
このように、先に関連先のテーブルをJOINしておき、user.idでグループ化した上で関連するテーブルのレコード数を集計しておくことでSQL文の発行を抑えることができます。
counter_cacheを使う方法に比べ若干パフォーマンスは落ちるものの、
- 一番のポイントであるSQLの発行回数は抑えている
- 新しくカラムを追加する必要がない
- 途中からの導入も可能
という点でこれもまた悪くはない方法な気がします。
最後に
今後は理解度が上がればModule: ActiveRecord::CounterCacheのコードも詳しく深掘りしていきたいと思います。
Discussion