以下の内容はhttps://tech.smarthr.jp/entry/2025/04/09/112745より取得しました。


明日から使えるRailsパフォーマンス改善Tips集

こんにちは。プロダクトエンジニアのnakanotです。普段は組織図・分析レポート・従業員サーベイの開発を担当しています。

「この画面、ちょっと遅いな…」 そんな違和感から始まるRailsアプリのパフォーマンス改善、皆さんも経験あるのではないでしょうか?

私が担当しているプロダクトでは、ありがたいことにユーザー数も増え、パフォーマンス課題が発生することが増えてきました。 実際にパフォーマンス改善に取り組む中で「ここ気をつけると結構変わる」「こんな感じで解決している」というポイントが見えてきたので、この記事ではそれらをTips集としてまとめてみました。

明日から使える実践テク、ぜひチェックしてみてください!

目次

ActiveRecord関連

ActiveRecord関連のパフォーマンス改善Tipsを紹介します。

ActiveRecordをむやみにインスタンス化しない

ActiveRecordのインスタンス化は意外とコストがかかります。 大量のデータを扱う場合、pluckメソッドを使用することで、必要なカラムのみを取得し、ActiveRecordオブジェクトの生成を避けることができます。また、selectメソッドを使用して必要なカラムのみを取得することも有効です。

# 非効率な例
user_names = User.all.map { |user| user.name }

# 効率的な例
user_names = User.pluck(:name)

実際のベンチマーク結果を見てみましょう。

require 'benchmark'

# テストデータの準備
User.create!(name: 'Test User', email: 'test@example.com') while User.count < 10000

# ベンチマーク実行
Benchmark.bm do |x|
  x.report("インスタンス化あり") do
    User.all.map { |user| user.name }
  end

  x.report("pluck使用") do
    User.pluck(:name)
  end

  x.report("select使用") do
    User.select(:name).map(&:name)
  end
end

このコードを実行すると、以下のような結果が得られます。

                  user     system      total        real
インスタンス化あり  0.500000   0.100000   0.600000 (  0.650000)
pluck使用        0.050000   0.010000   0.060000 (  0.070000)
select使用       0.200000   0.030000   0.230000 (  0.250000)

複数のカラムを扱いたい場合はStructを使用することで、ActiveRecordのインスタンス化を避けつつ、オブジェクトとして扱うことができます。

UserStruct = Struct.new(:id, :name, :email)

users = User.pluck(:id, :name, :email).map { |id, name, email| UserStruct.new(id, name, email) }
users.first.name

実際の業務では、1つのモデルのデータ件数が少なくても、preloadなどで関連モデルもまとめて取得していたことで、結果的に大量のActiveRecordインスタンスが生成され、パフォーマンスが低下することがありました。

データ量が少ない場合はあまり効果を感じられないかもしれませんが、データ量が増えるにつれて徐々に効果が出てくるので、ぜひ試してみてください。

ActiveRecord::Relationに対してデータの存在確認をする場合は exists? を使おう

配列に対してデータの存在確認を行う場合は、present?blank? をよく使うのではないでしょうか。 配列にpresent? や blank?を実行する分には大きな問題はないですが、ActiveRecord::Relationに対して実行すると、パフォーマンスが悪くなることがあります。 ActiveRecord::Relationに対して存在確認を行う場合は、exists?を使用しましょう。

ActiveRecord::Relationに対してpresent?blank? を実行した場合、以下のような挙動になります。

# 非効率な例
users = User.where(active: true)
if users.present?
  # 処理
end

# 発行されるSQL
# SELECT "users".* FROM "users" WHERE "users"."active" = true

この場合、以下のような処理が行われます。

  1. whereで指定した条件のクエリが発行される
  2. 取得した全データをメモリにロードする
  3. ロードしたデータに対してpresent?を実行する

つまり、relationで発行されたクエリが取得するデータ量が多いほど、パフォーマンスが悪くなります。

一方、exists?を使用した場合は以下のようになります。

