RailsでN+1 countクエリを潰すactiverecord-precountを作った

追記: 2017/09/06

少しAPIが冗長なもののActiveRecordへのモンキーパッチが少ないバージョン activerecord-precounter というのを作りました。こちらの方がバグりにくいはずなので、現在はactiverecord-precounterの方を使うことが推奨されます。

github.com

概要

N+1 countクエリを最大11.7倍速くできるactiverecord-precountというgemを作った。 *1

k0kubun/activerecord-precount

N+1 countクエリ

Tweet.all.each do |tweet|
  p tweet.favorites.count
end
# SELECT `tweets`.* FROM `tweets`
# SELECT COUNT(*) FROM `favorites` WHERE `favorites`.`tweet_id` = 1
# SELECT COUNT(*) FROM `favorites` WHERE `favorites`.`tweet_id` = 2
# SELECT COUNT(*) FROM `favorites` WHERE `favorites`.`tweet_id` = 3
# SELECT COUNT(*) FROM `favorites` WHERE `favorites`.`tweet_id` = 4
# SELECT COUNT(*) FROM `favorites` WHERE `favorites`.`tweet_id` = 5

私がN+1 countクエリと呼んでいるのはこういう奴で、見たことがある人は多いと思う。 これを解決するには、基本的にはcounter_cacheを使うしかない。 *2

countクエリのeager loading

しかし、スキーマ冗長化したら速くなるのは当たり前で、そんな手間をかけなくてもクエリをチューニングすればそこそこ速くなる。 で、それをActiveRecordで簡単に実現する方法がないのが問題で、そのAPIを提供するためにactiverecord-precount gemを作った。

このgemには、eager loadingのためのAPIが2つある。

precount

Tweet.all.precount(:favorites).each do |tweet|
  p tweet.favorites.count
end
# SELECT `tweets`.* FROM `tweets`
# SELECT COUNT(`favorites`.`tweet_id`), `favorites`.`tweet_id` FROM `favorites` WHERE `favorites`.`tweet_id` IN (1, 2, 3, 4, 5) GROUP BY `favorites`.`tweet_id`

普通にデータを取るクエリとeager loadingのためのクエリを分けて実行するのがprecount。 クエリを分割してeager loadingを行うpreloadにちなんで命名している。

このベンチ では、precount(:favorites)を呼ぶことで最大7.9倍速くなる。

eager_count

Tweet.all.eager_count(:favorites).each do |tweet|
  p tweet.favorites.count
end
# SELECT `tweets`.`id` AS t0_r0, `tweets`.`tweet_id` AS t0_r1, `tweets`.`user_id` AS t0_r2, `tweets`.`created_at` AS t0_r3, `tweets`.`updated_at` AS t0_r4, COUNT(`favorites`.`id`) AS t1_r0 FROM `tweets` LEFT OUTER JOIN `favorites` ON `favorites`.`tweet_id` = `tweets`.`id` GROUP BY tweets.id

JOINを使ってクエリ1つでeager loadingも行うのがeager_count。 似た挙動をするeager_loadにちなんで命名した。 なんとなくJOINしたくない気分のときにprecountの方を使うことを想定している(雑)。

このベンチ では、eager_count(:favorites)を呼ぶことで最大11.7倍速くなっている。 counter_cacheは同じ条件で20.0倍なので、手間をかけずに11.7倍か、コストかけて20倍にするかで実装を選択すると良さそう。

使い方

Gemfileに以下を追加してbundle installするとrelation上でprecountやeager_countが使えるようになります。

gem 'activerecord-precount'

N+1 countクエリを見かけることがあったらお試しください。

*1:ベンチマークこちらで、 結果はtravisで見れる

*2:この記事でちょっと書いた