新卒から始める パフォーマンスを意識したActiveRecordの使い方

こんにちは!SWEの高橋(@thehighhigh)です。 この記事は ANDPAD Advent Calendar 2023の 17日目の記事です。

今年新卒として入社し、研修を終えたのちに、現在は「ANDPAD図面」というプロダクトのサーバーサイドを主に担当しています。

配属から早くも半年が経過し、プロダクトと自身の成長を実感しながら、日々楽しく開発に取り組んでいます!

そんなANDPAD図面ですが、サーバーサイドは主にRuby on Railsで構築されています。

図面上に書き込みを入れることができたり、様々なオブジェクトを置くことができるというプロダクトの性質上、使われ方によっては非常に多くのデータを扱う可能性があり、また、ユーザーの業務に直結する機能を多く備えるプロダクトなので、可能な限りパフォーマンスを意識したコーディングが求められています。

Active Recordのパフォーマンスを意識する

Railsアプリケーションのパフォーマンスを意識する上で重要な要素として、Active Recordの使い方があります。 Active Recordは直感的に操作することができるORMフレームワークである反面、使い方を意識しないと不必要なSQLが発行されてしまったり、思いがけないスロークエリが発行されてしまうなどでパフォーマンスが低下してしまいます。

今回の記事では、自分がこの半年で学んだ、大量データを扱う場合のパフォーマンスを意識したActive Recordの使い方や、注意したいポイントを3つ紹介します。

※今回の動作環境は以下のとおりです

Rails: 7.0.8
MySQL: 8.0.28

1. SQLで書ける処理はなるべくSQLで済ませる

RailsアプリケーションではActive Recordで取得したオブジェクトをeach文などでループ処理をすることがよくあります。

たとえば電子書籍のアプリケーションがあったとして、以下のように、発行済であるbookのタイトル一覧を取得するようなケースです。 bookに対しては、発行済みか否かなど、本についてのメタ情報を持っているbook_meta_detailが1対1の関係で紐づいています。

def index
  @book_titles =
    User.find(params[:id]).books
      .preload(:book_meta_detail)
        .each_with_object([]) { |book, book_titles|
          book_titles << { title: book.title } if book.book_meta_detail.published?
        }
  # =>
  # [
  #   {title: 'hoge'},
  #   {title: 'foo'},
  #   {title: 'boo'}
  # ]
  ...
end

一見すると特に問題ないように思いますが、後々一人で数千冊の本を所持するようなユーザーも出てくる可能性があるなか、このループ処理を1つのbookごとに行うのはできれば避けたいところです。

そこで、このコードを以下のように変更します。

def index
  @book_titles =
    User.find(params[:id]).books.joins(:book_meta_detail)
      .where(book_meta_details: { status: :published }).select('books.title')
  # =>
  # [
  #  #<Book:0x0000ffff84f7f650 title: 'hoge'>,
  #  #<Book:0x0000ffff84f7f5b0 title: 'foo'>,
  #  #<Book:0x0000ffff84f7f510 title: 'boo'>
  # ]
  ...
end

このように、Active RecordによるSQLのみで先ほどのループ処理を実現できました。

SQLの集合ベースの処理は、Railsでのループ処理よりも高速なことが多いです。eachなどのループ処理を採用する前に、同じ処理をSQLで実現できないかを考えると習慣をつけたいところです。

2. サブクエリが発行されるwhereの代わりにmergeを使う

SQLで書ける処理はなるべくActive Recordで書くようにしようというのは先述した通りですが、同じ意味合いのコードだとしても、Active Recordでの記述の仕方によっては、発行されるSQLが大きく異なり、パフォーマンスが低下する可能性があるので注意が必要です。

たとえば、以下のようなmodelsがあるとします。

class User < ApplicationRecord
  has_many :books
  has_many :book_readable_users
  has_many :readable_books, through: :book_readable_users, source: :book
end


class Book < ApplicationRecord
  has_many :pages
  has_many :book_readable_users
  has_many :users, through: :book_readable_users