# 効率的な例
users = User.where(active: true)
if users.exists?
  # 処理
end

# 発行されるSQL
# SELECT 1 AS one FROM "users" WHERE "users"."active" = true LIMIT 1

exists?を使用した場合、以下のような処理が行われます。

  1. LIMIT 1が自動的に追加される
  2. 必要なカラムのみ(1 AS one)を取得する
  3. 最初の1件が見つかれば即座に結果を返す

この違いは、特に大量のデータを扱う場合に顕著になります。例えば、100万件のレコードがある場合、

  • present?の場合
    • 100万件のデータを全てメモリにロード
  • exists?の場合
    • 最初の1件が見つかった時点で処理を終了

また、countを使用した場合も同様に非効率です。

# 非効率な例
if User.where(active: true).count > 0
  # 処理
end

# 発行されるSQL
# SELECT COUNT(*) FROM "users" WHERE "users"."active" = true

countは実際に全レコードを数える必要があるため、exists?と比べて非効率です。

同様に、データが存在しないことを確認する場合は!exists?を使用しましょう。

# 非効率な例
if User.where(active: true).blank?
  # 処理
end

# 効率的な例
if !User.where(active: true).exists?
  # 処理
end

# 発行されるSQL
# SELECT 1 AS one FROM "users" WHERE "users"."active" = true LIMIT 1

このように、ActiveRecord::Relationに対して存在確認を行う場合は、exists?を使用することで、より効率的なクエリを発行できます。

DB設計関連

DB設計関連のパフォーマンス改善Tipsを紹介します。

「最新1件」を has_one で効率的に取得する

ブログのバージョン管理を例に考えてみましょう。以下のような関連を持つモデルがあるとします。

class Blog < ApplicationRecord
  has_many :versions

  def latest_version
    versions.order(created_at: :desc).first
  end
end

class Version < ApplicationRecord
  belongs_to :blog
end

最新のバージョンを取得する際、以下のようなコードを書くことがあるかもしれません。

blog.latest_version

データ量が少ない場合は問題ありませんが、データ量が多い場合、毎回order(created_at: :desc).firstを実行するのは非効率です。

この問題を解決するには、has_oneアソシエーションを使用します。親テーブルにlatest_version_idを持たせることで、最新のバージョンを効率的に取得できます。

create_table "blogs" do |t|
  t.uuid "latest_version_id", null: false
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
end

class Blog < ApplicationRecord
  has_one :latest_version, class_name: 'Version', foreign_key: 'latest_version_id'
end

この方法には以下のメリットがあります。

  • パフォーマンスの向上
    • 最新バージョンの取得がO(1)で可能
    • orderfirstによるソート処理が不要
  • コードの簡潔さ
    • アソシエーションを使用することで、コードがより読みやすくなる
    • 最新バージョンの取得ロジックがモデルに集約される

ただし、以下の点に注意が必要です。

  • データの整合性
    • バージョンが更新されるたびにlatest_version_idを更新する必要がある
    • 更新漏れがないよう、適切なコールバックやトランザクションを設定する必要がある
  • マイグレーション
    • 既存のデータがある場合、latest_version_idの初期値を設定する必要がある
    • マイグレーション時に最新バージョンを特定して設定する必要がある

このように、has_oneアソシエーションを使用することで、最新のレコードを効率的に取得できます。データ量が多い場合や、頻繁に最新レコードを取得する必要がある場合は、ぜひ試してみてください。

マスターデータやサマリーデータは別テーブルに切り出す

マスターデータやサマリーデータなど、「頻繁に参照されるが更新頻度は低いデータ」や「集計済みのサマリーデータ」を専用のテーブルに切り出すことで、大量のデータを都度結合・集計せずに済むため、パフォーマンスを改善できます。

従業員サーベイのプロダクトでは、サーベイを配信した従業員の部署情報を集計する際に以下のような課題がありました。(実際のコードとは異なります。)

