Railsアプリケーションを遅くするActiveRecordの3つの間違い:Count,Where,Present
ネイト・ベルコペック (@nateberkopec) SpeedShop(詳細),Railsパフォーマンス・コンサルタンシー
要約:Rails開発者の多くが、ActiveRecordがSQLクエリを実際に実行する条件を理解していません。よくある3つのケースを見てみましょう:countメソッドの誤使用と、サブセットをセレクトするためのwhereの使用、そしてpresent?です。 断言しましょう。あなたはこの3つのメソッドを使い過ぎていて、無駄なQueryとN+1を発生させているでしょう。
(字幕:なんて奇妙な謎だ、バットマン!)
"いつActiveRecordがクエリを実行するかって?知るか!"
ActiveRecordは素晴らしいです。本当に。しかし抽象化されているので、データ・ベース上で実行されている実際のSQLクエリに無頓着になりがちです。ActiveRecordがどのように動作するか理解していないと、意図しないSQLクエリが実行されるでしょう。
残念ながら、ActiveRecordの多くの機能に対するパフォーマンス・コストが意味するところは、無駄な使用を無視する余裕や、ORM を単純に実装の詳細として扱う余裕がないということです。どのようなクエリが、パフォーマンスが重要視されるエンド・ポイントで実行されるのか正確に理解する必要があります。自由は無料ではないのです。ActiveRecordも同じです。
(ダーティ・スリー .where .count. present?)
私の顧客で、ActiveRecord使用時によくある間違いは、例えば必要のないところでActiveRecordがSQLクエリを実行されることです。ほとんどの顧客が、このような事が起きていることすら全く気が付いていません。
特にコレクション内の各要素のためにレンダリングされている箇所に、必要のないSQLがあると、コントローラのアクションが非常に遅くなる、典型的な原因になります。サーチ・アクションやインデックス・アクションで顕著です。私がパフォーマンス改善のコンサルティングを顧客に行う際に目にする最も典型的な問題で、担当したほとんど全てのアプリケーションで起っていました。
必要ないクエリを除去する方法の一つは、ActiveRecordに頭を突っ込んで、内部を理解することです。つまり、どういったメソッドがどのように実装されているかを、正確に知ることです。今日は、3つのメソッドの実装と使用について見ていきます。多くの必要ないクエリをRailsアプリケーションの中で起こします:count、where、 present?
どうやって必要のないクエリを知るのですか?
特定のSQLクエリが不必要かどうか、判定するための基準があります。理想的には、Railsのコントローラ・アクションでのSQLクエリの実行は、テーブル毎に一つにするべきです。もしテーブル毎に一つ以上のSQLクエリがある場合、通常1、2回のクエリにする方法を見つけることができます。1つのテーブルに対して、半ダース以上かそのぐらいのクエリがあるのなら、ほとんど間違いなく不必要なクエリがあります(*11)。
*11 これに対して、"いや、それってさ(訳注:オタクっぽく)"とe-mailしたり、ツイートしないでください。これはガイドラインです。ルールではありません。一つのテーブルに対して1回より多いクエリが良い場合もあることは理解しています。
テーブル毎のSQLクエリの回数は、NewRelicをインストールしてあれば簡単に見ることができます。
(最悪なN+1を見つけるために、私の机の横には目を洗う場所があります)
もう一つの基準は、ほとんどのクエリはコントローラ・アクションのレスポンスの前半に実行されるべきで、中間時点に実行されることは、ほぼありません。中間時手にクエリが実行されることは、N+1になってしまうことが多いため、通常意図された状況ではありません。開発モードでログをみれば、コントローラが実行されている間に、問題が起こっているかどうか簡単にわかります。例えば下のログを見てください:
User Load (0.6ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT 1 [["id", 2]] Rendered posts/_post.html.erb (23.2ms) User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT 1 [["id", 3]] Rendered posts/_post.html.erb (15.1ms) |
...このログにはN+1があります。
通常クエリがコントローラ・アクションの半ば(例えばどのかの部分の深いところで)で実行される場合、必要なデータがpreloadされなかったことを意味します。
それでは、Count、Where、present?メソッドと、なぜこれらが必要ないSQLクエリを発生させるのか、一つづつ見ていきましょう。
.countは毎回COUNTを実行します
私が関わっている会社のほぼ全てで、この問題を見かけます。あまり知られていないようですが、ActiveRecordリレーションでcountをコールすると、常にSQLクエリの実行が試まれます。毎回です。これはほとんどのシナリオにおいて不適切です。そのため、原則的に、countはSQLのCOUNTを今すぐ実行したい時にだけ使ってください。
”テーブル毎に何回クエリをしたいですか?”
必要ないcountクエリを発生させる最も典型的な原因は、後でビューの中で使用する(もしくはすでに使った)、アソシエーションをcountすることです。
# _messages.html.erb # Assume @messages = user.messages.unread, or something like that (Assume @messages = user.messages.unread、もしくはそういったもの)
<h2>Unread Messages: <%= @messages.count %></h2> <% @messages.each do |message| %> blah blah blah <% end %> |
これは2つのクエリを実行します。一つのCOUNTと一つのSELECTです。COUNTは@messages.countによって実行されます。そして @messages.eachがSELECTを全てのメッセージを読み込むために使用しています。
その部分のコードの順番を変えて、countをsizeに変えると、COUNTクエリは完全に除去され、SELECTが残ります:
<% @messages.each do |message| %> blah blah blah <% end %> <h2>Unread Messages: <%= @messages.size %></h2> |
なぜこのような事が起こるのでしょうか。ActiveRecord::Relation:で、実際のsizeメソッドの定義を見れば十分でしょう。
# File activerecord/lib/active_record/relation.rb, line 210 def size loaded? ? @records.length : count(:all) end |
リレーションがロードされた時(リレーションが記述したクエリが実行され、結果がストアされることを言っています)、すでに読み込まれたレコード配列にあるlengthを取得しているだけです。これはただ単純な配列のRubyメソッドです。もしActiveRecord::Relationがロードされていなければ、COUNTクエリが起こります。
(起きちゃった)
一方、これがcountがどのように実装されているかです。(ActiveRecord::Calculationsの内部)。
def count(column_name = nil) if block_given? # ... return super() end calculate(:count, column_name) end |
そして、もちろんcalculateの実装は、記憶されたりキャッシュされることは一切ありません。コールされるたびにSQLの計算を毎回実行します。
先ほどの例の中で、単純にcountをsizeに変更するだけでは、まだCOUNTは起こります。sizeがコールされた時、レコードはloaded?されません。ということは、ActiveRecordはまだCOUNTを試みるでしょう。メソッドをレコードが読み込まれた後に移動すると、クエリが除去されます。ここで、ヘッダをこのコードの最後に移動することは、論理的に何の意味もありませんが、その代わり、loadメソッドを使用できます。
<h2>Unread Messages: <%= @messages.load.size %></h2> <% @messages.each do |message| %> blah blah blah <% end %> |
loadは@messagesで記述されたレコードの遅延読み込みというよりは即時読み込みを起こします。レコードではなくActiveRecord::Relationを返します。ということは、sizeがコールされた時、レコードはloaded?です。クエリを避けられています。やったね。
もしこの例で、messages.load.countを使ったとしたら?まだCOUNTクエリが起こるでしょう!
countがクエリを起こさない場合はあるのでしょうか?ActiveRecord::QueryCacheで結果がキャッシュされている時だけはそうです(*2)。同じSQLクエリを2回実行しようとすると、ありえます。
*2 私はQueryCacheの使用について、いくつか意見がありますが、別の機会に記事に書きます。
<h2>Unread Messages: <%= @messages.count %></h2> ... lots of other view code, then later: (...ビューの多くのコードがあり、そのあと:) <h2>Unread Messages: <%= @messages.count %></h2> |
(字幕:いまめちゃくちゃコケにされたぜ。ぐあーーー)
私の意見では、Railsの開発者はcountを使用しているほとんどの場所でsizeを使うべきです。なぜ、誰もがsizeの代わりにcountを使用するのかわかりません。sizeは適切な場所ではcountを使用し、レコードがすでに読み込まれている時は使用しません。これは、ActiveRecordリレーションを書いている時に、"SQL"の感覚だからだと思います。あなたはこう考えます"これはSQLだ。countを書くべきだ。なぜならCOUNTしたいんだから。"
さて、実際どんな時にcountを使うのでしょう。countが必要なアソシエーションを、まだ全て読み込んでいない時に使ってください。例えばRubygems.orgを見てください。シングルgemが表示されます:
"versions"リストでリリース(バージョン)数を取得するために、ビューはcountを実際に使用しています。
<% if show_all_versions_link?(@rubygem) %> <%= link_to t('.show_all_versions', :count => @rubygem.versions.count), rubygem_versions_url(@rubygem), :class => "gem__see-all-versions t-link--gray t-link-- has-arrow" %> <% end %> |
事情を説明すると、このビューは Rubygemのバージョンを決して全て読み込みません。バージョン・リストに表示する最新の5つだけです。
というわけで、ここではcountを使う完璧な意味があります。論理的にはsizeも同等ですが(@versionsがloaded?されていないため単に同様にCOUNTを実行します)、コードの意図を明確に伝えています。
アドバイスとして、countのコールをapp/viewsディレクトリの中からgrepで調べて、実際に意味があることを確認してください。もし本当に実際のSQL COUNTが必要かどうか、100%確信がもてないなら、sizeに変えてください。最悪の場合でも、アソシエーションが読み込まれていなければ、COUNTがまだ実行されるでしょう。後でそのアソシエーションをビューで使うなら、load.sizeに変えてください。
.whereはフィルタリングがデータ・ベースで行われることを意味します。
このコードの問題は何でしょう?(_post.html.erbとしましょう)
<% @posts.each do |post| %> <%= post.content %> <%= render partial: :comment, collection: post.active_comments %> <% end %> |
そして、Post.rbで:
class Post < ActiveRecord::Base def active_comments comments.where(soft_deleted: false) end end |
あなたの答えが、"これは次の部分に実行される部分のレンダリングのたびに、SQLクエリの実行が起こります。"だとしたら、正解です!whereは常にクエリを実行しようとします。関係ないので、コントローラのコードを書きませんでした。includesや他の事前ロードのメソッドを使っても、このクエリを防ぐことはできません。whereは常にクエリを実行しようとします!
アソシエーションのスコープを呼び出した時にも起こります。下のようなCommentモデルが代わりにあったとして:
class Comment < ActiveRecord::Base belongs_to :post scope :active, -> { where(soft_deleted: false) } end |
2つの基準を取り上げさせてください。:コレクションをレンダリングしている時は、アソシエーションのスコープを呼び出さないでください。また、ActiveRecord::Baseクラスのインスタンス・メソッドの中に、whereのようなクエリ・メソッドを入れないでください。
アソシエーションのスコープを呼び出すということは、結果を事前に読み込むことができないということを意味します。上記の例では記事にあるコメントを事前に読み込むことができます。しかし、アクティブになっているコメントは、事前にロードできません。そこで、データ・ベースに戻り、コレクションの各要素に対して、新しいクエリを実行しなければなりません。
もし1回だけで、各コレクションに対して行わない(例えば、上記で全ての記事に対して)のであれば、問題ありません。例えば記事を関連するコメントと一緒に一つだけ表示するようなPostsController#showアクションなどでは、スコープを多く使用しても構いません。しかしコレクションの中で行うと、常にN+1を起こします。
私が知る、この手の問題に対する最も良い解決方法は、新しいアソシエーションを作成することです。Railsのスコープの事前ロードについて、"Practicing Rails"のジャスティン・ウェイス(Justin Weiss)が書いたブログから知りました。このアイデアは、事前ロード可能なアソシエーションを作成するというものです。
class Post has_many :comments has_many :active_comments, -> { active }, class_name: "Comment" end class Comment belongs_to :post scope :active, -> { where(soft_deleted: false) } end class PostsController def index @posts = Post.includes(:active_comments) end end |
ビューは変わりませんが、今、2つのSQLクエリを実行します。一つは記事のテーブルで、もう一つはコメントのテーブルです。いいですね!
<% @posts.each do |post| %> <%= post.content %> <%= render partial: :comment, collection: post.active_comments %> <% end %> |
既に述べた通り2番目の基準として、ActiveRecord::Baseクラスのインスタンス・メソッドに、whereのようなクエリ・メソッドを入れないでください。例えば:
class Post < ActiveRecord::Base belongs_to :post def latest_comment comments.order('published_at desc').first end |
ビューがこのような場合、何が起こるでしょうか?
<% @posts.each do |post| %> <%= post.content %> <%= render post.latest_comment %> <% end %> |
何を事前ロードしたかに関係なく、各PostごとのSQLクエリになります。 私の経験では、ActiveRecord :: Baseクラスの全てのインスタンスメソッドは、最終的にはコレクション内で呼び出されます。 誰かが新しい機能を追加し注意を払っていません。
おそらく、このメソッドを最初に書いた開発者とは別の開発者によるものでしょう。実装を全て読みませんでした。あーあ、今N+1が起きています。先ほど説明したように、私が挙げた例はアソシエーションとして書き直すことができるかもしれません。まだN+1が起こりますが、少なくとも事前ロードを正しく使用すれば簡単に解決することができます。
(字幕:ルールなんて糞くらえっていってるのは、ここじゃ俺だけか?)
どのActiveRecordのメソッドをActiveRecordモデルのインスタンス・メソッドの内で避けるべきなのでしょうか?whereが最も頻繁に問題になりますが、原則的にはQueryMethods、FinderMethods、Calculations中のほぼ全てです。この中のどのメソッドも、通常SQLクエリを実行しようとします。そして事前ロードに逆らいます。
any?, exists?, present?
Railsプログラマは大きな苦痛を受けてきました...アプリケーション内のほぼすべての変数に特定の述語メソッドを追加しています。present?は13世紀ヨーロッパの疫病よりも早くRailsコード・ベースに広がりました。ほとんどの場合、述語は冗長性以外何も追加しません。実際必要だったのは、変数名を書くだけで可能なtruthy/falseyのチェックでした。
下はCodeTriageからの例です。私の友人の、リチャード・シュナイマン(Richard Schneeman)によって書かれたフリーのオープン・ソースのRailsアプリケーションです:
class DocComment < ActiveRecord::Base belongs_to :doc_method, counter_cache: true # ... things removed for clarity... (...わかりやすいように削除しました) def doc_method? doc_method_id.present? end end |
present?とは何でしょうか?ここでは何をしているのでしょうか?一つ目は、doc_method_idの値をnilもしくはIntegerから、trueかfalseに変換します。述語がtrue/falseを返すべきか、truthy/falseyを返すことができるかについて強い意見を持っている人もいます。私はそうではありません。しかしpresent?を追加するのはどうなのでしょうか?どう実装されているかをみて何をしているか見なければなりません。
class Object def present? !blank? end End |
blank? は、"このオブジェクトはtruthyかfalseyか"という質問より複雑です。空の配列やハッシュはtruthyですが、しかしblankか空の文字列もまたblank?なのでしょうか。上のCodeTriageの例では、しかしながら、doc_method_idがとり得るのはnilかIntegerだけです。present?の意味は論理的に!!と同じです。
def doc_method? !!doc_method_id # same as doc_method_id.present? (doc_method_id.present?と同じ) end |
このようなケースでpresent?を使用するのはジョブを行うのに間違った方法です。もし述語で呼び出す値が"空"になることを気にしなければ(つまり[]か{}になれない値)、他に利用可能でより簡単な(そしてより高速な)言語機能を使用して下さい。時折、すでにbooleanになっている値に対しても、行う人を見かけます。冗長になるだけですし、見えないところで、めったに発生しない、おかしなケースがあるのではないかと心配になります。
(おじさん、雲に向かって叫ぶ)
さて、以上が形式に関して私が批判している部分ですが、同意してもらえないかもしれないことは理解しています。present?は頻繁に空("")になる文字列を扱う場合のみ、使う意味があります。
トラブルになるのは、ActiveRecord::Relationオブジェクトでpresent?のような述語を呼び出す部分です。例えば、ActiveRecord::Relationがレコードを何か持っているか、知りたいとします。英語では同義語のany?/present?/exists?、もしくは反対の意味の none?/blank?/empty?が使用できます。どのメソッドを選択するかは、実際大した問題ではない?大声で読んだ時、最も自然な響きのものを選ぶだけでしょうか?もちろん、違います。
下のコードでは、どのようなSQLクエリが実行されると思いますか? @commentsはActiveRecord::Relationだとして考えてください。
- if @comments.any? h2 Comments on this Post - @comments.each do |comment| |
答えは2つです。一つは@comments.any? (SELECT 1 AS one FROM ... LIMIT 1)で起こる、存在のチェックです。それから、@comments.eachでリレーション全体(SELECT "comments".* FROM "comments" WHERE ...)のロードが起こります。
これはどうでしょうか?
- unless @comments.load.empty? h2 Comments on this Post - @comments.each do |comment| |
クエリが一度だけ実行されます。@comments.loadはSELECT "comments".* FROM "comments" WHERE ...によって即時にリレーション全体を読み込みます。
そして、これはどうでしょうか?
- if @comments.exists? This post has = @comments.size comments - if @comments.exists? h2 Comments on this Post - @comments.each do |comment| |
4つ!exists?は記憶されず、リレーションも読み込みません。リレーションはまだロードされていないため、ここでexists?はSELECT 1 ...を呼び出し、.sizeはCOUNTを呼び出します。そして次のexists?は別のSELECT 1 ...を呼び出し、最後に@commentsがリレーション全体を読み込みます!イエーイ!楽しくないでしょう?以下のようにすれば、クエリを1回だけに減らせます。
- if @comments.load.any? This post has = @comments.size comments - if @comments.any? h2 Comments on this Post - @comments.each do |comment| |
単純に良くなります...動作はRails 4.2、Rails 5.0、Rails 5.1+で異なります。
ails 5.1+での動作:
メソッド |
作成されるSQL |
記憶されるか? |
実装 |
loaded?時クエリx実行されるか? |
present? |
SELECT “users”.* FROM “users” |
yes (load) |
Object (!blank?) |
no |
blank? |
SELECT “users”.* FROM “users” |
yes (load) |
load; blank? |
no |
any? |
SELECT 1 AS one FROM “users” LIMIT 1 |
loadedでなければno |
!empty? |
no |
empty? |
SELECT 1 AS one FROM “users” LIMIT 1 |
loadedでなければno |
exists? if !loaded? |
no |
none? |
SELECT 1 AS one FROM “users” LIMIT 1 |
loadedでなければno |
empty? |
no |
exists? |
SELECT 1 AS one FROM “users” LIMIT 1 |
no |
ActiveRecord::Calculations |
yes |
Rails 5.0での動作:
メソッド |
作成されるSQL |
記憶されるか? |
実装 |
loaded?時クエリーが実行されるか? |
present? |
SELECT “users”.* FROM “users” |
yes (load) |
Object (!blank?) |
no |
blank? |
SELECT “users”.* FROM “users” |
yes (load) |
load; blank? |
no |
any? |
SELECT COUNT(*) FROM “users” |
loadedでなければno |
!empty? |
no |
empty? |
SELECT COUNT(*) FROM “users” |
loadedでなければno |
count(:all) > 0 |
no |
none? |
SELECT COUNT(*) FROM “users” |
loadedでなければno |
empty? |
no |
exists? |
SELECT 1 AS one FROM “users” LIMIT 1 |
no |
ActiveRecord::Calculations |
yes |
Rails 4.2での動作:
メソッド |
作成されるSQL |
記憶されるか? |
実装 |
loaded?時クエリーが実行されるか? |
present? |
SELECT “users”.* FROM “users” |
yes (load) |
Object (!blank?) |
no |
blank? |
SELECT “users”.* FROM “users” |
yes (load) |
to_a.blank? |
no |
any? |
SELECT COUNT(*) FROM “users” |
loadedでなければno |
!empty? |
no |
empty? |
SELECT COUNT(*) FROM “users” |
loadedでなければno |
count(:all) > 0 |
no |
none? |
SELECT “users”.* FROM “users” |
yes (loadがコールされる) |
配列 |
no |
exists? |
SELECT 1 AS one FROM “users” LIMIT 1 |
no |
ActiveRecord::Calculations |
yes |
any?、empty?、none?はsizeの実装を思い出させます。もしレコードがloaded?なら配列上のシンプルなメソッドを呼び出してください。読み込まれていない場合は、常にクエリを実行します。exists? は他のActiveRecord::Calculationsのように、キャッシュや記憶する機能が組み込まれていません。exists?はこのような環境で使われがちなメソッドですが、いくつかのケースでは、実際present?よりはるかに悪いです!
これら6つの述語メソッドは全て、同じ質問を行う英語の同義語ですが、実装とパフォーマンスにまったく異なる影響を及ぼしますし、結果は使用している Rails のバージョンによって異なります。ですので、いくつかの具体的なアドバイスに上記をすべてを集約させてください:
■present?かblank?をコールした後、ActiveRecord::Relationが全く使われないのであれば、present?やblank?を使うべきではありません。例えば@my_relation.present?; @my_relation.first(3).each
■firstかlastを使用してActiveRecord::Relationのセクションを取り出すだけでなければ、any?、none?、empty?はおそらくpresent?に置き換るべきです。これらは、もしリレーション全体が存在し、それを使おうとしている時でも、追加でSQLによる存在チェックを行うでしょう。要約すると、@users.any?; @users.each...を @users.present?; @users.each...もしくは@users.load.any?; @users.each...に変更してください。しかし @users.any?; @users.first(3).eachは問題ありません。
■exists? は countに非常に似ています。記憶されず常にSQLクエリを実行します。実際には、おそらく大多数の人がこの動作を望まず、present?かblank?を使用したほうがよいでしょう。
まとめ
(字幕:ちがう、やりすぎだ。抑えろ)
アプリケーションの規模や複雑性が増すにつれ、不要なSQLはアプリケーションのパフォーマンス低下において重大な問題となってきます。各SQLクエリは、データ・ベースへのラウンド・トリップが含まれており、通常は少なくとも1ミリ秒かかります。複雑なWHERE句の場合は、さらに多くの時間がかかることがあります。exist?でのチェックが一つ増えるだけでは、大きな問題になりません。しかし突然、行毎や、コレクションの一かたまり毎に発生した場合、大きな問題になるでしょう!
ActiveRecordは強力な抽象化ですが、データ・ベースのアクセスは決して"無料"にはなりません。必要のない場合に、データ・ベースのアクセスが起こらないように、ActiveRecordがどのように動作するか知っておく必要があります。
アプリケーション・チェック・リスト
■おそらくActiveRecord::Relationsになると思いますが、オブジェクト上の、present?、none?、any?、blank?、empty?を探してください。もしリレーションがpresent?の時、後で配列全体を読み込こむつもりですか?もしそうならloadのコールを追加して下さい(例えば@my_relation.load.any?)。
■exists?の使用に注意してください。常にSQLクエリを実行します。適切なケースにだけ使用して下さい...それ以外の場合は、present?もしくは、 empty?を使用している他のメソッドを使用して下さい。
■ActiveRecordのインスタンス・メソッド内でのWhereの使用は十分に注意してください。事前ロードを壊してしまい、コレクションのレンダリング中に使用され、よくN+1を起こします。
■countは常にSQLクエリを実行します。コード・ベースでの使用をよく吟味して、sizeでのチェックがより適当かどうかを判断してください。
ウェブ・サイトを高速化したいですか?
私はネイト・ベルコペック (@nateberkopec)です。 フル・スタック・エンジニアの観点から、ウェブのパフォーマンス、主にフロント・エンドとRubyのバック・エンドについて、オンライン記事を書いています。 もし、この記事が気に入って、次の記事について知りたい場合は、連絡をください。 毎週1通程度、Eメールを私から直接送ります。スパムではありません。控えめです。
Railsパフォーマンス完全ガイド
私が書いた「Railsパフォーマンス完全ガイド」を見てください! Ruby on Railsアプリケーションをより速く、よりスケーラブルに、より簡単にメンテナンスするためのツールを提供する、フル・スタックコースです。 361ページのPDF、プライベートSlack、15時間以上のビデオ・コンテンツが含まれています。