はじめに ─ 月次レポートの CSV ダウンロードがタイムアウトする
当社が運用する Rails アプリケーションの管理画面で、月次集計レポートを CSV でダウンロードする機能が、扱うデータ量の増加とともに段階的に遅くなっていました。直近では、2 ヶ月分の出力で 1 分以上、1 年分はそもそも完遂できずに HTTP タイムアウトするところまで来ていました。
管理担当者は「ボタンを押す → コーヒーを淹れて戻る → 真っ白なエラー画面」というルーチンを毎月繰り返している状態で、データ範囲を狭めて分割ダウンロードする運用回避でしのいでいました。
本記事は、この問題を 非同期ジョブ化(Solid Queue を gem 導入する案を検討、Sidekiq + Redis は追加サービスを増やしたくないため第一候補から外れていた)やバッチ事前生成といった「重い処理を逃がす」典型解を採用せず、CSV 書き出し処理そのものの構造を見直すことだけで解決した記録です。最終的に 2 ヶ月分は 60 秒 → 1.5 秒(40 倍速)、1 年分は タイムアウト → 20 秒で完遂まで来ています。
対象読者は、Rails 案件で同じように CSV / レポートのタイムアウトに遭遇しているエンジニア、および 「大改修を急ぐ前にもう一段見直す価値があるか」を判断したい技術リードです。実装の Before / After と計測値、そして「大改修を選ばなかった意思決定の物語」を、一次情報として開示します。
1. 最初に検討した「重い処理を逃がす」典型的な選択肢
「HTTP リクエストの中で 1 分以上かかる処理が走っている」状況に対する Rails 開発者の典型的な反射神経は、概ね次の 2 つに収れんします。本案件でも最初はこの 2 つを検討しました。
1-1. 案 A:バッチ処理化(レポートテーブル / 事前生成方式)
夜間バッチで CSV ファイル(または集計結果を持つレポートテーブル)を生成しておき、ダウンロード要求時にはそれを返すだけにする方式です。ダウンロード時の HTTP 処理は実質ファイル配信になるので、一瞬で完了します。
ただし本案件では、案 A を採用しにくい構造的事情がありました。もともとレポート画面もダウンロードも「同じクエリでオンザフライ生成」する設計で、レポートテーブルや事前生成ファイルは一切使っていません。月次レポートのダウンロードだけのために事前生成バッチを導入すると、月次レポートだけがバッチ経由・他のレポートはクエリ直叩き、という設計のチグハグさが生まれます。
さらにデイリーバッチで組む想定だったため、「先月分」を月初に出そうとするたびに最終日近辺のデータがバッチ生成時点までしか反映されない(=実質「昨日まで」しか出せない)鮮度問題も常時発生します。月次集計という性質上、月境界の数日のデータがズレるのは業務的に許容しにくい挙動です。
- メリット:HTTP タイムアウトから恒久的に解放、配信時の負荷が極小
- デメリット:既存システムの設計思想(クエリベース)とズレる、データ鮮度がデイリー単位に劣化
- 実装工数:数日〜1 週間(一度きり)
- 管理工数:案件の寿命まで永続 ── 夜間バッチの監視・失敗リカバリ、レポートテーブルのライフサイクル管理
1-2. 案 B:非同期ジョブ化(Solid Queue を gem 導入する想定)
ダウンロードボタンを押すと非同期ジョブが起動し、完了後にメール通知や S3 への配置でユーザに渡す方式です。HTTP タイムアウトから完全に切り離せます。
典型構成は Sidekiq + Redis ですが、本案件ではこれは選びませんでした。理由は Redis を新たに運用に追加すると、管理するサービスが 1 つ増えるためです。代わりに第一候補としていたのは Solid Queue を gem として導入する方式でした(Solid Queue は Rails 8 から標準採用された ActiveJob バックエンドで、本案件の Rails 7.x 環境では gem として導入できる。データベース駆動なので Redis 不要)。
ただし Solid Queue でも、非同期ジョブ化に伴う設計変更そのものは Sidekiq と同等に必要です。
- メリット:HTTP タイムアウトから解放、並行処理が可能、Solid Queue なら Redis 等の追加サービス不要
- デメリット:ワーカープロセスの常駐、ジョブ失敗時のリトライ設計、完了通知の仕組み、フロントエンドの変更
- 実装工数:数日〜1 週間(Sidekiq + Redis よりは軽い)
- 管理工数:案件の寿命まで永続 ── プロセス監視、ジョブテーブルのライフサイクル管理、Rails / gem のメジャー版更新時の互換性確認
1-3. 両方とも「煩雑さがリターンに見合うか」が問題
両方とも、実装工数よりも、その後 案件の寿命まで乗り続ける「管理工数」(バッチ監視、ワーカー常駐、ジョブテーブルの面倒見、Redis 保守 等)を積み増します。本質的な解決にはなっていないのに、永続コストだけは積み増す、という構造に違和感がありました。
こういう違和感を覚えた時は、一旦保留にして、処理の構造を改めて見直す時間を取ることにしています。大改修を急いで決めると、後から「もっと小さな変更で済んだのに」と気付くことが多いためです。
2. 一旦保留にして、構造を改めて調べる
大改修を保留にして、まず「本当に何が遅いのか」を切り分けることにしました。
2-1. 計測で分かったこと ── 遅さの中心は書き出し処理だった
ダウンロード処理を「クエリ単独で何秒」「CSV 書き出し単独で何秒」に分解して計測すると、意外な結果が出ました。1 年分のクエリ抽出ですら 19 秒程度で返ってきます。集計クエリには事前にインデックスが効いていて、SQL 自体はすでに十分速い状態だったということです。