class Survey < ApplicationRecord
  has_many :employees
end

class Employee < ApplicationRecord
  belongs_to :survey
  has_many :departments
end

class Department < ApplicationRecord
  belongs_to :employee
end

# 非効率な例
# 毎回従業員テーブルから部署情報を集計
departments = Survey.joins(employees: :departments)
                   .where(survey_id: survey_id)
                   .distinct(:department_id)
                   .pluck(:department_id)

この実装では、毎回従業員テーブルから部署情報を集計し、重複を削除する必要があり、パフォーマンスが悪くなっていました。(実際は他にも中間テーブルがあり、複数のテーブルと結合する必要がありました。)

この課題を解決するために、サーベイごとに部署情報を集計した結果を保存するテーブルを用意しました。

class Survey < ApplicationRecord
  has_many :survey_departments
end

class SurveyDepartment < ApplicationRecord
  belongs_to :survey
end

# 効率的な例
# 集計済みの部署情報を参照
departments = SurveyDepartment.where(survey_id: survey_id)
                            .pluck(:department_id)

部署情報を集計したテーブルを用意したことで、不要な結合や集計処理が不要になり、パフォーマンスが改善しました。

このように、マスターデータやサマリーデータを専用のテーブルに切り出すことで、パフォーマンスを大幅に改善できます。 集計処理のパフォーマンスを改善したい場合は、ぜひ試してみてください。

Ruby関連

Ruby関連のパフォーマンス改善Tipsを紹介します。

ハッシュテーブルを使ってO(1)アクセスを実現する

配列から特定の要素を取得する処理はよく書くのではないでしょうか。 配列の検索をハッシュテーブルに置き換えることで、検索量を減らし、処理速度を改善できます。

# 非効率な例
users = User.all.to_a
target_user = users.find { |user| user.id == target_id }

# 効率的な例
users = User.all.index_by(&:id)
target_user = users[target_id]

index_byメソッドなどを使用してハッシュテーブルを作成することで、O(n)の検索をO(1)に改善できます。

実際のベンチマーク結果も見てみましょう。

require 'benchmark'

# テストデータの準備
users = 10000.times.map { |i| OpenStruct.new(id: i, name: "User #{i}") }
target_id = 9999

# ベンチマーク実行
Benchmark.bm do |x|
  x.report("配列検索") do
    1000.times do
      users.find { |user| user.id == target_id }
    end
  end

  x.report("ハッシュ検索") do
    users_hash = users.index_by(&:id)
    1000.times do
      users_hash[target_id]
    end
  end
end

このコードを実行すると、以下のような結果が得られます。

            user     system      total        real
配列検索    2.887651   0.002461   2.890112 (  2.901490)
ハッシュ検索  0.003664   0.000093   0.003757 (  0.003766)

この結果から、配列検索に比べてハッシュ検索はかなり効率が良いことがわかります。 データ量が増えるほど、この差はさらに大きくなります。

その他

その他のパフォーマンス改善Tipsを紹介します。

プロファイラを使って、不要なクエリの発行やボトルネックとなっている処理を特定する

プロファイラを使うことで、リクエスト単位でのパフォーマンス分析が可能です。 私がパフォーマンス改善する際は、ローカル環境ではrack-mini-profilerを使用して、本番環境ではNewRelicを使用しています。

ローカル環境での分析

ローカル環境ではrack-mini-profilerを使用することで、画像のようにリクエスト内の各処理でかかった時間を確認できます。

rack-mini-profilerのリクエスト内の処理の一覧の結果
rack-mini-profilerでリクエスト内で実行されている処理の一覧を確認できる

赤枠の「sql」を押すと、該当の処理で発行されたクエリを確認できます。

rack-mini-profilerで確認できるクエリ実行ログの結果
発行されているクエリを確認できる

このツールを導入することで、各ページのレンダリング時間やSQLクエリの実行時間が視覚的に表示され、パフォーマンスのボトルネックを簡単に特定できます。

