AIにおまかせ!RailsのSQLチューニング
AIエージェント(Cursor)と共にRails APIを高速化!ベンチマーク駆動によるパフォーマンス改善自動化への挑戦
Railsアプリケーションを開発・運用していると、APIのパフォーマンス問題に直面することは少なくありません。特に、データの関連性が複雑になるにつれて、意図せずN+1クエリを発生させてしまい、レスポンスタイムが悪化するケースは頻繁に見られます。
パフォーマンス改善は重要ですが、問題箇所の特定、修正、そしてその効果測定という一連のプロセスは、地味で時間のかかる作業になりがちです。
そこで今回は、AIコーディング支援ツールである Cursor と、客観的な効果測定のための ベンチマーク を組み合わせることで、この改善プロセスを効率化・半自動化する試みを紹介します。目標は、Cursorをあたかも「改善エージェント」のように扱い、ベンチマーク結果に基づいて改善を「自走」してもらうことです。
準備:改善対象のAPIと問題の特定
まずは、改善対象となるシンプルなRailsアプリケーションを用意します。User
モデルと Post
モデルがあり、User
は多数の Post
を持つ(has_many :posts
)、Post
は特定の User
に属する(belongs_to :user
)という、よくある関係だとしましょう。
ここで、以下のような「ユーザーとその最新投稿一覧を返すAPI」のエンドポイントがあるとします。
app/controllers/users_controller.rb
(改善前)
class UsersController < ApplicationController
def index
users = User.limit(50) # 簡単のため、50件に制限
render json: users.map do |user|
{
id: user.id,
name: user.name,
# ここで各ユーザーごとにPostを取得するためN+1クエリが発生する
latest_post_title: user.posts.order(created_at: :desc).first&.title
}
end
end
end
このコードでは、User.limit(50)
でまずユーザーを50人取得し (1クエリ)、その後 users.map
のループ内で各ユーザーごとに user.posts
を呼び出しています。これにより、ユーザーの数(50回)+ 最初の1回 = 51回のSQLクエリが発生する、典型的なN+1問題が起こっています。
このような問題は、rails logs
を確認したり、bullet
gem のようなツールを使うことで発見できます。
ベンチマークスクリプトの作成
パフォーマンス改善を行う上で、「どれだけ改善されたか」を客観的に知ることは非常に重要です。感覚的な「速くなった気がする」だけでは、本当に効果があったのか、あるいは別の変更でリグレッション(性能低下)が起きていないかを確認できません。
そこで、Rubyの標準ライブラリである Benchmark
を使って、改善前後のコード実行時間を計測するスクリプトを作成します。
改善前のロジック (BeforeQuery
) と、これからCursorに改善してもらうロジック (AfterQuery
) をそれぞれクラスとしてカプセル化し、比較しやすくします。
benchmark_script.rb
(Railsプロジェクトルートに配置)
# benchmark_script.rb
require_relative './config/environment' # Rails環境を読み込む
require 'benchmark'
# --- 改善前のロジックをカプセル化 ---
class BeforeQuery
def execute
users = User.limit(50).to_a # 改善前のコントローラーと同じロジック
users.map do |user|
{
id: user.id,
name: user.name,
latest_post_title: user.posts.order(created_at: :desc).first&.title
}
end
end
end
# --- 改善後のロジックをカプセル化 ---
# 最初は改善前と同じか、空にしておく。Cursorの提案で更新する。
class AfterQuery
def execute
# === ここにCursorが提案する改善コードを実装する ===
# 例: includes を使ったコード
users = User.includes(:posts).limit(50).to_a
users.map do |user|
# includesによりPostはメモリに読み込み済みなので、追加クエリは発生しない
# ただし、最新1件を取得するロジックは別途最適化が必要な場合もある
# ここでは簡単のため、関連を読み込む改善のみとする
latest_post = user.posts.sort_by(&:created_at).last # メモリ上でソートして取得
{
id: user.id,
name: user.name,
latest_post_title: latest_post&.title
}
end
end
end
# --- ベンチマーク設定 ---
iterations = 10 # 試行回数
before_times = []
after_times = []
# --- 事前準備 ---
# 必要に応じて、テストデータの準備やキャッシュクリアなどを行う
unless User.exists? && Post.exists?
puts "Seeding database for benchmark..."
# ここで `rails db:seed` を実行するか、
# ベンチマーク用の最低限のデータを作成するコードを記述
# 例:
# 50.times do |i|
# user = User.create!(name: "User #{i}", email: "user#{i}@example.com")
# rand(1..5).times do |j|
# user.posts.create!(title: "Post #{j} by User #{i}", content: "Content...")
# end
# end
puts "Please ensure you have seed data. Run 'rails db:seed' if necessary."
# exit # データがない場合は終了する方が安全
end
puts "Benchmarking start (#{iterations} iterations each)..."
puts "----------------------------------"
# --- 改善前ロジックのベンチマーク ---
puts "Running BeforeQuery..."
before_query = BeforeQuery.new
iterations.times do |i|
print "." # 進捗表示
time = Benchmark.realtime do
before_query.execute
end
before_times << time
end
puts "\nBeforeQuery finished."
# --- 改善後ロジックのベンチマーク ---
puts "\nRunning AfterQuery..."
after_query = AfterQuery.new
iterations.times do |i|
print "." # 進捗表示
time = Benchmark.realtime do
after_query.execute
end
after_times << time
end
puts "\nAfterQuery finished."
# --- 結果の計算と表示 ---
puts "\n--- Results ---"
before_total_time = before_times.sum
before_average_time = before_total_time / iterations
after_total_time = after_times.sum
after_average_time = after_total_time / iterations
puts "Before Query:"
puts " Total time (#{iterations} runs): #{format('%.4f', before_total_time)} seconds"
puts " Average time: #{format('%.4f', before_average_time)} seconds"
# puts " Individual times: #{before_times.map { |t| format('%.4f', t) }.join(', ')}" # 必要ならコメント解除
puts "\nAfter Query:"
puts " Total time (#{iterations} runs): #{format('%.4f', after_total_time)} seconds"
puts " Average time: #{format('%.4f', after_average_time)} seconds"
# puts " Individual times: #{after_times.map { |t| format('%.4f', t) }.join(', ')}" # 必要ならコメント解除
puts "\n--- Comparison ---"
if after_average_time > 0 && before_average_time > 0 && before_average_time > after_average_time
improvement_factor = before_average_time / after_average_time
improvement_percentage = ((before_average_time - after_average_time) / before_average_time) * 100
puts "AfterQuery is #{format('%.2f', improvement_factor)}x faster than BeforeQuery."
puts "Improvement: #{format('%.2f', improvement_percentage)}%"
elsif after_average_time == 0 && before_average_time == 0
puts "Both queries executed extremely fast (average time is effectively zero)."
elsif after_average_time < before_average_time
puts "AfterQuery is faster, but cannot calculate factor due to zero time in before_average_time."
else
puts "AfterQuery seems slower or BeforeQuery was zero."
end
puts "----------------------------------"
実行前の注意:
- このスクリプトはRailsプロジェクトのルートディレクトリから実行してください (
ruby benchmark_script.rb
)。 - ベンチマークには実際のデータが必要です。
rails db:seed
などで事前にテストデータを作成しておいてください。上記のスクリプト例にも簡単なデータチェックとメッセージを入れています。 -
AfterQuery
のexecute
メソッドは、最初は空にするか、BeforeQuery
と同じ内容をコピーしておきます。
Cursorと共に改善サイクルを回す
ここからが本番です。Cursorとベンチマークスクリプトを使って、改善サイクルを回していきます。
Step 1: 現状の測定
まず、作成したベンチマークスクリプトを実行し、改善前のパフォーマンス(BeforeQuery
の平均実行時間)を記録します。
$ ruby benchmark_script.rb
Benchmarking start (10 iterations each)...
----------------------------------
Running BeforeQuery...
..........
BeforeQuery finished.
Running AfterQuery...
..........
AfterQuery finished.
--- Results ---
Before Query:
Total time (10 runs): 1.5432 seconds
Average time: 0.1543 seconds
After Query:
Total time (10 runs): 1.5510 seconds # この時点では改善前と同じはず
Average time: 0.1551 seconds
--- Comparison ---
AfterQuery seems slower or BeforeQuery was zero.
----------------------------------
(実行時間は環境やデータ量によって大きく異なります)
Step 2: Cursorへの指示
次に、Cursorを開き、問題のある UsersController#index
のコード、または benchmark_script.rb
内の BeforeQuery#execute
のコードを選択します。そして、Cursorのチャット機能やインライン編集機能(Cmd+K
など)を使って、改善を依頼します。
プロンプト例:
- 「このRubyコードのパフォーマンスを改善したいです。特にN+1クエリの問題を修正してください。」
- 「
User
モデルとPost
モデル(User has_many :posts
)があります。このコード(BeforeQuery#execute
)ではN+1が発生しているので、includes
を使って修正する案を提示してください。」 - (ベンチマーク結果を伝えて)「現在の平均実行時間は約 0.15 秒です。これを改善するためのコードを
AfterQuery
クラスに実装する形で提案してください。」
Cursorは、おそらく includes(:posts)
を使ったコードを提案してくるでしょう。
Cursorの提案例(イメージ):
# Cursorが提案するかもしれないコード
users = User.includes(:posts).limit(50).to_a
users.map do |user|
latest_post = user.posts.sort_by(&:created_at).last # includes後はメモリ上で処理
{
id: user.id,
name: user.name,
latest_post_title: latest_post&.title
}
end
Step 3: 改善コードの実装と検証
Cursorが提案したコードを、benchmark_script.rb
の AfterQuery
クラスの execute
メソッド内に実装します。
# benchmark_script.rb 内
class AfterQuery
def execute
# === Cursorが提案した改善コードをここに実装 ===
users = User.includes(:posts).limit(50).to_a
users.map do |user|
latest_post = user.posts.sort_by(&:created_at).last
{
id: user.id,
name: user.name,
latest_post_title: latest_post&.title
}
end
end
end
実装したら、再度ベンチマークスクリプトを実行します。
$ ruby benchmark_script.rb
Benchmarking start (10 iterations each)...
# ... (中略) ...
--- Results ---
Before Query:
Total time (10 runs): 1.5488 seconds
Average time: 0.1549 seconds
After Query:
Total time (10 runs): 0.0876 seconds # 大幅に改善されているはず!
Average time: 0.0088 seconds
--- Comparison ---
AfterQuery is 17.60x faster than BeforeQuery.
Improvement: 94.32%
----------------------------------
AfterQuery
の平均実行時間が大幅に短縮され、比較結果にも改善率が表示されました!
Step 4: 評価と反復 (「自走」させる部分)
ベンチマーク結果を見て、改善が十分かどうかを評価します。今回のN+1解消のように劇的な改善が見られれば、一旦ここで完了としても良いでしょう。
もし改善が不十分だったり、さらなる高速化を目指したい場合は、この結果(改善後の平均実行時間)を Cursor に伝え、追加の改善策を求めます。
追加のプロンプト例:
- 「
includes
を使って平均実行時間が 0.0088 秒になりました。さらに改善する方法はありますか?例えば、データベースインデックスの追加や、クエリ自体の見直しは可能ですか?」 - 「この
AfterQuery
のコードで、特にuser.posts.sort_by(&:created_at).last
の部分がまだ効率化できそうですか?」
Cursorは、状況に応じてデータベースインデックスの提案(例: posts
テーブルの user_id
と created_at
への複合インデックス)、キャッシュ戦略の導入、あるいはより高度なSQL(ウィンドウ関数など)を使ったクエリの書き換えなどを提案してくるかもしれません。
提案されたら、再び Step 3 に戻り、コードを実装してベンチマークを実行します。この 「ベンチマーク実行 → 結果をCursorにフィードバック → 提案を実装 → 再度ベンチマーク」 というサイクルを繰り返すことが、Cursorエージェントに改善を「自走」させるイメージです。開発者は、そのサイクルを回す指示を与える役割となります。
改善結果の確認
最終的に満足のいくパフォーマンスが得られたら、benchmark_script.rb
の AfterQuery
に実装されたコードを、実際の UsersController
に反映させます。
app/controllers/users_controller.rb
(改善後)
class UsersController < ApplicationController
def index
# includes を使ってN+1を解消
users = User.includes(:posts).limit(50)
render json: users.map do |user|
# メモリ上のデータで処理
latest_post = user.posts.sort_by(&:created_at).last
{
id: user.id,
name: user.name,
latest_post_title: latest_post&.title
}
end
end
end
最終的なベンチマーク結果(例: 平均 0.1549 秒 → 0.0088 秒、約17.6倍高速化)を記録し、改善が達成されたことを確認します。可能であれば、tail -f log/development.log
などでAPIアクセス時のSQLログを比較し、発行されるクエリ数が実際に減っていることを確認するのも良いでしょう。
考察とまとめ
今回試したように、Cursorとベンチマークを組み合わせることで、Rails APIのパフォーマンス改善プロセスを以下のように効率化できます。
-
アイデア出しの高速化: N+1の修正方法や、他の最適化手法(
preload
,eager_load
の使い分け、インデックス、キャッシュ等)のアイデアを素早く得られる。 -
定型的な修正の自動化:
includes
の追加のような典型的な修正コードは、高精度で生成してくれることが多い。 - 客観的な効果測定: ベンチマークによって、改善の効果を数値で正確に把握できる。これにより、「やったつもり」の改善を防ぎ、着実な前進を確認できる。
- 学習効果: Cursorが提案するコードや説明を読むことで、開発者自身のスキルアップにも繋がる可能性がある。
一方で、注意点もあります。
- AIは万能ではない: 常に最適・正確なコードが生成されるとは限りません。特に複雑なビジネスロジックが絡む場合、AIの提案が的を射ないこともあります。
- コードの理解は必須: Cursorが生成したコードは、必ず開発者自身が理解し、その動作原理や副作用を検討する必要があります。鵜呑みにせず、レビュープロセスは不可欠です。
- ベンチマークの限界: ベンチマークは特定の条件下での性能を示すものです。実際の多様なアクセスパターンやデータ量の下で、必ずしも同じ性能が出るとは限りません。負荷テストなども組み合わせるとより確実です。
CursorのようなAIコーディングツールは非常に強力ですが、あくまで「優秀なアシスタント」です。最終的な判断やコードの品質担保は、開発者の責任となります。
結論として、Cursorとベンチマークを組み合わせたパフォーマンス改善は、非常に有効なアプローチです。 ベンチマークという客観的な指標を軸に、AIの力を借りて改善サイクルを高速に回すことで、より効率的かつ効果的にRailsアプリケーションのパフォーマンスを高めることができるでしょう。
ぜひ、皆さんのプロジェクトでも試してみてはいかがでしょうか。
Discussion