Ruby on Rails アプリのパフォーマンス最適化10選

こんにちは!Webアプリケーションエンジニアのレミーです!

この記事はEnigmo Advent Calendar 2025の24日目の記事です。

Ruby on Rails アプリが遅いと感じるのは、ほぼ次の3の原因になります。

  1. DBクエリが多すぎる(特に N+1、COUNT/EXISTS の使い分けミス、インデックス不足)
  2. 不要なデータを読み込みすぎる(テーブル全て/重いカラム全て取得、あるいは全部を RAM に書き込む)
  3. ビューのレンダリング/コールバックが働きすぎる(partial の多用、重いフォーマット処理、不要なコールバック/バリデーションの実行)

この記事では、効果が見えやすいものに絞って、自分が特によく使う最適化10個をまとめます。

1. includes で N+1 クエリを防ぐ

問題: posts の一覧を取得して、view 側で post.user.namepost.comments.size のように関連を参照すると、20件なら20回(あるいはそれ以上)追加クエリが飛ぶ可能性があります。

解決: includes で関連を事前ロードします。

改善前: N+1 が発生

@posts = Post.order(created_at: :desc).limit(20)

@posts.each do |post|
  post.user.name # 毎回 SELECT users... WHERE id = ? が走る可能性
end

改善後: includes を使用

@posts = Post.includes(:user).order(created_at: :desc).limit(20)

Railsposts に紐づく users を1回のクエリでまとめてロードし、ループ内での追加クエリを防ぎます。

一覧をレンダリングして、ループ内で association を参照する(post.commentspost.userorder.items など)場合ではよく使われます。

重要ポイント: includes には 3 パターンがあり、Rails が状況に応じて選びます。

  • preload: 常に 2 クエリ(postsとusers)
  • eager_load: 常に LEFT OUTER JOIN(大きい 1 クエリ)
  • includes: Rails が自動判断(preload になる場合も join になる場合もある)

2. 必要なカラムだけ取る: select / pluck

問題: User.allUser.where(...).to_a全カラムを引いてきます。bio (text)settings (jsonb)avatar_data のような重いカラムも含まれがちです。実際には idname だけで十分なケースも多いはずです。

解決:

ActiveRecord オブジェクトは欲しい(でも最小限にしたい)

select を使います。

users = User.where(active: true).select(:id, :name)
users.first.name # OK

値の配列だけで十分(高速 + allocations 少なめ)

pluck を使います。

ids   = User.where(active: true).pluck(:id)
pairs = User.where(active: true).pluck(:id, :name) # [[1, "A"], [2, "B"]]

DB処理時間の短縮、返ってくるデータ量の削減、Ruby 側の allocations 削減。

dropdown/select box で idname だけが必要な時とか、バックグラウンドジョブで処理対象 id だけが必要な時などが使われます。

3. 存在するかどうかの確認なら exists? を使う

問題: relation に対して any? / present? で存在チェックをすると、不要にレコードを読み込んだり、最適でないクエリになったりすることがあります。

解決: exists? は EXISTS を使うため、目的に対して効率的になりやすいです。

改善前: 不要なロードが起こり得る

User.where(email: email).any?

改善後: EXISTS を使う

User.exists?(email: email)

メール重複チェック、ユーザーが注文を持っているか、対象レコードが既にあるか、などの場合に使われます。

4. count / size / length を正しく使い分ける

メソッド DB クエリは走る? どんなクエリ レコードをロードする? 使いどころ
count あり(常に) SELECT COUNT(*) なし DB から正確な件数が必要なとき
length 未ロードなら走る SELECT * あり(全件ロード) すでに records がロード済みだと確実できる時だけ
size 状況による COUNT(*) または なし 自動 ActiveRecord / association では基本これが安全

association がロード済みか不明なときは、次のように size が安全です。

comments_count = post.comments.size

view で association の件数を表示するなら、まずは size(または counter cache)を優先。

5. よく絞り込み/ソートするカラムに Index を貼る

問題: WHERE user_id = ... などがインデックスがないと、DB がテーブル全体をスキャンして重くなりがちです。

解決: WHERE / JOIN / ORDER BY によく出てくるカラムに index を追加します。