Query_time: 19.318174 および 19.692834。クエリ単独では 19 秒台で返ってきており、SQL は既に十分速い状態だったことが分かる。ところが既存実装は 1 年分でタイムアウト(=少なくとも 60 秒以上)していたので、遅さの大半は CSV 書き出し処理側に乗っていたことになります。データ量が増えるほど書き出し側の処理時間が線形を大きく超えて伸びていく構造で、これは非同期ジョブに逃がしても根本は変わらない問題です(逃がした先で同じだけ時間がかかる)。
ここで「非同期化したところで根本のボトルネックは解消されない、書き出しそのものを直す方が筋が良い」という方針に切り替わりました。
2-2. CSV 書き出しの構造 ── なぜ非線形に遅くなるか
既存の実装はおおむね次のような構造でした(汎用化、本案件では rows は上流で集計済みの行データ配列):
def export
rows = @report_data[:rows] # 上流で構築済みの集計データ配列
csv_data = CSV.generate do |csv|
csv << headers
rows.each do |row|
csv << build_row(row)
end
end
send_data csv_data, filename: "export.csv"
endこの実装には、データ量に対して非線形に劣化する要因が 2 つ潜んでいました。
CSV.generateブロック内のcsv << rowによる内部文字列結合 ── 内部バッファが伸びるたびにメモリ再確保とコピーが発生しやすく、Ruby のString結合の素朴な書き方は累積長 N に対して O(n²) 的に劣化することが知られているsend_dataによる全体一括送信 ──send_dataは引数で受け取った CSV 文字列全体を Rack でまるごとレスポンスバディに載せる。N に比例する CSV 全体をメモリ上に保持してから一気に流すため、メモリピークが伸び、最初のバイトが返るまでの TTFB も悪化する
「CSV 全体の文字列構築」「全体を一括送信」がレスポンス 1 回分のメモリ・処理時間として直列に積み上がる構造になっていました。
これが「2 ヶ月分で 60 秒、1 年分でタイムアウト」の正体です。データを 6 倍にしたとき、処理時間が 6 倍では収まらなくなる構造的な理由がここにあります。
3. 解決策の実装 ─ Enumerator + response_body によるストリーミング
§2 で見えたボトルネックに対して、CSV を 1 行ずつストリーミング送出するだけの変更を入れます。Solid Queue や Sidekiq、Redis などの追加サービス・追加 gem は一切入れず、コントローラ(と再利用のための concern)の中で完結する変更です。
3-1. Before / After のコード
実コードを汎用化して示します。
Before(改修前):
def export
rows = @report_data[:rows]
csv_data = CSV.generate do |csv|
csv << headers
rows.each do |row|
csv << build_row(row)
end
end
send_data csv_data, filename: "export.csv"
endAfter(改修後):
def export
stream_csv_response(
"export.csv",
build_csv_enumerator(@report_data[:rows])
)
end
private
# 複数のコントローラで使う場合は concern に切り出す
def stream_csv_response(filename, enumerator)
response.headers["Content-Type"] = "text/csv; charset=UTF-8"
response.headers["Content-Disposition"] = %(attachment; filename="#{filename}")
response.headers["Cache-Control"] = "no-cache"
response.headers["X-Accel-Buffering"] = "no" # nginx 経由ならバッファリング無効化が必要
response.headers.delete("Content-Length") # ストリーミング時はサイズ未確定
self.response_body = enumerator
end
def build_csv_enumerator(rows)
Enumerator.new do |yielder|
yielder << CSV.generate_line(headers)
rows.each do |row|
yielder << CSV.generate_line(build_row(row))
end
end
end3-2. 各変更が何をしているか
変更を 「性能改善の本体」「ストリーミングを正しく動かすための前提」「再利用性のための整理」 の 3 グループに分けて見ると、ポイントが整理しやすくなります。
性能改善の本体 ── この 2 つが核心
self.response_body = Enumerator.new {|yielder| ... }── Rails のストリーミング配信の仕組み。Enumerator がyielder << rowで行を yield するたびに、その時点までの内容が Rack 経由で HTTP レスポンスに流れる。クライアントは最初のバイトを早く受け取れる(TTFB 短縮)、Rails 側は CSV 全体をメモリに溜め込まずに済むCSV.generate_line── 1 行分だけ CSV エスケープして文字列を返すユーティリティ。CSV.generateブロックの内部バッファ再確保問題(O(n²) 的に劣化)を避けて、1 行ずつ独立した短い文字列として扱える
ストリーミングを正しく動かすための前提
X-Accel-Buffering: no── nginx などのリバースプロキシがレスポンスをバッファリングしないように指示。これが無いと、Rails 側でいくら yield してもクライアントには一気にしか届かない。Cloudflare などの CDN 経由でも同じ目的で使うContent-Lengthを削除 ── ストリーミングではレスポンス全体のサイズが事前に分からないので、Content-Length を出してはいけない。Rails のミドルウェアが自動で付けてしまう場合があるので明示削除
再利用性のための整理(性能とは別の判断)
- concern に切り出す ── 本案件は複数のコントローラ(管理画面 / 取引先画面)で同種の CSV 出力があったため、
stream_csv_responseとbuild_csv_enumeratorをEmissionCsvStreaming相当の concern に分離し、両方の controller でincludeする形にしました
なお本案件では rows は上流で集計済みの行データなので find_each は使っていません。raw レコードを直接 stream するケース(数十万件以上の取引履歴 CSV など)では、Model.find_each(batch_size: 1000) 等でバッチ取得しつつ yield する形に拡張する余地があります。
3-3. 実装時間 ── 数時間で完了
コードの変更行数としては実質 10〜20 行で、レビュー込みで 数時間で完了しました。バッチ化(数日〜1 週間)や非同期ジョブ化(Solid Queue / Sidekiq 導入で数日〜2 週間)に対して、桁違いに小さい変更で済んでいます。
4. 計測結果 ─ 2 ヶ月分 40 倍速、1 年分も処理可能に
改修前後を、同じ環境・同じデータ範囲で計測しました。
4-1. データ量別の処理時間
| データ範囲 | Before | After | 改善率 |
|---|---|---|---|
| 2 ヶ月分 | 60 秒 | 1.5 秒 | 約 40 倍速(-97.5%) |
| 1 年分(行数 6 倍) | タイムアウト | 20 秒 | 完遂可能化 |
実測の画面(Chrome DevTools Network タブ)を 3 枚並べておきます。Waiting for server response の値が今回の主指標です。



