追記: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クエリが走る