実際に、rack-mini-profilerを利用したことで、以下のような問題を特定できました。

  • N+1クエリの発生
  • アプリケーション内で使われていないカウントクエリの発行
  • パフォーマンスが悪いクエリの発行

本番環境での分析

本番環境ではNewRelicというサービスを使用してパフォーマンスを分析しています。 主に、本番環境の実際のデータ量でのパフォーマンスの確認やRAILS_ENV=productionでしか発生しない問題の特定などに利用しています。

実際にNewRelicを使用して、本番環境でのみ実行されるRack Middlewareのパフォーマンス低下を特定できました。 具体的には、committeeと呼ばれるOpenAPIの定義に基づいてリクエストとレスポンスのバリデーションを行うgemがあります。 本番環境でもリクエストとレスポンスがOpenAPIと合致しているかバリデーションを行っていたのですが、このバリデーション処理がボトルネックとなり、パフォーマンスが低下していました。

committeeのバリデーションがパフォーマンスに影響している例
committeeのバリデーションがパフォーマンスに影響している例

この問題は結局committeeのバリデーションを実行しないようにすることで解決しました。

このように、ローカル環境と本番環境それぞれで適切なツールを使うことで、より正確にパフォーマンスの問題を把握し、効率的に改善を進めることができます。

EXPLAIN ANALYZE を使って、SQLのボトルネックを可視化する

何はともあれ、SQLのパフォーマンス改善の第一歩は、ボトルネックの特定です。PostgreSQLのEXPLAIN ANALYZEを使用することで、SQLクエリの実行計画と実際の実行時間を詳細に確認できます。

sql = %{
EXPLAIN ANALYZE
#{User.where(email: 'example@example.com').to_sql}
}
ActiveRecord::Base.connection.execute(sql)

# rails 7.1からは ActiveRecord::Relation#explain(:analyze) と指定すると、 EXPLAIN ANALYZE が実行できます。
User.where(email: 'example@example.com').explain(:analyze)

このコマンドを実行すると、以下のような結果が得られます。

QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
 Seq Scan on users  (cost=0.00..10.50 rows=1 width=100) (actual time=0.015..0.015 rows=1 loops=1)
   Filter: (email = 'example@example.com'::text)
   Rows Removed by Filter: 99
 Planning Time: 0.050 ms
 Execution Time: 0.030 ms

実行計画の主なポイントは以下の通りです。

  • cost
    • 処理にかかる推測コスト(数値が大きいほど処理に時間がかかる)
  • actual time
    • 実際の実行時間(ミリ秒)
  • rows
    • 取得される行数の見積もりと実際の行数
  • Seq Scan
    • テーブルを先頭から順にスキャンしている状態(インデックスが効いていない可能性がある)

ここでは詳しい改善手順は説明はしませんが、cost / actual time辺りの値が大きいものを見ながらボトルネックになっている箇所を特定したり、Seq Scanが起きてる場所であればインデックスの追加を検討したりしています。

最近パフォーマンス改善でクエリを改善した事例の一部を紹介します。

  • 不要なテーブルとの結合をやめる
  • レコードを適切に絞り込んだ上で、他のテーブルと結合する
  • インデックスが活用されていない場合は、インデックスを追加したりインデックスが活用されるようにクエリに変更する

このように、EXPLAIN ANALYZEの結果を分析することで、SQLのパフォーマンスを改善できます。

まとめ

以上、Railsアプリのパフォーマンス改善Tips集でした。 Railsアプリのパフォーマンス改善は「小さな積み重ね」で大きく変わります。まずは身近なところから試して、ボトルネックを1つずつ解消していきましょう!

We Are Hiring!

SmartHRでは一緒にSmartHRを作りあげていく仲間を募集中です!少しでも興味を持っていただけたら、カジュアル面談でざっくばらんにお話ししましょう!

hello-world.smarthr.co.jp




以上の内容はhttps://tech.smarthr.jp/entry/2025/04/09/112745より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14