end


class Page < ApplicationRecord
  belongs_to :book
end


class BookReadableUser < ApplicationRecord
 belongs_to :user
 belongs_to :book
end

要するに、userbookを閲覧可能かという情報を持つ、book_readable_usersという中間テーブルが存在するようなケースです。 bookにはpageが1対Nの関係で紐づいています。

この時、 あるユーザーが閲覧することができるpageを一括で取得したいとして、以下のようなコードを実装したとします。

Page.joins(book: :book_readable_users)
  .where(books: { id: BookReadableUser.where(user_id: 1).select(:book_id) })

一見すると問題なさそうにも思えるコードですが、発行されるSQL、実行計画(一部抜粋)は以下のとおりです。

SELECT
    `pages`.*
FROM
    `pages`
    INNER JOIN `books` ON `books`.`id` = `pages`.`book_id`
    INNER JOIN `book_readable_users` ON `book_readable_users`.`book_id` = `books`.`id`
WHERE
    `books`.`id` IN(
        SELECT
            `book_readable_users`.`book_id` FROM `book_readable_users`
        WHERE
            `book_readable_users`.`user_id` = 1)
select_type table type key
SIMPLE subquery2 ALL
SIMPLE book_readable_users ref index_book_readable_users_on_category_id
SIMPLE books eq_ref PRIMARY
SIMPLE pages ref index_pages_on_book_id
MATERIALIZED book_readable_users ref index_book_readable_users_on_user_id

しかし今回のケースだと、 Active Record#mergeを以下のように使用することができます。

Page.joins(book: :book_readable_users).merge(BookReadableUser.where(user_id: 1))

このクエリによって発行されるSQL、実行計画(一部抜粋)は以下のとおりです。

SELECT
    `pages`.*
FROM
    `pages`
    INNER JOIN `books` ON `books`.`id` = `pages`.`book_id`
    INNER JOIN `book_readable_users` ON `book_readable_users`.`book_id` = `books`.`id`
WHERE
    `book_readable_users`.`user_id` = 1
select_type table type key
SIMPLE book_readable_users ref index_book_readable_users_on_user_id
SIMPLE books eq_ref PRIMARY
SIMPLE pages ref index_pages_on_book_id

発行されるSQLからわかる通り、mergeは引数内の絞り込みに対応した形でinner joinされ、結果として無駄なサブクエリの発行が省かれていることが実行計画からもわかります。

もちろん、inner join自体もコストがかかる処理なので場合によってはそこまでパフォーマンスに差が見られない場合もあると思います。しかし、サブクエリの発行を抑えられるので、積極的に使っていくべきだと思いました。

preloadは、Active Recordの関連付けを事前に読み込んでくれるメソッドで、RailsアプリケーションにおいてN+1問題を解決するために重要なメソッドです。 しかし、N+1問題が解消されるからといって、どんな時でもpreloadを使えば良いというわけではないので注意が必要です。

preloadによって発行されるSQL

先ほどの電子書籍アプリを例に、preloadによって発行されるSQLをrails console上で見てみましょう。

irb(main)> Book.preload(:pages).where(id: [1,2,3])
  # Book Load (4.3ms)  SELECT `books`.* FROM `books` WHERE `books`.`id` IN (1, 2, 3)
  # Page Load (3.1ms)  SELECT `pages`.* FROM `pages` WHERE `pages`.`book_id` IN (1, 2, 3)

本(book)に対して、ページ(page)が1対Nで紐づいているケースです。

発行されているSQLを見るとわかる通り、Book Loadで指定したidと同じidを、pagebook_idに対してIN句で指定しています。

IN句で大量の値を指定してしまう可能性がある

本(book)に対して、ページ(page)が1対Nの関係で存在していて、そのページ(page)に対してもコンテンツ(content)が1対Nの関係で存在している場合、以下のようにbookに対して、孫の関係にあるcontentまで読み込むと、以下のようなSQLが発行されます。

