🔖

【Ruby on Rails】N+1問題の基本と対策【中級編】

2024/02/24に公開

はじめに

Ruby on Rails における N+1問題と対応方法の基本を解説します。
初級編はこちらをご覧ください。
https://zenn.dev/tyamap/articles/n-plus-one-beginner-level

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 されているメソッド

delegation.rb

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 のメソッド

batch_enumerator.rb

ActiveRecord::Relation は Enumerableをインクルードしているため、 #map, #collect などは、 Enumerable の関数として実行される。
つまり、全て each を用いた実装の関数なので、 Array の振る舞い。

#pluck

calculations.rb

指定したカラムのみ取得して配列を返す関数。 指定したカラムを含んだ 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

relation.rb

# Returns size of the records.
def size
  if loaded?
    records.length
  else
    count(:all)
  end
end

詳細は後述。

#first など

finder_methods.rb

キャッシュがあればその配列に対してインデックスを指定して取得する。

キャッシュを使わない関数

#count

calculations.rb

入力:

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

などなど…

selectfind は ActiveRecord の関数としても定義されていて、引数にブロックを渡すかどうかで挙動が変わり、紛らわしいので注意。

まとめ

  • 扱っているオブジェクトが何者なのかを意識しよう
  • 特にループ処理や View 内の処理は気をつけよう
  • ActiveRecord はロードとキャッシュを意識しよう
  • ActiveRecord のソースコードを読もう

参考

GitHubで編集を提案
株式会社Inner Resource

Discussion