こんにちは。プロダクトエンジニアの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
この場合、以下のような処理が行われます。
whereで指定した条件のクエリが発行される- 取得した全データをメモリにロードする
- ロードしたデータに対して
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?を使用した場合、以下のような処理が行われます。
LIMIT 1が自動的に追加される- 必要なカラムのみ(
1 AS one)を取得する - 最初の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)で可能
orderやfirstによるソート処理が不要
- コードの簡潔さ
- アソシエーションを使用することで、コードがより読みやすくなる
- 最新バージョンの取得ロジックがモデルに集約される
ただし、以下の点に注意が必要です。
- データの整合性
- バージョンが更新されるたびに
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を使用することで、画像のようにリクエスト内の各処理でかかった時間を確認できます。

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

このツールを導入することで、各ページのレンダリング時間やSQLクエリの実行時間が視覚的に表示され、パフォーマンスのボトルネックを簡単に特定できます。
実際に、rack-mini-profilerを利用したことで、以下のような問題を特定できました。
- N+1クエリの発生
- アプリケーション内で使われていないカウントクエリの発行
- パフォーマンスが悪いクエリの発行
本番環境での分析
本番環境ではNewRelicというサービスを使用してパフォーマンスを分析しています。
主に、本番環境の実際のデータ量でのパフォーマンスの確認やRAILS_ENV=productionでしか発生しない問題の特定などに利用しています。
実際にNewRelicを使用して、本番環境でのみ実行されるRack Middlewareのパフォーマンス低下を特定できました。
具体的には、committeeと呼ばれるOpenAPIの定義に基づいてリクエストとレスポンスのバリデーションを行うgemがあります。
本番環境でもリクエストとレスポンスがOpenAPIと合致しているかバリデーションを行っていたのですが、このバリデーション処理がボトルネックとなり、パフォーマンスが低下していました。

この問題は結局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を作りあげていく仲間を募集中です!少しでも興味を持っていただけたら、カジュアル面談でざっくばらんにお話ししましょう!