irb(main)> Book.preload(pages: :contents).where(id: [1,2,3])
  # Book Load (16.1ms)  SELECT `books`.* FROM `books` WHERE `books`.`id` IN (1, 2, 3)
  # Page Load (7.5ms)  SELECT `pages`.* FROM `pages` WHERE `pages`.`book_id` IN (1, 2, 3)
  # Content Load (2.4ms)  SELECT `contents`.* FROM `contents` WHERE `contents`.`page_id` IN (1, 2, 4, 5, 6, 3, 7, 8, 9, 10, ... )

Content Loadのタイミングで、Page Loadの際に取得できたpagesidがそのままIN句に入っていることがわかります。

つまり、bookに500個のpageが存在していて、そのbookを50個取得する場合、先ほどのクエリが発行するContent LoadのSQLのIN句には25000個のpage_idが入る計算になります。

単純にN+1問題を解消したかったのにも関わらず、そのために使ったpreloadがスロークエリになってしまう可能性があるということです。 また、MySQLではIN句に大量の値が入ると適切なインデックスが用意されていてもフルスキャンが発生する可能性もあります。※1

対処法1. しっかり絞り込んでからpreloadする

preloadする際に必要最低限まで絞り込むことで、この問題を回避することができます。

例えば以下のコードのように、

irb(main)> Book.preload(pages: :contents).where(id: [1,2,3], state: 1)
  # Book Load (16.1ms)  SELECT `books`.* FROM `books` WHERE `books`.`id` IN (1, 2, 3) AND  `books`.`state` = 1
  # Page Load (7.5ms)  SELECT `pages`.* FROM `pages` WHERE `pages`.`book_id` IN (1, 2)
  # Content Load (2.4ms)  SELECT `contents`.* FROM `contents` WHERE `contents`.`page_id` IN (1, 52, 53, 3, 12)

と絞り込んでしまえば、孫関係のpreloadのIN句の数がかなり絞れることがわかります。

対処法2. eager_loadしてしまうのも手

where句での絞り込みが存在しない場合はeager_loadを使わず、preloadを使用することが一般的ですが、大量のIN句と比較してleft joinの方がコストが低いと判断した場合、preloadの代わりにeagar_loadを使用することで、大量の値が入ったIN句のSQLの発行を避けることができます。

irb(main)> Book.eager_load(pages: :content).where(id: [1,2,3])
  # SQL (5.6ms)  SELECT `books`.`id` AS t0_r0, < 略 >
  # FROM `books` LEFT OUTER JOIN `pages` ON `pages`.`book_id` = `books`.`id` LEFT OUTER JOIN `contents` ON `contents`.`page_id` = `pages`.`id` WHERE `books`.`id` IN (1, 2, 3)

大量データに対してpreloadをする際には負荷検証をしてみる

基本的に、孫関係までの読み込みでなければpreloadを使って問題ないと思いますが、ケースによってはN+1を回避することはできてもその代わりにスロークエリが発行されてしまう可能性があることは意識すべきです。

preloadする際に、大量のデータ読み込みが予想される箇所では一度本番で想定されるデータ量で負荷検証を行ってみるべきでしょう。

まとめ

ここまで、Railsアプリケーションにおけるパフォーマンスを意識したActive Recordの使い方をいくつか紹介してきました。

私自身、このような大規模なデータを扱うプロダクトで開発をする経験はANDPAD図面が初めてで、まだまだコーディングする上で意識できること、学ぶべきことはたくさんあると思っています。 これからもより多くのユーザーに安定した動作で快適に使ってもらえるプロダクトを目指して日々開発を続けていきます。

最後に

アンドパッドでは、「幸せを築く人を、幸せに。」というミッションの実現のため、一緒に働く仲間を募集しています。

ご興味を持たれた方は、下記のサイトをぜひご覧ください。

engineer.andpad.co.jp

※1: https://developers.freee.co.jp/entry/large-in-clouse-length-cause-full-scan