Edited at

Railsでパフォーマンスを低下させないために気をつけること


概要

Railsを開発する上でパフォーマンスを低下させないためのTipsをメモ


Tips


実行時間の大きい処理はJobで非同期実行する

リクエスト内で複数な処理を実行してしまうと、レスポンスが遅くなりユーザビリティの低下につながります

リクエスト内で実行する必要がないものは、ActiveJob等で非同期に実効するか、バッチ処理で定期的に処理するように

することでユーザビリティの向上が図れます


条件式判定よりもSQLで絞る

取得してきたデータを1件ずつ条件反映して処理をするよりは、

取得するデータそのものをSQLで絞る方がはるかにパフォーマンスが良くなります

ループの中で毎回条件式で反映してしまうと、ループの中で毎回SQLが走ってしまうこともあるので、

パフォーマンスが要求される場合はSQLで絞るか、SQLで絞れる設計にすることをおすすめします


Array よりも ActiveRecord::Relation

Array オブジェクトはデータを全てメモリ上にもった上で処理を実効するのに対し、

ActiveRecord::Relation オブジェクトはSQLの構造だけをメモリ上に持ち、

処理する直前に遅延評価を実行し、値をメモリ上に展開します

ActiveRecord::Relation を使える場合は、そちらを使う方がパフォーマンスが良くなります


in_batches

ActiveRecord::Relation オブジェクトを分割して取得したい場合、 Rails5からは in_batches メソッドが使えます(※Rails5以前では find_in_batches が使えます)

in_batches はオプションで of を受けることができ、分割で取得するレコード数を指定できます(デフォルトは1000件です)

Array で渡してしまうとパフォーマンスが低下してしまう場合は in_batches を使って処理することをおすすめします

Log.in_batches do |logs|

# ActiveRecord:: Relationオブジェクトをblockで受け取れる
end

Source Code on Github


each よりも find_each

大量のデータを処理する際、DBから取得してきたデータを each のループで回してしまうと、

ループを回す直前にデータを全てメモリ上に展開してしまいます

取得するデータ量が多い場合は、 find_each を使ってDBからデータを分割して取得するようにし、

メモリ上に展開されるデータ数を絞る方がパフォーマンスが良い場合があります

ちなみに、 find_eachin_batches で取得したデータをeachで返しています

※ 追記

find_each では、取得結果がprimary_keyで一度whereもしくはpluckされるため、order等でソートしていた場合は

ソートの順序が無視されてしまうため注意が必要です

https://github.com/rails/rails/blob/master/activerecord/lib/active_record/relation/batches.rb#L223

× データ数が多いと一度に全てをメモリ上に展開してしまうためパフォーマンスが悪い

Log.all.each do |log|

# 処理を実行
end

◯ DBから分割して取得してくれるので、必要とするメモリの量を抑えながら実行できる

Log.find_each do |log|

# 処理を実行
end

Source Code on Github


CSVの取込は分割して取得する

大量のデータをCSVから一度に取得してしまうと、メモリに大量のデータが展開されてしまい、

パフォーマンスが低下してしまいます

この場合、 foreach メソッドを使って分割して取得することでパフォーマンスの低下を防ぐことができます

CSV.foreach(filename, headers: true, skip_blanks: true).with_index do |row, index|

# CSVを1行ずつ処理する
end

下記例では、each_slice と組み合わせて1000件ずつCSVデータを取得し処理を実行します

CSV.foreach(filename, headers: true, skip_blanks: true).each_slice(1000).with_index do |rows, index|

# CSVを1000件ずつまとめて処理する
end


バッチ処理では不要になった変数に明示的に nil を設定する

HTTPのリクエストで完結する処理に関しては、リクエスト終了後に展開された変数は全てメモリから開放されますが、

バッチ処理等で展開した変数を何度も使いまわした場合、変数がGCされる対象にならずメモリに残り続けてしまう場合があります

この場合、ループ処理の最後で不要になった変数に明示的に nil を設定することで

使用するメモリ量を抑えることができ、パフォーマンスの改善が図れる場合があります


大量にデータを挿入する場合はBULK INSERTをする

activerecord-importを使うことで、DBへのINSERTを1回のSQLで実行できます

大量のデータの取込処理がある場合は導入を検討してみることをおすすめします

下記例では、CSVから1000件ずつデータを取得し、取得した1000件のデータを1回のSQLでINSERTしています

fields = [:title, :body, :created_at, :updated_at]

CSV.foreach('blog.csv', headers: true, skip_blanks: true).each_slice(1000).with_index do |rows, index|
values = []
rows.each do |row|
values << [row['title'], row['body'], row['created_at'], row['updated_at']]
end
Blog.import fields, values, validate: false, timestamps: false, on_duplicate_key_update: fields
# @note 使用済みの変数に明示的にnilを設定してメモリから開放する
values = nil
rows = nil
end


destroy_all vs delete_all

destroy_all で削除を実行した場合、レコードを1件ずつ each でまわし、 1件ずつ destroy を実行するため、

レコードごとにバリデーションとコールバックが実行されます

レコード数が多い場合はDBが詰まってしまう可能性があるため、

バリデーションもコールバックも不要な場合は、 delete_all を使い、SQLから一括削除する方が良い場合があります

※ 外部参照制約に要注意

destroy_alldelete_all の違いについては下記リンクでもまとめたので、参照ください

destroy_allとdelete_all


Cardinalityが大きいカラムに対する絞込やソートにはINDEXを貼ろう

Railsアプリケーションのパフォーマンスが悪い場合の、よくある例がINDEXの貼り忘れです

SlowQueryを検出してくれるツールを導入し、該当するテーブルにINDEXを貼ることでパフォーマンスが劇的に改善します

ツールの例として、ヘルスチェックも合わせて実行してくれるNewRelicを紹介しておきます

また、複数のカラムを組み合わせて絞込やソートを実行している場合は複合INDEXを貼ることでパフォーマンスが変わることがあります

SQLで explain を実行し、SQLの実行計画を調査してみると良いです

ActiveRecord::Relation オブジェクトにも explain メソッドがあるので、 rails console から確認することも可能です

Source Code on Github


参考