N個ActiveRecordのオブジェクト(モデルA)があったとして、それらが持つassociation(モデルB)のデータは要らずcountだけ表示したいとする。
その際、以下のような4つの手が思いつく。
- AのテーブルにBの数をキャッシュするカラムを追加し、それをSELECTする
-
belongs_to
のcounter_cache
オプション
-
- N回BのCOUNTクエリを走らせる
- 普通にやるとこうなる
-
LEFT JOIN
とGROUP BY A.id
でB.id
をCOUNTする- がんばってクエリを1回にするとこうなる
- N個の
A.id
からB.a_id
をpluckしてきて各idごとの個数をとる- これをやる方法がないので作った: k0kubun/activerecord-has_count
- count専用のhas_count associationを生やし、外部キーをpluckする専用のpreloaderを使うことができる
- 作ったばかりでいまのところ最新のRailsでしかテストしてないので注意
カラムを好きに増やせる状況ではcounter_cache
を使えば良いが、問題はそうでない場合に残り3つのどれを選択すべきか、である。
ベンチをとる
「推測するな、計測せよ」というわけで、実際にベンチをとってみた。
DBはMySQLを使い、以下のコードでベンチをとった。
TWEET_COUNT = 5 # N個のレコード
REPLY_COUNT = 100 # COUNTしたい数
TWEET_COUNT.times do
tweet = Tweet.create(replies_count_cache: 0)
replies = REPLY_COUNT.times.map do
Reply.create(tweet: tweet)
end
end
Benchmark.bm do |bench|
bench.report("counter_cache") do
Tweet.first(TWEET_COUNT).map{ |t| t.replies_count_cache }
end
bench.report("N+1 COUNT query") do
Tweet.first(TWEET_COUNT).map{ |t| t.replies.count }
end
bench.report("LEFT JOIN") do
# 無駄にオブジェクト作りたくないのでincludesやeager_loadのLEFT JOINに頼らない
tweets = Tweet.joins('LEFT JOIN replies ON tweets.id = replies.tweet_id').
select('tweets.*, COUNT(replies.id) AS replies_count').
group('tweets.id').first(TWEET_COUNT).
map{ |t| t.replies_count }
end
bench.report("pluck foreign key") do
# replies_count は activerecord-has_count gem 独自のhas_countというassociationで、
# preloadするとActiveRecord内部で外部キーをpluckする
Tweet.preload(:replies_count).first(TWEET_COUNT).map{ |t| t.replies_count }
end
end
いろいろ省略してるけど要点は伝わったと思う。
結果
目的はcounter_cache
以外の3つの方法を比較することだが、参考までにcounter_cache
の結果も並べる。
N = レコードの個数 TWEET_COUNT
C = COUNT対象の数 REPLY_COUNT
とすると、
N / C | 5 / 10 | 5 / 100 | 5 / 1000 | 10 / 10000 | 1000 / 5 |
---|---|---|---|---|---|
counter_cache(参考) | 0.002101 | 0.001904 | 0.002094 | 0.010339 | 0.064779 |
N+1 COUNT query | 0.008318 | 0.007874 | 0.009034 | 0.039144 | 0.554877 |
LEFT JOIN | 0.004635 | 0.005358 | 0.006857 | 0.056636 | 0.497913 |
pluck foreign key | 0.002272 | 0.004111 | 0.023250 | 0.616233 | 0.371234 |
となった。
どの方法を使うべきか
場合によって最速の方法が変わる結果となった。
傾向としては、以下のようなことがわかる。
- 当たり前だけどcounter_cacheはいつでも速い
- COUNT対象が 100オーダー以下 の場合、 外部キーをpluckするのが最速
- クエリ効率が良く、数が小さければRubyでやる処理が少ないので速い
- COUNT対象が 1000オーダー の場合、LEFT JOINするのが最速
- たいていの場合無難な速さが出る
- COUNT対象が 10000オーダー以上の場合、COUNTクエリをN回走らせるのが最速
- JOINコストが大きくなり、COUNTしまくるほうが速くなる
- あんまこういうケースなさそう
結論
カラムを追加しても問題ないときはcounter_cacheを使う。
そうでなければ、COUNT対象があまり多くないときはactiverecord-has_countを使い、ある程度スケールしてきたらLEFT JOINを行うのが良い。