新年明けましておめでとうございます。ゴミカスコードを書いた者です。
この記事の目的
- N+1問題を単なる「知識」ではなく、物理的なリソースの無駄として「実感」する。
- コードレビュー時に、AIの提案を鵜呑みにせず、パフォーマンス上のリスクを直感的に指摘できるメンタルモデルを構築する。
- 理想の処理を実現するための「道具」として、ActiveRecordの各メソッドを使い分けられる状態を目指す。
この前、実務であるデータインポート処理を実装した際、完了まで約5分かかるあまりに重たいコードを書いてしまいました。先輩には「これくらいの処理なら数秒で終わるはず」とレビューいただき、修正することにしました。
結果、それを6秒まで短縮(約50倍の高速化)できました。
(今後、同じようなパフォーマンスのコードを書かないためにも、自戒を意をこめて冒頭、ゴミカスコードと呼ばせていただきました。)
ただ、今回の高速化で満足せずに、再現性をもってリファクタリングしたり、他の人のコードレビューにも活かせるようにしてこそ、ようやく「成長に繋がる収穫」といえるはずということで整理しました。
「これ使っておけば、パフォーマンス良くなるんだよね」
「とにかくクエリの回数を減らそう」
というレベルではなく、
「この後この情報が欲しいから、今のうちにこの分はまとめて取得しておこう。」
みたいな考えができたり、
「AIに書いてもらったコードをなぜapproveしたのかをちゃんと自分の言葉で説明できる状態」になりたいのです。
なので、この記事の目的は、単にメソッドを覚えることではなく、コードレビュー時に「あ、これパフォーマンス悪そうだな」と直感的に気づけるメンタルモデル を自分の中に作ることがゴールです。
そして、理想の処理を実現するための道具としてメソッドを使いこなせている!みたいな状態にしたい。
1. 現実世界の「当たり前」が、コードでは「意識」しないとできない
もし100個のネジが必要になったとき、Amazonでどう注文するでしょうか。 普通は、100個をまとめてカートに入れ、1回の会計で済ませるはずです。(1個売りのネジなんてない)
しかし、コードを書く際、私たちは無意識に 「ネジ1個を注文して、カートに入れて会計し、届くのを待つ。届いたら、またAmazonを開いて次のネジを1個注文する」 という行動を100回繰り返してしまいます。
現実世界でそんなことをすれば、100回分の送料(通信負荷)がかかり、100回玄関のチャイムが鳴り(クエリ発行)、あなたは100回ハンコ(コミット)を押す羽目になります。
こんな非効率的なこと、絶対にやらないはずです。
しかし、コードの世界では、この「狂気」ともいえる非効率を、私たちは平気な顔をしてやってしまっているらしい。
N+1問題という言葉を知らないエンジニアは少ないはずなのに、なぜ「5分のクソコード」は生まれてしまうのか。僕だって、わざと遅く書いたわけじゃないです。
CSV.foreach(file_path) do |row|
# ネジ1本のためにAmazonを開いて検索(DBへの問い合わせ)
product = Product.find_by(code: row[:code])
# ネジ1本のために会計ボタンを押し、ハンコを押して受け取る(更新とコミット)
product.update!(price: row[:price])
end
(大前提、このコードを書いたときはとにかく動くものを急いで書きたいと思っていたことに加え、知識も意識も足りなかったのですが、、)
なぜ、現実なら気を付けなくてもできることが、コードになると難しいのかを考えたのですが、それは、コードの世界には 「物理的な手応え」 がないからだと思いました。
「物理的な感覚の欠如」こそが、本来ならあり得ないはずの地獄の注文を、平気な顔で書かせてしまう要因の一つではないかと思いました。
だからこそ、パフォーマンス改善とは単にメソッドを暗記することではなく、この物理的なコスト感をメンタルモデルとして脳内に構築し、「こんなコード、気持ち悪くて書けない!」 という状態を自分の中に作ることがまずは近道だと考えました。
そのためには、まず何がその「気持ち悪さ」の正体なのか。パフォーマンスを悪くする要因を整理して、理解を深めていこうと思います。
2. そもそも「パフォーマンスが悪い」の正体とは?
パフォーマンスを悪くする要因を知るために、以下の記事なども参考にしながら整理しました。
整理すると、「遅さ」の正体は、主に以下があるようです。
① 通信回数の浪費
アプリケーションとDBサーバー間を往復する回数の問題です。
- ネットワーク遅延: クエリが1回走るたびに「配送トラックが1台走る」イメージ。1回の走行は一瞬でも、1万回往復すればその「積み重ね」だけで日が暮れてしまいます。
-
コネクションの占有: トラックが走り続けている間、DBという「レジ」を一人で占有することになり、他のお客さん(他のリクエスト)を延々と待たせてしまいます。
② 探索コストの浪費
DBが「特定のデータを見つけ出すまで」にかかる時間です。
- インデックスのない検索: 整理整頓されていない巨大な倉庫の中から、目当ての商品を端から端まで歩いて探し回るような状態です。
-
メモリ上での解決:
index_byを使って手元に「在庫リスト(Hash)」を作っておけば、わざわざ巨大な倉庫に探しに行く必要がなくなり、手元のメモを見るだけで一瞬でデータが見つかります。
③ メモリ使用量の浪費
DBから取得したデータをActiveRecordオブジェクトに変換・保持するコストです。
- オブジェクト生成コスト: ネジ1本の情報が欲しいだけなのに、わざわざ「冷蔵庫用の巨大な段ボール(全カラムデータ)」に梱包して運んでくるような状態です。
- GC(ガベージコレクション)の誘発: 部屋(メモリ)が巨大な段ボールでパンクすると、Rubyは必死に片付けを始めます。その間、アプリケーションの動きは悪くなります。
④ 書き込み・更新のオーバーヘッド
データの整合性を保つための「手続き」にかかるコストです。
- トランザクションとロック: 1件保存するたびに「配送センターのシャッターを閉めて、受領印をもらう」作業をしているイメージです。
-
バリデーションとインデックス更新: 保存の度に毎回、厳しい「検品(バリデーション)」と、分厚い「台帳の書き換え(インデックス再構築)」が行われます。これが1万回繰り返されると、手続きの時間だけで膨大なロスになります。
これら4つの要因を自分なりに整理した上で、次に、実際に僕が書いてしまった「30,000クエリのコード」がどのような数値を出し、各ステップでどう削ぎ落としていったのか、その改善プロセスを段階的に見ていこうと思います。
3. 実際のBefore / After
セクション2で挙げた4つのパフォーマンス阻害要因が、実際のコードでどのように発生していたかを確認します。改善前のインポート処理に対し、現状を定量的に把握するための計測を行いました。
3-1. 改善前の実装パターン
改善前のコードには、パフォーマンスの観点から非効率な実装が複数箇所に存在しました。以下は、その代表的なパターンです。
パターン①:ループ内での個別クエリ発行(N+1)
CSVデータを1行ずつ処理するループ内で、都度 find_by を実行し、関連するマスタデータを検索しています。
def import_project
# CSVの行数分だけ、DBへの問い合わせが発生する
@csv.each do |row|
# 課題: ループ内での都度検索による通信回数の増加(コスト①)
company = ClientCompany.find_by(code: row[:delivery_code])
# 論理削除チェックのため、再度アクセスが発生する場合がある
next if company&.deleted?
# ...以降の処理
end
end
パターン②:不要なデータを含むオブジェクト全体のロード
特定のステータス確認やID取得だけが必要な場面で、関連モデルのオブジェクト全体を遅延ロードしていました。
# 関連モデルへのアクセス時に遅延ロードが発生
projects.find_each do |project|
# 課題: 判定に必要なのは一部の情報だが、モデルオブジェクト全体をメモリに展開している(コスト③)
# ここでN+1が発生し、ProjectCategoryの全カラムがロードされる
next unless project.project_category.outsource_type?
# こちらも同様に、関連モデルの個別検索とオブジェクト生成が発生
agreement = project.client_company.contract_stats.find_by(...)
end
これらの実装により、処理対象のデータ件数に比例してクエリ数とメモリ使用量が増加する構造となっていました。
3-2. 計測用ツール(QueryCounter)の準備
ボトルネックを正確に特定するため、ActiveRecordが発行するクエリをフックして集計する計測用のクラス(QueryCounter)を用意しました。
特に、どのテーブルへのアクセス頻度が高いかを把握するため、「テーブル別のSELECT回数集計」機能を実装しています。
計測ツール(QueryCounter)
# lib/query_counter.rb
# ActiveRecordのクエリ発行イベントを購読して集計するクラス
class QueryCounter
# ... (省略: 初期化や計測開始メソッドなど) ...
def stop
# ... (省略: 総クエリ数や合計時間の表示) ...
# SELECTクエリのテーブル別集計
# これにより、N+1が発生している対象モデルを特定する
select_queries = @queries.select { |q| q[:sql].upcase.start_with?('SELECT') }
if select_queries.any?
puts "\nSELECTクエリ テーブル別集計(Top 5):"
# テーブル名(モデル名)単位でグルーピングして多い順に表示
table_groups = select_queries.group_by { |q| get_table_name(q[:name]) }
table_groups.sort_by { |_, qs| -qs.count }.take(5).each do |table, qs|
puts " #{table}: #{qs.count}回"
end
end
end
# ...
end
3-3. 計測結果
上記のツールを用い、約1万件のCSVデータを対象に改善前のインポート処理を実行した結果は以下の通りです。
================================================================================
クエリカウント結果(改善前)
================================================================================
総クエリ数: 30,051 回
処理時間: 301.65 秒(約5分)
================================================================================
クエリ種類別集計:
SELECT: 30,004回
INSERT: 40回
UPDATE: 7回
SELECTクエリ テーブル別集計:
Aテーブル: 9,557回
Bテーブル: 6,157回
Cテーブル: 5,741回
Dテーブル: 3,074回
================================================================================`
結果の分析:
- 総クエリ数: 約1万件のデータ処理に対し、3万回を超えるクエリが発行されています。
-
テーブル別集計:
ClientCompanyやProjectCategoryといったマスタ系のテーブルに対し、数千回のSELECTが実行されています。これは処理するCSVの行数と相関しており、ループ内でのN+1問題が主要なボトルネックであることが数値的に確認できました。
この計測結果をベースラインとし、次節以降で段階的な改善を行いました。
4. 段階的な改善
計測によって特定されたボトルネックに対し、AIに計測のサポートをもらいながら、段階的に改善を適用していきました。
Step 1: マスタデータのプリロード
ループ内で都度実行されるマスタデータの検索を、事前の「まとめ買い」に置き換える基本的なN+1解消パターン。
# ❌ Before: ループ内で毎回クエリを発行
@csv.each do |row|
# CSVの行数分だけDBアクセスが発生
company = ClientCompany.find_by(code: row[:delivery_code])
# ...
end
# ✅ After: 事前に一括取得してハッシュ化(メモ化)
# 1. CSVから必要なコードを抽出
delivery_codes = @csv.map { |row| row[:delivery_code] }.compact.uniq
# 2. 1回のクエリで全データを取得し、codeをキーにしたHashを作成
# pluck(:code, :id)で必要なカラムだけ取得し、メモリ使用量も最適化
company_map = ClientCompany.unscoped
.where(code: delivery_codes)
.pluck(:code, :id)
.to_h
@csv.each do |row|
# 3. DBアクセスなしで、メモリ上のHashからIDを取得
company_id = company_map[row[:delivery_code]]
# ...
end`
ポイント:
-
index_byやpluck(...).to_hを使ってデータをHash化することで、検索コストをO(N)からO(1)に削減。 - DBアクセス回数を「CSVの行数(N)回」から「1回」に劇的に削減。
結果: 総クエリ数が 30,051回 → 21,825回 へ(約27.4%削減)
Step 2: データのキャッシュ
存在チェック(exists?)もDBアクセスを伴います。これも事前に必要な値だけを取得してキャッシュします。
# ❌ Before: 毎回DBに問い合わせて存在確認
unless ProjectMenu.exists?(code: row[:menu_code])
raise "メニューコードが存在しません"
end
# ✅ After: 必要な値だけをSetでキャッシュ
# pluckでコードだけを取得し、Setに変換して検索をO(1)にする
@valid_menu_codes = ProjectMenu.pluck(:code).to_set
unless @valid_menu_codes.include?(row[:menu_code])
# DBアクセスなしで、メモリ上のSetから判定
raise "メニューコードが存在しません"
end
ポイント:
-
pluckで必要なカラムの値だけを取得し、メモリ使用量を最小限に抑える。
結果: 総クエリ数が 21,825回 → 16,084回 へ(累計約46.5%削減)
Step 3: 関連データのプリロード
Step 1と同様のアプローチを、他の関連データの検索にも適用します。
# ❌ Before: 関連データを都度検索
@csv.each do |row|
project = Project.find_by(code: row[:project_code])
# ...
end
# ✅ After: 事前に一括取得してハッシュ化
project_codes = @csv.map { |row| row[:project_code] }.compact.uniq
# codeをキーにして、Projectオブジェクト自体を値に持つHashを作成
projects_map = Project.unscoped
.where(code: project_codes)
.index_by(&:code)
@csv.each do |row|
# メモリ上のHashからO(1)でオブジェクトを取得
project = projects_map[row[:project_code]]
# ...
end
結果: 総クエリ数が 16,084回 → 12,873回 へ(累計約57.2%削減)
Step 4: バルクUPSERT(activerecord-import)
ここが最大の改善ポイントです。1件ずつ行っていた保存処理(INSERT/UPDATE)を、activerecord-import を使って一括化(バルク処理)します。
# ❌ Before: 1件ずつ保存(通信と手続きの嵐)
@csv.each do |row|
project = Project.find_or_initialize_by(code: row[:project_code])
# ...属性のセット...
project.save! # 毎回クエリ発行
end
# ✅ After: データを溜めてバルクUPSERT(フォークリフトで一括納品)
project_buffer = []
batch_size = 500 # 一度に処理する件数
@csv.each do |row|
# 保存するオブジェクトをメモリ上に準備するだけ
project = Project.new(
code: row[:project_code],
# ...他の属性...
)
project_buffer << project
# バッファが溜まったら一括保存を実行
if project_buffer.size >= batch_size
bulk_upsert_projects(project_buffer)
project_buffer.clear
end
end
# 残りのデータを保存
bulk_upsert_projects(project_buffer) if project_buffer.any?
private
def bulk_upsert_projects(buffer)
# 更新対象のカラムを指定
update_columns = %i[name status updated_at ...]
# MySQLの ON DUPLICATE KEY UPDATE を利用したUPSERTを実行
# validate: false でRails側のバリデーションをスキップし高速化(必要な場合はDB制約で担保)
Project.import(
buffer,
on_duplicate_key_update: update_columns,
validate: false
)
end
ポイント:
-
activerecord-importgemを利用し、on_duplicate_key_updateオプションでUPSERTを実現(DBがMySQLの場合。 - 1回のクエリで数百〜数千件をまとめて処理できるため、通信回数とDB側の更新手続きコストが劇的に下がる。
- 適切なバッチサイズ(例: 500〜1000件)を設定する。大きすぎるとメモリを圧迫し、小さすぎると効果が薄れてしまう。
効果: 総クエリ数が 12,873回 → 1,279回 へ(累計約95.7%削減)🎉
Step 6: includesによる遅延ロードの防止
ループ処理内で関連モデルへのアクセスが発生する場合、includes を使って事前にデータをロード(イーガーロード)しておきます。
# ❌ Before: 遅延ロードで隠れN+1が発生
target_projects = Project.where(id: target_ids)
target_projects.find_each do |project|
# project.category にアクセスした瞬間にクエリが発行される(N回)
next unless project.category.outsource_type?
# さらにネストした関連へのアクセスでもクエリが発生(N回)
stat = project.client_company.contract_stats
end
# ✅ After: includesで関連データを事前ロード
# ネストした関連(client_companyとその先のcontract_stats)もまとめて指定可能
target_projects = Project.includes(:category, client_company: :contract_stats)
.where(id: target_ids)
target_projects.find_each do |project|
# すでにメモリ上にロード済みなので、DBクエリは発生しない
next unless project.category.outsource_type?
stat = project.client_company.contract_stats
end
ポイント:
-
includesを使うことで、メインのデータ取得時に、関連するデータもまとめて(JOINまたは別クエリで)取得する。 - データベースへの問い合わせ回数を減らす。使うとわかっているデータを一回の配送でもらっておいて、自分のメモリからの参照にする。
Step 7: メソッド内DBアクセスの排除
モデルのインスタンスメソッド内で「こっそり」DBアクセスが行われているパターン。
# Projectモデル
def outsource_type?
# ❌ メソッド内部で関連モデルをたどっており、呼び出すたびに最大2回のクエリが発生
category.cost_type.outsource?
end
# 呼び出し側
# N回のループで、最大 N * 2 回の隠れクエリが発生!
projects.each { |p| p.outsource_type? }
# ✅ After: 判定に必要な情報を事前にキャッシュし、メソッド呼び出しを回避
# アウトソース判定に必要なカテゴリコードのSetを事前に作成
outsource_category_codes = Category::OUTSOURCE_CODES.to_set
projects.find_each do |project|
# DBアクセスなしで、メモリ上のSetを使って判定
next unless outsource_category_codes.include?(project.category_code)
# ...
end
ポイント: ループ内でモデルのメソッドを呼ぶ際は、その内部実装がDBアクセスを引き起こしていないか必ず確認する。必要な値は可能な限りプリロードやキャッシュで対応する。
5. 改善効果のまとめ
段階的な改善の結果、最終的に処理時間とクエリ数は以下のように推移しました。
| Step | 改善内容 | 処理時間 | 総クエリ数 | クエリ削減率(累計) |
|---|---|---|---|---|
| 0 | ベースライン | 301.65秒 | 30,051 回 | - |
| 1 | プリロード | 285.42秒 | 21,825 回 | 27.4% |
| 2 | キャッシュ | 284.25秒 | 16,084 回 | 46.5% |
| 3 | プリロード | 279.88秒 | 12,873 回 | 57.2% |
| 4 | バルクUPSERT(activerecord-import) |
7.75秒 | 1,279 回 | 95.7% |
| 5 | UPDATEバルク化 | 6.74秒 | 359 回 | 98.8% |
| 6 |
includesによる遅延ロード防止 |
6.45秒 | 184 回 | 99.4% |
| 7 | メソッド内DBアクセスの排除 | 6.22秒 | 46 回 | 99.8% |
6. アンチパターンと解決策
パフォーマンス改善の要は、コードを見た瞬間に「物理的な違和感」を持てるかどうかだと痛感しました。 コードレビューの時、そして何より自分がコードを書く時にその「異変」に気づけるよう、Amazonの注文に例えた視点を常に意識するようにしていきます。
① トラックの台数を減らす(N+1問題)
- 事象: 繰り返し処理(ループ)の中で、無意識に都度データベースへの問い合わせ(クエリ)を発行している。
- メンタルモデル: 「今、ネジ100個を、100台のトラックで1個ずつ運ばせようとしていないか?」
- 解決策: 必要なデータを事前に一括で取得し、メモリ上で高速に検索できるデータ構造(「まとめ買いリスト」や「在庫確認ハガキ」のようなもの)を用意しておく。
② 段ボールのサイズを最適化する(メモリ効率と不要なオブジェクト生成)
- 事象: たった一つの値(IDやコードなど)が欲しいだけなのに、思考停止してデータベースから全てのカラムデータを取得し、重量なオブジェクトを大量に生成してしまう。
- メンタルモデル: 「ネジ1本が欲しいだけなのに、冷蔵庫用の巨大な段ボール(全データ入りの重いオブジェクト)で運ばせていないか?」
- 解決策: 必要なフィールドの値だけを部分的に取得する。必要なのは重厚なオブジェクトの群れではなく、中身の値が入ったただの軽量なデータ構造(配列など)なはず。「不要なオブジェクト生成は徹底的に避ける」 という意識を持つ。
③ フォークリフトで一括納品(バルク処理)
- 事象: ループの中で、1件ずつデータベースへの書き込み処理(保存や更新)を繰り返している。一番重い処理を、一番効率悪く行っている状態。
- メンタルモデル: 「1万枚の伝票を持って、1枚ずつ手書きで判子(受領印)をもらう行列に並ぼうとしていないか?」
- 解決策: 複数のデータをまとめて一括で書き込む機能を利用する。荷物をパレットに載せてフォークリフトで一気に搬入し、手続きを1回で済ませる。
自分の目で気づけるようになる
AIがコードを生成してくれるようになりましたが、ロジックの「正しさ」は保証してくれても、最も効率的な方法で実装してくれている保証はまだなさそうです。悪気なく「1万回トラックを呼ぶ」コードや「メモリをパンパンにする」コードを提案してくることだってあります。
こうした現実世界に引き寄せた感覚を自分の中に持っておくことで、AIの提案による「地獄のAmazon注文」を未然に防ぎ、自分自身の目でパフォーマンスを担保できるエンジニアでいたいと思います。
参考文献