例:

add_index :orders, :user_id
add_index :users, :email, unique: true
add_index :orders, [:user_id, :created_at]

インデックス選びの目安:

  • WHERE / JOIN によく出るカラム: index を追加
  • ORDER BY と filter がセットでよく出る:複合インデックス(例 [:user_id, :created_at])を追加
  • email/username のようなユニーク値: unique: true

確認方法: ログで遅いクエリを見つける、DB で EXPLAIN を実行して index が使われているか確認。

注意: index は読み込みを速くしますが、書き込みは少し遅くなる傾向があります。

6. 大量データは batch で処理する: find_each / in_batches

問題: User.where(...).each は全件を RAM に書き込む可能性があります。件数が多い(数万〜数百万)と、メモリが不足になって、worker/job が落ちる原因になります。

解決: find_eachバッチ処理します。

find_each は 主キーによるページングで、メモリには 1 バッチ分だけ保持します。

User.where(active: true).find_each(batch_size: 1000) do |user|
  # user を1件ずつ処理
end

バッチ単位で処理したい(特に一括更新)なら in_batches が便利です。

User.where(active: true).in_batches(of: 1000) do |relation|
  relation.update_all(flag: true)
end

rake タスク、データの移行作業、数万件以上のレコードを扱うジョブなどに使われます。

7. コールバックが不要なら bulk update/delete: update_all / delete_all

問題: 1万件を each { update } すると、1万回のクエリに加えて validations/callbacks が走ります。場合によってはメール送信なども巻き込まれて重くなります。

解決: 1クエリでまとめて処理します。

# 一括更新
User.where(id: ids).update_all(active: false)

# 一括削除
Log.where("created_at < ?", 30.days.ago).delete_all

重要: update_all / delete_all は バリデーションとコールバックを完全にスキップします。

フラグを一括変更、単純なデータ修正、ログの削除などに使われます。

8. association の件数表示には counter cache を使う

問題: 一覧で「コメント数」を表示するために post.comments.count を多用すると重くなります。includes(:comments) にしても comments が多いとそれ自体が重くなることがあります。

解決: comments_count のようなカラムに件数を保持します(counter cache)。

# posts に comments_count を追加
add_column :posts, :comments_count, :integer, default: 0, null: false

# counter cache を有効化
class Comment < ApplicationRecord
  belongs_to :post, counter_cache: true
end

以降は post.comments_count を使えます。

これは、一覧ページや管理画面など「件数表示」があちこちに出てくる画面で特に効きます。

注意: 読み込みは非常に速くなりますが、コメント作成/削除時に post 側のカラム更新が 1 回増えます。

9. 高コストな処理は Rails.cache.fetch でキャッシュする

問題: 重いクエリや計算(トップの記事、統計、設定値など)を毎リクエスト再計算してしまう。

解決: TTLと分かりやすい key を持ったキャッシュを使います。

top_posts = Rails.cache.fetch(["top_posts", Date.current], expires_in: 10.minutes) do
  Post.published.order(score: :desc).limit(20).pluck(:id, :title)
end

キー設計は ["feature_name", version, params...] の配列にすると、管理しやすいです。expires_in も付けて意図せず永続化する事故を避けましょう。

10. view をキャッシュする(fragment / collection caching)

問題: DB はそこまで遅くないのに、partial が多い、フォーマット処理が重いなどで view のレンダリングが遅くなる。

解決1: record 単位の fragment cache

<% @posts.each do |post| %>
  <% cache(post) do %>
    <%= render "posts/post", post: post %>
  <% end %>
<% end %>

Railsはレコードのcache key(バージョン付き)を使うので、post が更新されるとキーも変わり、自然に無効化されます。

解決2: collection caching(短くて速い)

<%= render partial: "posts/post", collection: @posts, cached: true %>

レンダリング時間とCPU使用量が大きく減ります。


Rails が遅い原因は、framework そのものというより、余計なことをしてしまっているコードにあることがほとんどです。まずは上の基本最適化から入れるだけで、複雑なキャッシュ設計やサーバー増強をしなくても、速くなるケースが珍しくありません。

明日の記事の担当はエンジニアの嘉松さんです。お楽しみに。