こんにちは!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
要するに、user
がbook
を閲覧可能かという情報を持つ、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
を、page
のbook_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
の際に取得できたpages
のid
がそのまま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図面が初めてで、まだまだコーディングする上で意識できること、学ぶべきことはたくさんあると思っています。 これからもより多くのユーザーに安定した動作で快適に使ってもらえるプロダクトを目指して日々開発を続けていきます。
最後に
アンドパッドでは、「幸せを築く人を、幸せに。」というミッションの実現のため、一緒に働く仲間を募集しています。
ご興味を持たれた方は、下記のサイトをぜひご覧ください。
※1: https://developers.freee.co.jp/entry/large-in-clouse-length-cause-full-scan