ActiveRecordでN+1 countクエリを潰す方法、あるいはactiverecord-count_loaderの使い方

追記:activerecord-count_loaderはactiverecord-precountに変わりました。使い方はこちら

ActiveRecordでassociationを読むためにN+1クエリが出てしまった場合、 includesなどのメソッドを利用することで発行されるクエリの数を減らすことができる。*1
ところが、それがcountクエリになると、eager loading用のメソッドでクエリの数を減らすことができない。

そこで、N+1 countクエリを潰す方法を調べ、まとめてみた。

ActiveRecordでN+1 countクエリを潰す3つの方法

TweetとFavoriteというモデルがあったとして、Tweetの全カラムと、各TweetのFavoriteの数だけ欲しい場合にN+1 countクエリを潰す方法を示す。

1. counter_cacheを使う

class Tweet < ActiveRecord::Base
  has_many :favorites
end

class Favorite < ActiveRecord::Base
  belongs_to :tweet, counter_cache: true
end

@tweet = Tweet.create
# INSERT INTO `tweets` (`created_at`, `updated_at`) VALUES ('2014-09-12 15:37:25', '2014-09-12 15:37:25')

Favorite.create(tweet: @tweet)
# INSERT INTO `favorites` (`created_at`, `tweet_id`, `updated_at`) VALUES ('2014-09-12 15:37:27', 12, '2014-09-12 15:37:27')
# UPDATE `tweets` SET `favorites_count` = COALESCE(`favorites_count`, 0) + 1 WHERE `tweets`.`id` = 12

Tweet.first(5).map(&:favorites_count) #=> [3, 1, 4, 2, 1]
# SELECT `tweets`.* FROM `tweets` ORDER BY `tweets`.`id` ASC LIMIT 5

belongs_toにはcounter_cacheというオプションがある。
これは、favorites_countという名前のカラムを用意しておくと、レコードが増えたときにfavorites associationのcountをfavorites_countカラムにいれておいてくれるというもの。
おそらくこれがRails wayというやつである。

実際パフォーマンス的には最良の選択だと思うが、カラムを一つ増やしたりcounter_cacheの初期化のためにクエリを流したりとオペレーション上導入少し手間がかかるのと、counter_cacheの更新にはデッドロックのエラーが出るリスクがある。

2. JOINを使う

Tweet.joins('LEFT JOIN favorites ON tweets.id = favorites.tweet_id').
  select('tweets.*, COUNT(favorites.id) AS favorites_count').
  group('tweets.id').first(5).map(&:favorites_count) #=> [3, 1, 4, 2, 1]
# SELECT  tweets.*, COUNT(favorites.id) AS favorites_count FROM `tweets` LEFT JOIN favorites ON tweets.id = favorites.tweet_id GROUP BY tweets.id  ORDER BY `tweets`.`id` ASC LIMIT 5

すごい汚いが、クエリで実現しようとするとこうなる。 *2
カラムを追加しなくて良いし、クエリ1発で済む分性能も良い。

3. eager loadingしてsizeをとる

Tweet.includes(:favorites).first(5).map{ |t| t.favorites.size } #=> [3, 1, 4, 2, 1]
# SELECT `tweets`.* FROM `tweets` ORDER BY `tweets`.`id` ASC LIMIT 5
# SELECT `favorites`.* FROM `favorites` WHERE `favorites`.`tweet_id` IN (1, 2, 3, 4, 5)

includesでassociationをキャッシュしておきその配列のサイズを取る方法。*3
コードは綺麗になるのだが、countされたレコードのオブジェクトを全て生成してしまうので、count対象が多いとかなり遅くなる。

activerecord-count_loader gemを使う方法

最近、要件のビジネスロジックが複雑で、associationが大量にネストするRailsアプリを書いているのだが、その際にネストしているassociationのN+1 countクエリを潰したいということがある。
先ほど述べたようにその状況では2の方法を使えないし、1を使って再利用できないカラムを増やしたくないので悩んでいた。

そこで、associationのcountもassociationとして扱えれば良いのではと思いついたので、activerecord-count_loaderというgemを作った。

k0kubun/activerecord-count_loader

使い方

class Tweet < ActiveRecord::Base
  has_many :replies
  has_many :favorites, count_loader: true # :favorites_countというassociationが生える
end

class Reply < ActiveRecord::Base
  belongs_to :tweet
end

class Favorite < ActiveRecord::Base
  belongs_to :tweet
end

Tweet.includes(:favorites_count).first(5).map(&:favorites_count) #=> [3, 1, 4, 2, 1]
# SELECT  `tweets`.* FROM `tweets`   ORDER BY `tweets`.`id` ASC LIMIT 5
# SELECT `favorites`.* FROM `favorites`  WHERE `favorites`.`tweet_id` IN (1, 2, 3, 4, 5)

Reply.includes(tweet: :favorites_count).first(5).map(&:tweet).map(&:favorites_count) #=> [3, 1, 4, 2, 1]
# SELECT  `replies`.* FROM `replies`   ORDER BY `replies`.`id` ASC LIMIT 5
# SELECT `tweets`.* FROM `tweets`  WHERE `tweets`.`id` IN (1, 2, 3, 4, 5)
# SELECT `favorites`.* FROM `favorites`  WHERE `favorites`.`tweet_id` IN (1, 2, 3, 4, 5)

このように、count_loaderでassociationを生やすと、ネストしたassociationまでeager loadingすることができる。
当たりまえだけどincludesしてなくてもfavorites.countを呼んで正常に動作する。
従来の方法と比較して、以下のようなメリットがある。

  • 1と違って、カラムを追加しなくて良い
  • 2と違って、ネストした要素までeager loadingでき、コードも綺麗に保てる
  • 3と違って、count対象のassociationをキャッシュしなくて良いため、余計なメモリの消費を抑えられる

もし機会があればお試しください。

*1:合わせて読みたい: ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い

*2:joinsで生のクエリを書いているのは、includesやeager_loadでJOINすると無駄にActiveRecord::Baseのオブジェクトが生成されメモリを大量消費し、かえって遅くなる可能性もあるため。

*3:sizeにするとassociationのサイズをとってくれるが、countだとCOUNTクエリが走る