パフォーマンス・チューニング
パフォーマンス改善!
N+1問題の対策
N+1問題とは、「一覧の取得に1回」「関連データの取得にN回」のデータ量(N)+1回分のSQLが実行されパフォーマンスが低下してしまうことです。
この対策として、JOIN句で結合するか、処理の前に Eager Loading などを行っておきます。
joins を利用
User.joins(:comments).where(comments: { id: 1 })
# SELECT `users`.* FROM `users` INNER JOIN `comments` ON `comments`.`user_id` = `users`.`id` WHERE `comments`.`id` = 1
INNER JOINを行います。left_joinsを使うと LEFT OUTER JOINを行います。
eager_load を利用
User.eager_load(:comments)
# SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`created_at` AS t0_r2, `users`.`updated_at` AS t0_r3, `comments`.`id` AS t1_r0, `comments`.`user_id` AS t1_r1, `comments`.`created_at` AS t1_r2, `comments`.`updated_at` AS t1_r3 FROM `users` LEFT OUTER JOIN `comments` ON `comments`.`user_id` = `users`.`id`
User.eager_load(:comments).where(comments: { id: 1 })
# SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`created_at` AS t0_r2, `users`.`updated_at` AS t0_r3, `comments`.`id` AS t1_r0, `comments`.`user_id` AS t1_r1, `comments`.`created_at` AS t1_r2, `comments`.`updated_at` AS t1_r3 FROM `users` LEFT OUTER JOIN `comments` ON `comments`.`user_id` = `users`.`id` WHERE `comments`.`id` = 1
指定したアソシエーションを LEFT OUTER JOIN で引いてキャッシュします。
preload を利用
User.preload(:comments)
# SELECT `users`.* FROM `users`
# SELECT `comments`.* FROM `comments` WHERE `comments`.`user_id` IN (1, 2, 3, ...)
User.preload(:comments).where(comments: { id: 1 })
# SELECT `users`.* FROM `users` WHERE `comments`.`id` = 1
# => Mysql2::Error: Unknown column 'comments.id' in 'where clause': SELECT `users`.* FROM `users` WHERE `comments`.`id` = 1
指定したアソシエーションを複数のクエリに分けて引いてキャッシュします。 複数のアソシエーションを Eager Loading するときや、あまりJOINしたくない大きなテーブルを扱うときは preload を利用すると良い。
includes を利用
User.includes(:comments)
# SELECT `users`.* FROM `users`
# SELECT `comments`.* FROM `comments` WHERE `comments`.`user_id` IN (1, 2, 3, ...)
User.includes(:comments).where(comments: { id: 1 })
# SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`created_at` AS t0_r2, `users`.`updated_at` AS t0_r3, `comments`.`id` AS t1_r0, `comments`.`user_id` AS t1_r1, `comments`.`created_at` AS t1_r2, `comments`.`updated_at` AS t1_r3 FROM `users` LEFT OUTER JOIN `comments` ON `comments`.`user_id` = `users`.`id` WHERE `comments`.`id` = 1
eager_load もしくは preload を使い分けます。
まとめ
メソッド | キャッシュ | クエリ発行数 |
---|---|---|
joins | しない | 単一 |
eager_load | する | 単一 |
preload | する | 複数 |
includes | する | 単一 または 複数 |
対象のテーブルとのJOINしたくない場合は preload を利用し、必ずJOINしたい場合は eager_load を利用、JOINの有無に問題がなく Eager Loading したい場合は includes を利用します。
ActiveRecord は count ではなく size を利用
countメソッドを利用するとSQLを発行してしまいます。countメソッドをループ内で使うために都度クエリが発行されてしまいます。そこで sizeメソッドを利用します。
all_users = User.all
all_users.count
# countメソッドを用いた SELECT文が発行される
(3.9ms) SELECT COUNT(*) FROM `users`
=> 118
all_users = User.all
all_users.size
# SQLクエリは発行されない
=> 118
allメソッドで取得した ActiveRecord を eachで回さない
メモリ消費を考慮する
User.all.each do |user|
# userオブジェクトを利用して処理
end
Userの全件をメモリに展開してから、ひとつひとつの処理を eachで行っていくためメモリ消費が激しくなります。
User.find_each do |user|
# userオブジェクトを利用して処理
end
find_eachの場合はレコードを1000件取得ずつ取得し、その取得したレコードを1件ずつ処理します。
ActiveRecordは exist? ではなく present? を利用
exist?メソッドは、ActiveRecordに使用すると、SQLを発行してしまいます。 存在するかの確認したい場合は present?メソッドで代用します。
active_users = User.where(active_flg: true)
active_users.exist?
# SELECT文が発行される
User Load (4.8ms) SELECT `users`.* FROM `users` WHERE `users`.`active_flg` = TRUE
=> []
active_users = User.where(active_flg: true)
active_users.present?
# SQLクエリは発行されない
=> []
不必要な ActiveRecordオブジェクトの生成
user_names = User.all.map(&:name)
この場合、必要のない ActiveRecordオブジェクトを生成してしまい、パフォーマンスがあまりよくありません。
user_names = User.pluck(:name)
pluckメソッドを利用して、必要なオブジェクトのみ生成し目的の処理を行うようにします。
モデルのバリデーションを無効にする
もしバリデーションエラーが発生しない前提のロジックなのであれば、意図的にバリデーションを無効にすることでパフォーマンスが向上します。
バリデーションを無効にすることで、予期せぬデータが混入するというリスクが伴うので、きちんと検討が必要です。
product.save(validate: false)
その他の注意や検討
- 基本的なデータ取得はループ処理前に済ませておき、ループ内で何度もコールしない
- 不要な関数コールや分岐を行わない
- 共通処理は分岐内に実装しない
- 数値計算は予め計算しておき、コメントで計算式を残す
- マスター系のデータ読などをキャッシュしてテーブルアクセスの頻度を下げる
- 重い処理を非同期にしてユーザービリティーを向上させる