0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

これまでにやったパフォーマンス改善

0
Posted at

はじめに

今まで色々と仕事のアプリのパフォーマンスを改善してきたので、それのメモ

基本

  • includesとかpreloadとかで関連をまとめて取得する
  • 大量レコードの可能性がある場合はfind_eachを使う

本題

パターン1

  • Active Recordのクエリメソッドを使うと、呼ぶたびにSQLがDBに発行される
    • スコープ
    • where
    • exists?
    • sum
  • すでにpreload/includesでメモリ上にロード済みなら、Rubyの配列メソッドを使うことで追加のSQLを発行せずに済む
    • select
    • reject
    • any?
sample.rb
# Before
User.where.not(id: book.user_id)

# After
User.all.reject { |u| u.id == book.user_id }

パターン2

特定の条件を満たす関連レコードについては、where でその都度取得するのではなく、
新しく関連付けを定義することで、includes などで事前にメモリ上に読み込めるようにする。

「絞り込み条件」を関連自体に埋め込む。

sample.rb
# Before
class Author < ApplicationRecord
  has_many :books
end

class Book < ApplicationRecord
  belongs_to :author
end

author.books.where(published: true)
sample.rb
# 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. 絞り込み条件(ステータス範囲など)だけで全体を1回のクエリで取得する
  2. order で目的の並び順(最新が先頭)にしておく
  3. Array#uniq { ブロック } でグループキーごとに最初の1件だけ残す

という形に書き換えることで、SQLの発行回数をグループ数に依存させず、常に一定回数(このケースでは2回)に抑えることができる。

sample.rb
# 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
sample.rb
# 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のまま維持できる。

sample.rb
# Before
parents = parents.select { |parent|
  parent.children.present? &&
    parent.children.max_by(&:created_at)&.value > THRESHOLD
}
sample.rb
# 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で一番最初の要素をキーとしたハッシュを生成している。

sample.rb
# 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
sample.rb
# 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: ...) に渡す
sample.rb
# Before
requests = parent.confirmations.where(flag: true).map(&:request)
requests.map(&:guarantees).flatten
sample.rb
# After
request_ids = parent.confirmations.where(flag: true).pluck(:request_id)
Guarantee.where(request_id: request_ids)
sample.rb
# After
parents = ParentModel.where(条件).available
children = ChildModel
  .where(parent: parents)
  .where(status: :replyed)

パターン6

メモ化。
同じオブジェクトのライフサイクルの中で複数のメソッドが同じデータを個別に取得しているところのパフォーマンス改善になる。

sample.rb
# Before
def client
  Client.find(id)
end
sample.rb
# After
def client
  @client ||= Client.find(id)
end

パターン7

オブジェクトの属性に追加し、コントローラーでの最初の呼び出し時にまとめて計算した値を各レコードの属性にセットすることでN+1の計算を防ぐ方法

sample.rb
class Exam < ApplicationRecord
  attr_accessor :calculated_amount

  # Before: 呼ばれるたびに個別にSUMクエリが発行される
  def amount
    guarantees.sum(:amount)
  end
end
sample.rb
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
controller.rb
# コントローラーでの最初の呼び出し時にまとめて計算してセットしておく
@exams = Exam.where(...)
Exam.assign_amount(@exams)

# 以降、ビューなどでamountを何度呼んでも追加SQLは発行されない

パターン8

sample.rb
books.each do |book|
  book.cur_amount
  book.previous_book.cur_amount
end
sample.rb
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のまま保持できる。

sample.rb
# Before
records = scope_a(params) + scope_b(params)
sample.rb
# After
records = scope_a(params).union(scope_b(params))

records.find_each do |record|
  # ...
end

設計面での見直し

  • 関連付けや、テーブルのカラムで判定できる条件を、あらためてSQLで取得をしようとしていないか
  • 母数が増大な場合、少しでも必要な情報で事前に絞り込むことができないか検討する

まとめ

各レコードごとに計算しているところなど、まとめて取得するようにする意識があるといいと思った。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?