DBパフォーマンスを意識したRailsの書き方まとめ
Railsではデータセットが巨大化したときのデフォルトのパフォーマンスがかなり低下するケースがあります。
そんなときに気をつけた方が良いポイントを記載します。
1. N+1
N+1は簡単に言うと必要以上に SQL が走るせいでパフォーマンスが低下する問題です。
悪い例
class UsersController < ApplicationController
def index
@tweets = Tweets.all
end
end
<% @tweets.each do |tweet| %>
<%= tweet.user.name %> # ここでN+1が起きている
<% end %>
この書き方だと、tweetsの数だけSQLが発行されるのでパフォーマンスが落ちます。
良い例
class TweetsController < ApplicationController
def index
@tweets = Tweets.includes(:user)
end
end
includes
を利用して事前にクエリキャッシュをすることで、N+1解決できます。
参考:
https://qiita.com/hirotakasasaki/items/e0be0b3fd7b0eb350327
https://qiita.com/massaaaaan/items/4eb770f20e636f7a1361
2. countではなくてsizeを使用する
Active Recordのメソッドで、データの件数を取得するためにはcount
,length
, size
の3つのメソッドがあります。
この挙動の違いを理解せずに利用するとパフォーマンスに影響が生まれます。
悪い例
以下のコードはRailsビューで非常によく見かけます。
irb(main):001:0> users = User.all #usersテーブルのレコードを全件取得
irb(main):002:0> users.count #取得したレコードの件数を取得
(0.6ms) SELECT COUNT(*) FROM `users`
=> 3
irb(main):003:0> users.count #取得したレコードの件数を再度取得
(1.9ms) SELECT COUNT(*) FROM `users`
=> 3
count
を実行すると毎回データベースに対してクエリを投げていられます。
テーブルの全行数が多ければ多いほど、パフォーマンス影響が大きくなります。
良い例
irb(main):001:0> users = User.all
irb(main):002:0> users.length #取得したレコードの件数を取得
User Load (0.3ms) SELECT `users`.* FROM `users`
=> 3
irb(main):003:0> users.length #取得したレコードの件数を再度取得
=> 3
length
を利用すると、初回のlength実行時にデータをメモリに保存しているので、2回目からクエリが発行されません・
参考:https://himakuro.com/rails-count-length-size-guide
3.安易にallで取得した結果をループ処理しない
all
では取得全件をメモリに展開するという特徴があります。
取得件数が少なければそこまで問題になりませんが、取得件数が大きい場合はメモリ圧迫されてしまいメモリエラーになるケースがあるので注意が必要です。
悪い例
User.all.each do |user|
# 何かuserのオブジェクトを使用して処理をするコード
end
仮にUserデータが10,000件取得された場合、10,000件が全てメモリ解放されてしまいメモリ消費が激しくなります。
良い例
User.find_in_batches(batch_size: 1000) do |users|
# 何かuserのオブジェクトを使用して処理をするコード
end
もし仮にUserデータが10,000件取得されたとしても、find_in_batchesを利用すれば1000ずつ処理されます。
つまり、10,000 / 1000 = 10回の処理に分けるイメージです。
ちなみに、大量にデータを挿入する場合はBULK INSERTをすると
参考:https://qiita.com/Marusoccer/items/af75b387cdb17eef91c6
4.不必要なActive Recordオブジェクトの生成
Active Recordオブジェクトは膨大な数のモジュールやメソッドをラップしているので、無駄な作成は避けるべきです。
悪い例
user_names = User.all.map(&:name)
Userのオブジェクトを生成して、そこからActiveRecord::AttributeMethods
関連のメソッドを呼び出しています。
良い例
user_names = User.pluck(:name)
pluckメソッド
を使うとオブジェクトの生成を行わずにテーブルの値を取得することができます。
pluckメソッド
はActiveRecordインスタンスの配列ではなく指定されたカラムの取得値配列を返すメソッドです。
5. メモ化する
メモ化とはメソッドが最初に呼び出されたときに戻り値がキャッシュされ、それ以降、同じスコープ内でメソッドが呼び出されるたびにキャッシュされた値が返されることを意味します。
悪い例
def user
@user = User.find(params[:id])
end
毎度、UserテーブルにアクセスしているのでDB負荷が高い
良い例
def user
@user ||= User.find(params[:id])
end
@user
がnillの場合 User.find(params[:id])が@user
に代入されます。
しかし、二回目同じメソッドを呼び出した時にUser.find(params[:id])この部分の処理が走らないので余計なDBアクセスを省略することができます。(一回目処理が走った時に、@user
にキャッシュされているので)
6. 一括更新するときは一括更新にすべき
バルクインサートでは1回のトランザクションでインサートすることができるため、トランザクションを発行して何回もインサート処理をループさせるよりもコンテキストスイッチの回数が減るため実行速度が速いです。
一括更新するときはできる限りバルクインサート処理に寄せる形で実装する方がベターです。
悪い例
class Entry < ApplicationRecord
end
now = Time.current
# この書き方だと毎回insert処理クエリが走るのでパフォーマンスがあまり良くない。
Entry.create(title: 'foo1', body: 'bar1', created_at: now, updated_at: now)
Entry.create(title: 'foo2', body: 'bar3', created_at: now, updated_at: now)
良い例
DBアクセスが少なく済むようにこのように一つのINSERT文にまとめらる方がベターです。
class Entry < ApplicationRecord
end
now = Time.current
Entry.insert_all([
{ title: 'foo1', body: 'bar1', created_at: now, updated_at: now },
{ title: 'foo2', body: 'bar3', created_at: now, updated_at: now },
])
# 内部的にはBulkInsert処理になる
#=> Entry Bulk Insert (7.3ms) INSERT INTO "entries"("title","body","created_at","updated_at") VALUES ('foo1','bar1','2020-01-25 17:22:50.533182', '2020-01-25 17:22:50.533182'),('foo2','bar2','2020-01-25 17:22:50.533182', '2020-01-25 17:22:50.533182')
7. 重い処理は非同期にする
コードの書き方ではないですが、重い処理は同期処理ではなく非同期処理にすることを検討しましょう。
高負荷処理がかかる実装はバックグランド処理に移動させることで、リクエストタイムアウトエラーやメモリエラーを事前に防ぐことができます。
8. 大量レコードを扱う時はメモリ圧迫とオブジェクト作成を抑えるよう意識する
大量レコードをメモリに乗せすぎない、オブジェクト作成ではなくHashをできる限り利用するように意識するの大事。
9. 大きいテーブルをJoinしない
レコード数が多いテーブルをJoinは処理が重くなるのでできる限り避けましょう。
10.大量データのCSVダウンロード処理はタイムアウトなども考慮する
大量データのCSVダウンロード処理はタイムアウトも考慮しましょう。
考慮しないとタイムアウトエラーになるケースがあります。
パフォーマンスが悪くなっている場所を見つけよう
パフォーマンスを意識して書いたとしても、人間なので抜け漏れはあります。
ですので、パフォーマンス検知ライブラリを導入して、パフォーマンスが落ちている場所に気づけるような仕組みを作っておきましょう。
rack-mini-profilerを導入
rack-mini-profilerは手軽にRuby on Railsのパフォーマンス計測ができるgemです。
HTMLレンダリングした時のパフォーマンスを確認したいときに利用するのがオススメ
bulletを導入
bulletはN+1問題をアラート表示してくれるgemです。N+1は気をつけていても発生してしまうケースがあるので事前に導入しておくことをオススメします。
New Relicを導入
アクション実行時にどの処理にどれだけ時間が掛かったかをメトリクス収集してくれる
https://newrelic.com/jp/ruby/rails
Benchmarkモジュールを利用したベンチマークの実行
Ruby標準のBenchmarkモジュールを利用したベンチマークの実行をしてみてパフォーマンスチェックするのも良い方法です。
実際の利用方法はこちらが参考になります。
まとめ
他にも気をつけた方が良いポイントなどがあれば、ご指摘いただければ嬉しいです!