ActiveRecord周りのパフォーマンス・チューニングの備忘録。

DBレベルのチューニングはしたものの、Railsアプリのレイヤーでパフォーマンスを上げたい、という人向けの記事です。
今回ベンチマークに使ったコードはこちらにあります。

先に結論

  • preloadとeager_loadではpreloadの方がパフォーマンスが良くなるケースがある。
  • SQLでは十分速くてもActiveRecordのインスタンス生成の部分でコストがかかるので、SQLだけでなくトータルで計測する必要がある。
  • SQLで絞込みした方が高速。アプリ内での絞込みは遅くなる。
  • select_allやfind_by_sqlは速いが、JOINのインスタンス生成を行わないことや可読性/可搬性に注意する必要がある
  • やっぱりcacheが一番速い

前提

  • 今回はINDEXなどのDBレベルのチューニングは済んでいる。その上でパフォーマンスを上げるのが今回の目的。
  • 基本的にはレコード数やスキーマ、抽出条件に依存するため、何が速いかはケースバイケース。パフォーマンスに問題有るのであれば、まず計測・比較することが大事。

計測対象アプリの環境

Ruby 2.4.1
Rails 5.1.1
DB MySQL
OS macOS Sierra

スキーマとデータ量

  • 記事種別(ContentType)、記事(Content)、記事に対するコメント(Comment)、コメントを書いた人(Author)
  • ContentType : Content = 1 : N
  • Content : Comment = 1 : N
  • Author : Comment = 1 : N
ContentType 10
Content 8,000(ContentTypeに対して800レコードずつ)
Comment 24,000(Contentに対して3レコードずつ)
Author 3

ベンチマークの方法

以下のRakeタスクで計測しました。

ActiveRecordが配列に展開されるまで(to_a)の時間を計測しています。

また、一部のケースにおいてはstackprofを使って、メソッドのプロファイリングをしました。

測定結果

  • eager_loadよりもpreloadの方が速い傾向にある
  • 絞込は有ったほうが速い
  • ActiveRecord::Baseのインスタンス生成が走らないselect_all、find_by_sqlは速い
  • やっぱりcacheが最強

preload vs eager_load

今回はpreloadの方がeager_loadよりも速い結果となりました。

どちらもN+1クエリを撲滅するためのメソッドですが、preloadは複数クエリに分割してアプリ内でJOINし、eager_loadはDB内でLEFT OUTER JOINをするという違いがあります。また、preloadをしたアソシエーションに対しては、そのインスタンスのwhereメソッドで絞込をすることができず、以下のような呼び出し方をするとエラーになります。

これはpreloadで最初に発行されるクエリ(上記だとHogeを取得するクエリ)でwhere句が効いてしまうのが原因です。
preloadでwhere句による絞込をする場合は、アソシエーションのスコープに対して絞込条件を記述します。これによって、分割したクエリ内で絞り込み条件が効くようになります。

分割されたクエリの中で条件(scope)が効いている様子↓

一番遅いケース(eager_loadの複数JOIN)でも、SQL自体の速度はそこまで遅くないことにも注意が必要です。4〜5秒程度かかっていたケースでもSQL自体は200msで取得できていて、ActiveRecord::Baseにインスタンス生成する過程が遅いことがわかります。

絞込有り無し

絞込有り/無しを比較した場合、明らかに絞込有りの方が速いです。

絞込に関しては、stackprofによるプロファイリングをかけて調査してみました。絞込なしのケースだと以下のような結果になりました。

MySQL2::Result#eachのSAMPLES(コールスタック内のトップでサンプリングされた回数)が多く、SQLで取得したレコードをインスタンス生成する処理に時間がかかっていることが予想されます。ActiveRecord::Associations::JoinDependencyのメソッド呼び出しのTOTAL(コールスタック内でサンプリングされた回数)も大きく、JOIN関連の処理が重いことがわかります。

絞込有りのケースだと以下のようになりました

全体的にTOTAL/SAMPLESの数が圧倒的に減っています。また、ActiveRecord::Associations::JoinDependency#construct_modelはTOTAL/SAMPLESともに減っています。このことから、絞込んだことによってJOINのコストが大幅に減少し、速度が改善されたということが言えそうです。

select_all/find_by_sql

こちらはインスタンス生成やアプリ内でJOINの処理をしないので高速です。

プロファイリングの結果は以下の通り。

select_allはインスタンス生成が走っておらず、find_by_sqlはインスタンス生成は走っているもののJOINの処理が無いことがわかります。SQLを直接書くということは可搬性も損ねかねないのと、可読性も悪くなるため導入する際は注意が必要です。

キャッシュ

言わずもがなだと思いますが、今回のベンチマークで一番高速でした。

ActiveRecordをキャッシュする場合はfind系以外の場合、遅延評価でクエリを実行するActiveRecord::Relationが返ってくるためto_aメソッドで配列化する必要があります。もしto_aなどで配列展開しない場合は実行クエリがキャッシュされるだけで、レコード自体はキャッシュされません。具体的には以下のようにしてキャッシュする必要があります。

参考URL