4-2. 内訳分析 ── 書き出しコストがほぼゼロまで圧縮された
改修後の 1 年分 20 秒の内訳を見ると、構造の変化が明確になります。§2-1 で計測したとおり 1 年分のクエリ単独は約 19 秒なので、改修後の合計 20 秒のうち、書き出し処理が占めているのは残り約 1 秒 ということです。
| 工程 | 改修前(1 年分) | 改修後(1 年分) |
|---|---|---|
| クエリ実行 | 19 秒 | 19 秒(変わらず) |
| CSV 書き出し | 40 秒以上(タイムアウト) | 約 1 秒(ほぼゼロ化) |
| 合計 | タイムアウト | 20 秒 |
書き出しコストが 40 倍以上に圧縮された結果、処理時間は事実上 クエリ実行時間に律速される構造 になりました。データ量が増えてもクエリの線形伸びがそのまま全体の伸びになる(=ほぼ O(n) にスケールする)状態で、改修前のように書き出し側が非線形に爆発する余地はもう残っていません。これは非同期ジョブに逃がしても得られない、構造的な改善です。
5. 結果の正確性を必ず確認する
パフォーマンス改善で最も注意すべきは 「速くなったが結果が変わっていないか」です。本案件では Before / After の両実装で 2 ヶ月分の CSV を出力し、diff で 完全一致(レコード並び順、カラム、改行コード、文字エンコーディングを含む)を確認しました。
特にレポート系は速度より正確性が優先される領域です。「結果が変わっていない」確認をチェックリストに入れる、というだけの作法ですが、これを抜くと集計値が静かにズレる事故を起こしかねない領域でもあります。
6. 教訓 ─ 「逃がす」前に「処理そのものを見直す」、ただし条件付き
6-1. 反射神経で「逃がす」を選ぶ前に、計測でボトルネックを特定する
「処理が遅い → 非同期化」「クエリが重い → SQL を直す」という反射神経自体は健全ですが、その前に 計測で本当のボトルネックを切り分ける工程を省くと、間違った場所を直すリスクがあります。本案件は SQL ではなく書き出し処理がボトルネックだったので、非同期ジョブに逃がしても同じ時間がかかっていたはずです。経験年数に関係なく、「計測で確かめる」工程の省略は判断の質を下げます。
6-2. 大改修の前に「構造を見直す」フェーズを必ず挟む
「違和感」を覚えた時に大改修を即決せず、1〜2 日の保留期間を置いて構造を読み直す習慣を組み込むだけで、本案件のような「小さな変更で済んだ」ケースを拾えます。30 分の計測で数日〜数週間の大改修を回避できる可能性があるなら、計測の方が投資効果が高い。バッチ化や非同期化も実装工数は一度で済みますが、その後 案件の寿命まで乗り続ける「管理工数」(プロセス監視、ジョブテーブルの面倒見、バッチ実行枠と失敗リカバリ 等)を積み増します。当初の目的(ダウンロード機能の高速化)に釣り合っているか、毎回検討する価値があります。
6-3. それでも「逃がす」が正解になる条件
本案件では「逃がす」を採用しませんでしたが、条件が揃えば即採用する解です。別案件ではこれらが第一選択になるケースもあります。
バッチ化が正解になる条件
- 処理時間が改善後でも分オーダー(数分以上)を要する
- データの鮮度がリアルタイムでなくて良い(前日時点で十分、等)
- データ規模が極端に大きく、同期処理ではどう書き換えても収まらない
- 同じ集計結果を複数ユーザが共有する(=事前生成 1 回で全員に配信できる)
非同期ジョブ化が正解になる条件
- 処理時間が改善後でも 10 分以上かかる(同期 HTTP で耐えられない)
- 同じシステムに他のバックグラウンド処理が既にあり、ワーカープロセスが既に常駐している(Solid Queue / Sidekiq 等)
- 「処理完了の通知」の仕組み(メール、Slack、SSE 等)が既にあり、追加コストが低い
- 処理失敗時のリトライを安全に走らせる必要がある(冪等性の設計が前提)
本案件は「2 ヶ月分が 1.5 秒、1 年分でも 20 秒」で同期 HTTP の範囲内に収まったため、非同期化や事前生成バッチを入れる利点よりも、運用要素を増やさないメリットの方が大きいと判断しました。今後データ量が増えて 30 秒〜1 分のオーダーに戻ってきたら、その時点で改めて非同期化を検討する想定です。
あとがき
本記事は、Rails 案件で起きた CSV ダウンロードのタイムアウトを、大改修を選ばずに Enumerator + response_body ストリーミングへの書き換えで解決した記録です。非同期ジョブ化(Solid Queue / Sidekiq)もバッチ化も悪い選択肢ではありませんが、それらに踏み切る前に 「処理そのものの構造を見直す 1 日」 を挟むだけで、規模の大きい変更を回避できることがあります。同じ問題を踏んでいる Rails エンジニアの判断材料になれば幸いです。
MOOBON では、Rails / Web アプリケーション開発の受託、既存案件のパフォーマンス改善・リファクタリング、技術判断のセカンドオピニオン を承っています。「大改修を急ぐ前にもう一段見直すべきか」「Solid Queue / Sidekiq 化を検討中だが妥当か」といった判断のご相談も歓迎です。info@moobon.jp までお気軽にどうぞ。
よくある質問
Qなぜ非同期ジョブ化(Solid Queue / Sidekiq)を採用しなかったのですか?
本案件で第一候補としていたのは Solid Queue(Rails 8 から標準採用、Rails 7 では gem 導入)でした。Sidekiq + Redis は「Redis 等の追加サービスを増やしたくない」理由で外しています。ただし Solid Queue でも、実装工数(数日〜1 週間)に加えて、案件の寿命まで乗り続ける管理工数(ワーカー常駐とプロセス監視、ジョブテーブルの面倒見、Rails / gem 更新時の互換性確認)が積み増します。書き出し処理を見直せば 1.5 秒で済むことが計測で分かったので、永続コストを背負う前に同期のまま解決しました。非同期ジョブ化が正解になるのは「処理時間が分オーダー」「他にもバックグラウンド処理が必要」「通知の仕組みが既にある」といった条件が揃っている場合です。
Qrows が大きすぎてメモリに乗らないケース(数十万行〜)では?
本案件では rows は集計済みの行データで件数が限定的だったため、配列のまま全行をメモリに保持して 1 行ずつ stream できました。raw レコードを直接 stream するケース(例えば数十万件以上の取引履歴 CSV など)では、Model.find_each(batch_size: 1000) 等でバッチ取得しつつ、取り出したレコードを yield する形に拡張します。Ruby 側で同時に保持するレコード数を batch_size に抑えながらストリーミングを成立させる構造です。本案件の規模では find_each 自体は不要でしたが、データ量が伸びれば検討対象になります。
Qストリーミング配信(response_body = Enumerator)の注意点は?
前段に nginx などのリバースプロキシがある場合、デフォルトでは proxy_buffering が有効でレスポンスをバッファリングするため、せっかくのストリーミングが効きません。本案件では Rails 側のレスポンスヘッダに <code>X-Accel-Buffering: no</code> を付与して nginx のバッファリングを切る対応を入れています。また、ストリーミング中にエラーが発生した場合、すでに送出済みのバイトは取り戻せないので、出力の途中で例外が起きた場合の挙動(中途半端な CSV が降ってくる)も設計時に意識する必要があります。本案件では事前に rows が構築できることを確認した上で書き出し処理に入る前提なので、実害は出ていません。
Qデータがさらに増えた場合はどう対応しますか?
現状の改善後の実装は データ量と処理時間がほぼ線形(O(n))にスケールしているので、本案件のユースケース(月次レポート、最大で年単位)では当面この設計で対応できる見込みです。仮に 3 年分・5 年分と更に増えて 1 分を超えるようになってきた場合は、その時点で初めて非同期ジョブ化(Solid Queue / Sidekiq)やバッチ事前生成(夜間バッチでファイルを作っておく)を検討します。「いつ非同期化するか」の閾値は、本案件では「同期 HTTP リクエストで 30 秒を超えた時点」を目安に置いています。
