【Ruby on Rails】N+1問題の基本と対策【中級編】
はじめに
Ruby on Rails における N+1問題と対応方法の基本を解説します。
初級編はこちらをご覧ください。
TL;DR
- 扱っているオブジェクトが何者なのかを意識しよう
- 特にループ処理や View 内の処理は気をつけよう
- ActiveRecord はロードとキャッシュを意識しよう
- ActiveRecord のソースコードを読もう
例示用のモデル
今回例示用に用いるデータモデルの関連性は下記の通り。
author.rb
class Author < ApplicationRecord
has_many :books
end
book.rb
class Book < ApplicationRecord
belongs_to :author
end
ActiveRecord とは
Ruby on Rails における O/Rマッパー。MVC でいうところの M担当。
DBMS に依存しない形で、DB関連の下記機能を提供してくれる。
- レコードの作成・更新・削除
- レコードの取得
- マイグレーション
- バリデーション
- アトリビュート管理
ActiveRecord::Relation とは
指定の条件に沿ってSQL文を作ってDBから読み取ってくる処理(レコードの取得)を担当するモジュール。
DB のレコードをオブジェクト指向的に扱えるものにしてくれる。
books = Book.all
=> Book Load (0.9ms) SELECT `books`.* FROM `books`
puts books.class
=> Book::ActiveRecord_Relation
puts books.class.superclass
=> ActiveRecord::Relation
様々なモジュールが組み込まれてる
ActiveRecord::FinderMethods
ActiveRecord::Calculations
ActiveRecord::SpawnMethods
ActiveRecord::QueryMethods
ActiveRecord::Batches
ActiveRecord::Explain
ActiveRecord::Delegation
とりあえず押さえておきたい ActiveRecord::Relation のアトリビュート
@loaded
SQLクエリを発行して オブジェクトを取得したことがあるかのフラグ
Book.all.loaded? => false
books = Book.all => Book Load (0.9ms) SELECT `books`.* FROM `books`
books.loaded? => true
@records
SQLクエリを発行して取得した条件に合うオブジェクトをメモ化した配列
Book.all.records => [#<Book:...>, #<Book:...>, ...]
Book.all.records.class => Array
キャッシュを使う関数
records に delegate されているメソッド
delegate :to_xml, :encode_with, :length, :each, :uniq, :to_ary, :join,
:[], :&, :|, :+, :-, :sample, :reverse, :compact, :in_groups, :in_groups_of,
:to_sentence, :to_formatted_s, :as_json,
:shuffle, :split, :index, to: :records
これらは records
に委譲されているので、 Array の関数として実行される。
Enumerable のメソッド
ActiveRecord::Relation は Enumerableをインクルードしているため、 #map
, #collect
などは、 Enumerable の関数として実行される。
つまり、全て each
を用いた実装の関数なので、 Array の振る舞い。
#pluck
指定したカラムのみ取得して配列を返す関数。 指定したカラムを含んだ records
があればそちらを使う。キャッシュがない場合、ロードによる更新をしない。
# キャッシュがない場合の例
Book.limit(10).pluck(:id)
=> (0.7ms) SELECT `books`.`id` FROM `books` LIMIT 10
# キャッシュがある場合の例
books = Book.limit(10)
=> Book Load (0.7ms) SELECT `books`.* FROM `books` LIMIT 10
books.pluck(:id)
=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
#size
# Returns size of the records.
def size
if loaded?
records.length
else
count(:all)
end
end
詳細は後述。
#first など
キャッシュがあればその配列に対してインデックスを指定して取得する。
キャッシュを使わない関数
#count
入力:
books = Book.limit(10)
5.times do
puts books.count
end
結果:
Book Load (0.8ms) SELECT `books`.* FROM `books` LIMIT 10
(0.5ms) SELECT COUNT(*) FROM (SELECT 1 AS one FROM `books` LIMIT 10) subquery_for_count
10
(0.3ms) SELECT COUNT(*) FROM (SELECT 1 AS one FROM `books` LIMIT 10) subquery_for_count
10
(0.2ms) SELECT COUNT(*) FROM (SELECT 1 AS one FROM `books` LIMIT 10) subquery_for_count
10
(0.3ms) SELECT COUNT(*) FROM (SELECT 1 AS one FROM `books` LIMIT 10) subquery_for_count
10
(0.3ms) SELECT COUNT(*) FROM (SELECT 1 AS one FROM `books` LIMIT 10) subquery_for_count
10
全ての count
処理でSQLクエリが叩かれている。
これは、 #count
関数が @records
キャッシュを使用しない 関数であるからである。
取得した @records
を利用する関数である、 #size
や #length
を使うことで、無駄なクエリを防ぐことができる。
入力:
books = Book.limit(10)
5.times do
puts books.count
end
結果:
Book Load (0.8ms) SELECT `books`.* FROM `books` LIMIT 10
10
10
10
10
10
#size
と #length
も仕様が異なるので注意する。
メソッド名 | 内容 | キャッシュ |
---|---|---|
count |
COUNT クエリで要素数を取得する |
使わない |
length |
キャッシュがなければ SELECT * で取得して Array#length で要素を数える |
使う |
size |
キャッシュがなければ COUNT クエリで要素数を取得する キャッシュがあれば Array#length で要素を数える |
使う |
計算関数
Active Record クエリインターフェイス - Railsガイド
クエリやロードするための関数
下記のようなSQLクエリを作る関数や、ロードするための関数は、キャッシュを更新するためのものなのでキャッシュを使わない。
where
, find
, find_by
, order
, …
load
, reload
, …
気をつけたいこと、意識したいこと
キャッシュを使うということは
情報が古くないか気を遣う必要あり
入力:
books = Book.all
books.each_with_index do |u,i|
puts "count: #{books.count}, length: #{books.length}, size: #{books.size}"
if i === 2
Book.create!(author_id: 1, name: 'hoge')
end
if i === 4
break
end
end
結果:
Book Load (1.1ms) SELECT `books`.* FROM `books`
(0.5ms) SELECT COUNT(*) FROM `books`
count: 60, length: 60, size: 60
(0.3ms) SELECT COUNT(*) FROM `books`
count: 60, length: 60, size: 60
(0.2ms) SELECT COUNT(*) FROM `books`
count: 60, length: 60, size: 60
TRANSACTION (0.2ms) BEGIN
Book Create (1.5ms) INSERT INTO `books` (`author_id`, `name`) VALUES (1, 'hoge')
TRANSACTION (1.3ms) COMMIT
(0.4ms) SELECT COUNT(*) FROM `books`
count: 61, length: 60, size: 60
(0.5ms) SELECT COUNT(*) FROM `books`
count: 61, length: 60, size: 60
ループ処理で ActiveRecord 関数を使う際は注意
キャッシュ取得した ActiveRecord::Relation をループで回すのは(ループ内でキャッシュを使用する関数を使う限り)問題ないが、キャッシュを使わない関数や、アソシエーションをロードする処理(基礎編参照)をすると N+1 が発生する。ループ処理内で ActiveRecord::Relation 関数を使う際は気をつけよう。
authors = Author.limit(10)
authors.each do |l|
puts authors.size # Author キャッシュされてるので問題なし
puts l.books.size # Book キャッシュされてないので毎回クエリ叩かれる
end
初級編でも扱ったように、ちゃんと preload しようねという話
authors = Author.limit(10).includes(:books)
authors.each do |l|
puts authors.size # Author キャッシュされてるので問題なし
puts l.books.size # Book キャッシュされてるので問題なし
end
ActiveRecord 関数と Array 関数の対応
レコードを更新する必要のない処理に関しては、 ActiveRecord の関数をつかわずに #records
や #to_a
で Array にした上で Array の関数で代替できないか考える。
今まで見てきたように ActiveRecord::Relation と Array の仕様は全く異なるので注意は必要だが、的確に使えれば N+1 を防げる。
ActiveRecord | Array (Enumerable) |
---|---|
where |
filter , select , reject
|
find , find_by
|
find , detect
|
first , second , last
|
first , second , last
|
count |
count , length , size
|
などなど…
select
や find
は ActiveRecord の関数としても定義されていて、引数にブロックを渡すかどうかで挙動が変わり、紛らわしいので注意。
まとめ
- 扱っているオブジェクトが何者なのかを意識しよう
- 特にループ処理や View 内の処理は気をつけよう
- ActiveRecord はロードとキャッシュを意識しよう
- ActiveRecord のソースコードを読もう
Discussion