はじめに
今まで色々と仕事のアプリのパフォーマンスを改善してきたので、それのメモ
基本
-
includesとかpreloadとかで関連をまとめて取得する - 大量レコードの可能性がある場合は
find_eachを使う
本題
パターン1
- Active Recordのクエリメソッドを使うと、呼ぶたびにSQLがDBに発行される
- スコープ
whereexists?sum
- すでにpreload/includesでメモリ上にロード済みなら、Rubyの配列メソッドを使うことで追加のSQLを発行せずに済む
selectrejectany?
# Before
User.where.not(id: book.user_id)
# After
User.all.reject { |u| u.id == book.user_id }
パターン2
特定の条件を満たす関連レコードについては、where でその都度取得するのではなく、
新しく関連付けを定義することで、includes などで事前にメモリ上に読み込めるようにする。
「絞り込み条件」を関連自体に埋め込む。
# Before
class Author < ApplicationRecord
has_many :books
end
class Book < ApplicationRecord
belongs_to :author
end
author.books.where(published: true)
# After
class Author < ApplicationRecord
has_many :books
has_many :published_books, -> { where(published: true) }, class_name: "Book"
end
class Book < ApplicationRecord
belongs_to :author
end
authors = Author.includes(:published_books).where(id: target_ids)
パターン3
「グループ(組み合わせ)ごとに1件(例えば最新)を取る」ような処理の時に
- 先に組み合わせをのパターンを配列で取得
- 上記の配列のループの中で、組み合わせごとに
where(...).order(...).limit(1)として最新1件を取得するように書くと、グループの数だけSQLが発行されN+1になる
この場合
- 絞り込み条件(ステータス範囲など)だけで全体を1回のクエリで取得する
-
orderで目的の並び順(最新が先頭)にしておく -
Array#uniq { ブロック }でグループキーごとに最初の1件だけ残す
という形に書き換えることで、SQLの発行回数をグループ数に依存させず、常に一定回数(このケースでは2回)に抑えることができる。
# Before: ユーザーごとに「最新の注文」をループで取得(N+1)
user_ids = orders.map(&:user_id).uniq
latest_orders = []
user_ids.each do |user_id|
latest_orders << orders.where(user_id: user_id).order(created_at: :desc).limit(1).first
end
# After
latest_orders = orders
.order(created_at: :desc)
.uniq { |order| order.user_id }
発展形: Window関数を使い、判定自体をSQLだけで完結させる
上記の Array#uniq を使う方法は、N+1は解消できるものの、一度Ruby側に全件ロードしてから最新1件を選別するため、結果が配列(Array)になってしまう。
そのため後続で find_each のようなActiveRecordのRelationが前提のバッチ処理メソッドが使えなくなる、というデメリットがある。
「グループごとの最新1件かどうか」という判定自体もSQLのWindow関数に移すことで、
Ruby側での選別処理を一切なくし、結果をActiveRecordのRelationのまま維持できる。
# Before
parents = parents.select { |parent|
parent.children.present? &&
parent.children.max_by(&:created_at)&.value > THRESHOLD
}
# After
latests = ChildModel.select(
"child_models.*", "row_number() over(partition by parent_id order by created_at desc) rn"
)
latest_ids = ChildModel.from(latests, :child_models)
.where("rn = 1").where.not(parent_id: nil).select(:id)
parents = parents.joins(:children)
.where(children: { id: latest_ids })
.where("children.value > ?", THRESHOLD)
parents.find_each do |parent|
# ...
end
パターン4
ループの中で複数回クエリを投げていた処理を、ループの前に1回のクエリでまとめて取得し、自前のハッシュを作っておく。
一部のカラムしか必要ない場合に有効。
下記のケースでは、
pluckで必要なカラムだけ抽出した後、group_byで一番最初の要素をキーとしたハッシュを生成している。
# Before
parent_records.find_each do |parent|
owner = parent.owner
# ①関連レコードの取得(ループのたびにSQLが飛ぶ)
children_scope = ChildTable.where(owner: owner).available
# ②孫レコードの取得(ループのたびにSQLが飛ぶ)
grandchildren = GrandchildTable
.where(child: children_scope)
.where(status: :active)
.eager_load(:something)
results = grandchildren.map { |g| build_result(g) }
end
# After
# ループの前に、対象全件分をまとめて1回のクエリで取得し、owner_idごとのハッシュにしておく
# 下記のようなハッシュを作成する
# {
# owner_id => [
# [owner_id, grandchild_id, some_id],
# [owner_id, grandchild_id, some_id],
# ],
# owner_id2 => [
# [owner_id2, grandchild_id, some_id],
# [owner_id2, grandchild_id, some_id],
# ],
# }
owner_grandchildren = GrandchildTable
.where(status: :active)
.where(child: { owner: parent_records.map(&:owner), available: true })
.joins(child: [:owner])
.pluck("child.owner_id, grandchild_table.id, child.some_id")
.group_by(&:first)
parent_records.find_each do |parent|
owner = parent.owner
# ハッシュから引くだけ。SQLは飛ばない
grandchildren = owner_grandchildren[owner.id] || []
results = grandchildren.map do |owner_id, grandchild_id, some_id|
build_result(grandchild_id:, some_id:, owner_id:)
end
end
パターン5
中間モデル・親モデルをRubyのループで経由して関連先を取得するのではなく、
子モデル(取得したい対象のモデル)に対して直接 where で条件を渡し、1回のSQLでまとめて取得する。
IDの渡し方は以下の2通りがあり、どちらでも同様の効果が得られる。
-
pluckで外部キーだけ取り出してからwhere(id: ...)で渡す - 関連先の集合(Relationや配列)をそのまま
where(assoc: ...)に渡す
# Before
requests = parent.confirmations.where(flag: true).map(&:request)
requests.map(&:guarantees).flatten
# After
request_ids = parent.confirmations.where(flag: true).pluck(:request_id)
Guarantee.where(request_id: request_ids)
# After
parents = ParentModel.where(条件).available
children = ChildModel
.where(parent: parents)
.where(status: :replyed)
パターン6
メモ化。
同じオブジェクトのライフサイクルの中で複数のメソッドが同じデータを個別に取得しているところのパフォーマンス改善になる。
# Before
def client
Client.find(id)
end
# After
def client
@client ||= Client.find(id)
end
パターン7
オブジェクトの属性に追加し、コントローラーでの最初の呼び出し時にまとめて計算した値を各レコードの属性にセットすることでN+1の計算を防ぐ方法
class Exam < ApplicationRecord
attr_accessor :calculated_amount
# Before: 呼ばれるたびに個別にSUMクエリが発行される
def amount
guarantees.sum(:amount)
end
end
class Exam < ApplicationRecord
attr_accessor :calculated_amount
# After: すでに計算結果がセットされていればそれを返す
def amount
calculated_amount || guarantees.sum(:amount)
end
# 対象レコード全件分をまとめて1回のクエリで計算し、各レコードにセットする
def self.assign_amount(exams)
map = Guarantee.group(:exam_id).sum(:amount)
exams.each { |exam| exam.calculated_amount = map.fetch(exam.id, 0) }
end
end
# コントローラーでの最初の呼び出し時にまとめて計算してセットしておく
@exams = Exam.where(...)
Exam.assign_amount(@exams)
# 以降、ビューなどでamountを何度呼んでも追加SQLは発行されない
パターン8
books.each do |book|
book.cur_amount
book.previous_book.cur_amount
end
def self.assign_cur_amount(books)
current_ids = books.map(&:id)
prev_ids = books.map(&:previous_book_id)
# 自分自身の合計金額を id ごとにまとめて取得(SQL1回)
current = Loan.where(book_id: current_ids)
.group(:book_id)
.sum(:amount)
# previous_book の合計金額を next_book_id ごとにまとめて取得(SQL1回)
prev = Loan.joins(:book)
.where(book_id: prev_ids)
.group(:next_book_id)
.sum(:amount)
# 2つのHashを「同じキーなら加算」でマージ
map = current.merge(prev) { |_key, old_val, new_val| old_val + new_val }
# ループ内ではDBに問い合わせず、Hashから引くだけ
books.each do |book|
book.calculated_cur_amount = map.fetch(book.id, 0)
end
end
- Hash(book_id => 合計)を 事前に1回のSQL で作る
- Hash(previous_book_id => 合計)を 事前に1回のSQL で作る
- ループの中では map.fetch(book.id, 0) のようにHashのキーを見るだけにする
パターン9
複数の条件で絞り込んだ結果を組み合わせたい時、+(配列結合)を使うと、
ActiveRecordのRelationではなくRubyの配列(Array)になってしまう。
後続で find_each のようなActiveRecordのRelationが前提のバッチ処理メソッドが使えなくなり、全件を一度にメモリへロードせざるを得なくなる。
+ の代わりに union(SQLの UNION)を使うことで、2つの条件をSQLレベルで合成でき、結果もActiveRecordのRelationのまま保持できる。
# Before
records = scope_a(params) + scope_b(params)
# After
records = scope_a(params).union(scope_b(params))
records.find_each do |record|
# ...
end
設計面での見直し
- 関連付けや、テーブルのカラムで判定できる条件を、あらためてSQLで取得をしようとしていないか
- 母数が増大な場合、少しでも必要な情報で事前に絞り込むことができないか検討する
まとめ
各レコードごとに計算しているところなど、まとめて取得するようにする意識があるといいと思った。