🐙

SQLの発行を抑えて関連するテーブルのレコード数を取得する方法

2021/08/22に公開

はじめに

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のコードも詳しく深掘りしていきたいと思います。

参考

Active Record の関連付け - conter_cache

Discussion