Zenn
📝

Railsパフォーマンス改善のメモ

2025/04/13に公開

自分用に改めて整理してみます。

キャッシュの活用

頻繁に参照するデータや計算コストの高い処理を毎回DBから呼び出すようにしていると、ページへのアクセスが急増した場合に、サーバー負荷が膨大になるのでデータを再利用する仕組みが有効です。

RedisやMemcachedのキャッシュを使う

Railsの構成に、RedisやMemcachedが組み込まれている場合、Rails.cache.fetchにブロックを渡すことでキャッシュを行うことができます。

Rails.cache.fetch("items_all", expires_in: 5.minutes) do
  Item.all.to_a
end

もし、「items_all」というデータがキャッシュとして「5.minutes」に作成されていれば、それを利用し、そうでなければブロック内の処理を実行します。

ActiveRecord::Relationをキャッシュする意味

先の例で使ったItem.allはActiveRecord::Relationでそれそのものはデータを取得していません(遅延評価)。実際にデータ取得が行われるのはこのオブジェクトに対してアクセスしたときになります。
なのでItem.allをキャッシュしても、データ取得の結果をキャッシュしているわけではありません。
先の例では、to_aをデータを取得し配列に詰め直しており、その配列に対してキャッシュを持っています。

Rails.cache.fetch("items_all", expires_in: 5.minutes) do
  Item.all.to_a
end

クエリパラメーターに応じてキャッシュ名を変える

データ取得したい内容が、検索ワードなど状況によって変わる場合、items_allといった名前でキャッシュを管理することはできません。
そういう場合は、"items_with_keyword=#{aaa}"といったふうに動的にキャッシュの名前を付けるのが有効です。

Rails.cache.fetch("items_with_keyword=#{aaa}", expires_in: 5.minutes) do
  Item.all.where(keyword: 'aaa').to_a
end

DB取得

Railsではデータベースからの情報取得をしょっちゅう行いますが、「N+1問題」と呼ばれる情報の取得方法を行っていると、パフォーマンスが低下する要因になります。

N+1問題

基本的な話ですが、言語化の練習も兼ねて。

たとえば、あるブログのアプリケーションで「記事(Post)」が複数の「コメント」(Comments)を持っているとします。

posts = Post.all  # ここで全記事を一度に取得(1回のクエリ)
posts.each do |post|
  puts post.comments.count  # 記事ごとにコメントを取得(N記事ごとにN回のクエリ)
end

最初の Post.all で全記事を取得するのに 1回のクエリが発行されます。
その後、各記事について post.comments にアクセスするたびに、その記事に関連するコメントを取得するため、記事の数が N 件なら N回のクエリが発行されます。

設計の都合上、都度処理や判定をさせたいという場合もあるので、一概に悪とはいいませんが、パフォーマンスの観点ではかなり気を付けるべき問題です。

この問題への対応として、有名なのが、preload, eager_load, includesです。

preload

「preload」を使用すると、まずメインのレコードを取得し、その後、関連するテーブルに対して別のSQLクエリを実行して関連オブジェクトを一括読み込みます。

posts = Post.preload(:comments) # ここで全記事と付属するコメントを一度に取得
posts.each do |post|
  puts post.comments.count  # ここで追加のクエリは発行されず、既にロード済みのコメントを参照する
end

具体的なSQL

Post.preload(:comments)
-> SELECT posts.* FROM posts
   SELECT comments.* FROM comments WHERE comments.post_id IN [xxx, yyy, zzz]

eager_load

「eager_load」を使用すると、関連するテーブルをLEFT OUTER JOINで結合し、1つのSQLクエリで全てのデータを取得します。preloadは関連するテーブルについて別々のSQLを発行してデータを取得していましたが、eager_loadは一つのSQLです。

posts = Post.eager_load(:comments) # ここで全記事と付属するコメントを一度に取得
posts.each do |post|
  puts post.comments.count  # ここで追加のクエリは発行されず、既にロード済みのコメントを参照する
end

具体的なSQL

Post.eager_load(:comments)
-> SELECT posts.* FROM posts LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id"

このくらいのデータ取得であればpreloadの方がいいでしょう。
ですが、たとえば条件などが入り、個別にSQLを発行してもダメな場合、一度のSQLでデータを取得できるearger_loadを使うことになります。

posts = Post.eager_load(:comments).where(comments: {id: [1,2,3]}) # ここで全記事と付属するコメントを一度に取得
posts.each do |post|
  puts post.comments.count  # ここで追加のクエリは発行されず、既にロード済みのコメントを参照する
end

具体的なSQL

Post.eager_load(:comments).where(comments: {id: [1,2,3]})
-> SELECT posts.* FROM posts LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE comments.id IN [1,2,3]

includes

これは状況に応じて、自動でpreloadとeager_loadを分けてくれます。

Post.includes(:comments)
-> SELECT posts.* FROM posts
   SELECT comments.* FROM comments WHERE comments.post_id IN [xxx, yyy, zzz]
Post.includes(:comments).where(comments: {id: [1,2,3]})
-> SELECT posts.* FROM posts LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE comments.id IN [1,2,3]

includes は、取得したい関連データのカラムをクエリ内で参照しなければ自動的に preload として動作し、関連テーブルの条件(例:where 句、order 句など)が含まれていれば eager_load として LEFT OUTER JOIN を実行します。

じゃあincludes一択じゃないの?

includesがよろしくやってくれるならそれだけでいいんじゃないか?と思うのは誰もが通る道ですが、ある程度のデータ規模になってくると、予期せぬパフォーマンス低下の原因になるため、なるべくincludesを使わないほうがいいです。

予期せぬパフォーマンス低下

includesは予期せぬパフォーマンス低下を引き起こす可能性があります。
理由は、「JOIN」(eager_load)が常に「個別のクエリを複数実行」(preload)より軽いわけではないためです。
eager_loadは常にLEFT OUTER JOINで結合処理を行うため、取得するデータが大きいと負荷が大きくなる場合があります。
その場合は、preloadで個別のクエリを複数実行する方が負荷が小さいです。

joinsってあるけど?

eager_loadはSQLとしてはJOINをしているのですが、Railsにはjoinsというメソッドもあります。
joinsメソッドはSQL的にはINNER JOINを行っており、関連モデルはWHERE句やORDER句に使われるのみで結果に含まれません。
なので、N+1問題は解決しません。

Discussion

ログインするとコメントできます