本番環境で数万件のCSVエクスポートが止まってしまう問題を解決。メモリリーク対策からストリーミング処理まで、実案件で効果のあった改善手法を詳しく解説します。
こんな悩みありませんか?
「管理画面でCSVエクスポートボタンを押したのに、数千件まででデータが止まってしまう...」 「本番環境では動かないのに、開発環境では正常に出力できる」 「大容量データの処理でサーバーが重くなって他の処理に影響が出る」
このような問題は、Laravelで大容量データを扱うWebアプリケーションでよく発生します。特に売上データや顧客情報、商品マスタなど、数万件を超えるデータのCSV出力で頭を悩ませている開発者の方は多いのではないでしょうか。
本番環境でのメモリ制限や実行時間制限により、開発時には気づかない問題が顕在化することがあります。今回は、実際のプロジェクトで発生したこれらの問題を解決した実践的な改善手法をご紹介します。
大容量CSV出力で発生する典型的な問題
メモリ不足による処理停止
あるECサイトのプロジェクトで、2万件の商品データをCSV出力する機能を実装した際に遭遇した問題です。開発環境では問題なく動作していたのですが、本番環境では1万件程度で処理が止まってしまいました。
原因を調査したところ、以下のような非効率なコードが問題でした:
// ❌ 悪い例:全データを一度にメモリに読み込む
public function exportCsv()
{
$products = Product::all(); // 全データを一度に取得
$csvData = [];
foreach ($products as $product) {
$csvData[] = [
$product->id,
$product->name,
$product->price,
// ... その他のフィールド
];
}
// CSV生成処理
return response()->stream(/* ... */);
}
このコードの問題点は、Product::all()で全てのレコードを一度にメモリに読み込んでしまうことです。2万件のデータが各レコード10個のフィールドを持つ場合、PHPのメモリ使用量は簡単に128MBの制限を超えてしまいます。
実行時間制限による処理中断
もう一つの問題は、処理時間の長さです。多くのレンタルサーバーやクラウドサービスでは、HTTPリクエストの実行時間に制限があります。通常は30秒から300秒程度ですが、大容量データの処理では簡単にこの制限を超えてしまいます。
実践的な改善手法
1. チャンク処理によるメモリ効率化
最も効果的な改善策は、データを小さな塊(チャンク)に分けて処理することです。Laravelのchunk()メソッドを使用することで、メモリ使用量を大幅に削減できます。
// ✅ 良い例:チャンク処理でメモリ効率化
public function exportCsv()
{
$filename = 'products_' . date('Y-m-d_H-i-s') . '.csv';
return response()->stream(function () {
$handle = fopen('php://output', 'w');
// BOMを追加(Excelで文字化けを防ぐ)
fwrite($handle, "\xEF\xBB\xBF");
// ヘッダー行を出力
fputcsv($handle, ['ID', '商品名', '価格', '在庫数', '登録日']);
// チャンク処理で分割して取得
Product::chunk(1000, function ($products) use ($handle) {
foreach ($products as $product) {
fputcsv($handle, [
$product->id,
$product->name,
$product->price,
$product->stock_quantity,
$product->created_at->format('Y-m-d H:i:s'),
]);
}
// メモリを明示的に解放
unset($products);
});
fclose($handle);
}, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => "attachment; filename={$filename}",
]);
}
この改善により、メモリ使用量は一定に保たれ、データ量に関係なく安定して処理できるようになります。
2. ストリーミング処理の導入
大容量データの場合、レスポンスのストリーミング処理を適切に実装することが重要です。上記のコードではresponse()->stream()を使用していますが、さらに細かい制御が必要な場合は、以下のようなアプローチも有効です:
public function exportLargeCsv(Request $request)
{
// 処理開始時刻を記録
$startTime = time();
$maxExecutionTime = 270; // 4分30秒でタイムアウト
$filename = 'large_export_' . date('Y-m-d_H-i-s') . '.csv';
return response()->stream(function () use ($startTime, $maxExecutionTime) {
$handle = fopen('php://output', 'w');
fwrite($handle, "\xEF\xBB\xBF");
// ヘッダー出力
fputcsv($handle, $this->getCsvHeaders());
$offset = 0;
$chunkSize = 1000;
do {
// 時間制限チェック
if (time() - $startTime > $maxExecutionTime) {
fputcsv($handle, ['処理時間制限のため中断されました']);
break;
}
$products = Product::offset($offset)
->limit($chunkSize)
->get();
if ($products->isEmpty()) {
break;
}
foreach ($products as $product) {
fputcsv($handle, $this->formatProductForCsv($product));
}
$offset += $chunkSize;
// バッファをフラッシュ
if (ob_get_level()) {
ob_flush();
}
flush();
// メモリクリア
unset($products);
} while (true);
fclose($handle);
}, 200, [
'Content-Type' => 'text/csv; charset=utf-8',
'Content-Disposition' => "attachment; filename={$filename}",
'Cache-Control' => 'no-cache, no-store, must-revalidate',
]);
}
private function getCsvHeaders(): array
{
return ['ID', '商品名', '価格', '在庫数', 'カテゴリ', '登録日'];
}
private function formatProductForCsv($product): array
{
return [
$product->id,
$product->name,
number_format($product->price),
$product->stock_quantity,
$product->category->name ?? '',
$product->created_at->format('Y-m-d H:i:s'),
];
}
3. 非同期処理とキューの活用
特に大容量データの場合、ユーザーを待たせることなく処理を行うため、キューを使った非同期処理が効果的です。
// Jobクラスの作成
php artisan make:job ExportLargeCsvJob
// app/Jobs/ExportLargeCsvJob.php
class ExportLargeCsvJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $userId;
protected $filters;
public function __construct($userId, array $filters = [])
{
$this->userId = $userId;
$this->filters = $filters;
}
public function handle()
{
$filename = 'export_' . $this->userId . '_' . date('Y-m-d_H-i-s') . '.csv';
$filepath = storage_path('app/exports/' . $filename);
// ディレクトリが存在しない場合は作成
if (!file_exists(dirname($filepath))) {
mkdir(dirname($filepath), 0755, true);
}
$handle = fopen($filepath, 'w');
fwrite($handle, "\xEF\xBB\xBF");
fputcsv($handle, ['ID', '商品名', '価格', '在庫数', '登録日']);
$query = Product::query();
// フィルター適用
if (!empty($this->filters['category_id'])) {
$query->where('category_id', $this->filters['category_id']);
}
if (!empty($this->filters['date_from'])) {
$query->where('created_at', '>=', $this->filters['date_from']);
}
// チャンク処理でCSV出力
$query->chunk(2000, function ($products) use ($handle) {
foreach ($products as $product) {
fputcsv($handle, [
$product->id,
$product->name,
$product->price,
$product->stock_quantity,
$product->created_at->format('Y-m-d H:i:s'),
]);
}
});
fclose($handle);
// 完了通知(メールやSlackなど)
$user = User::find($this->userId);
Mail::to($user)->send(new CsvExportCompletedMail($filename));
}
}
コントローラーでは以下のようにキューにジョブを投入します:
public function requestExport(Request $request)
{
$filters = $request->only(['category_id', 'date_from', 'date_to']);
ExportLargeCsvJob::dispatch(auth()->id(), $filters);
return response()->json([
'message' => 'エクスポート処理を開始しました。完了時にメールでお知らせします。'
]);
}
よくある失敗パターンと対処法
失敗パターン1: メモリリークの見落とし
問題: チャンク処理を実装しても、関連データの取得でメモリリークが発生
// ❌ 悪い例:N+1問題とメモリリーク
Product::chunk(1000, function ($products) use ($handle) {
foreach ($products as $product) {
// 毎回SQLが実行される(N+1問題)
$category = $product->category; // ここでメモリリーク
fputcsv($handle, [$product->name, $category->name]);
}
});
対処法: 必要な関連データを事前に読み込む
// ✅ 良い例:Eager Loadingでメモリ効率化
Product::with('category')->chunk(1000, function ($products) use ($handle) {
foreach ($products as $product) {
fputcsv($handle, [
$product->name,
$product->category->name ?? 'カテゴリなし'
]);
}
// 明示的にメモリ解放
unset($products);
});
失敗パターン2: 文字エンコーディングの問題
問題: 日本語が文字化けする、Excelで正しく表示されない
対処法:
public function exportCsvForExcel()
{
return response()->stream(function () {
$handle = fopen('php://output', 'w');
// UTF-8 BOM を追加(Excelの文字化け対策)
fwrite($handle, "\xEF\xBB\xBF");
// または Shift_JIS で出力(古いExcel対応)
// fwrite($handle, mb_convert_encoding($csvData, 'SJIS-win', 'UTF-8'));
// ヘッダー出力
fputcsv($handle, ['商品ID', '商品名', '価格']);
Product::chunk(1000, function ($products) use ($handle) {
foreach ($products as $product) {
fputcsv($handle, [
$product->id,
$product->name,
number_format($product->price) // 数値フォーマット
]);
}
});
fclose($handle);
}, 200, [
'Content-Type' => 'text/csv; charset=utf-8',
'Content-Disposition' => 'attachment; filename="products.csv"',
]);
}
失敗パターン3: サーバーリソースの考慮不足
実案件では、CSV出力処理中にサーバーの他の処理が遅くなる問題が発生することがあります。これは、以下の要因が考えられます:
対処法: 処理の優先度調整と負荷分散
// config/queue.php で処理優先度を設定
'connections' => [
'database' => [
'driver' => 'database',
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 90,
],
'export' => [
'driver' => 'database',
'table' => 'jobs',
'queue' => 'export', // 専用キューで分離
'retry_after' => 600, // 長時間処理用
],
];
ジョブ実行時:
// 通常優先度でキューに投入
ExportLargeCsvJob::dispatch($userId, $filters)->onQueue('export');
// または低優先度で実行
ExportLargeCsvJob::dispatch($userId, $filters)
->onQueue('low-priority')
->delay(now()->addMinutes(5)); // 5分後に実行
性能向上のための追加テクニック
データベースクエリの最適化
大容量データ処理では、SQLクエリの最適化が重要です:
// インデックスを活用したクエリ
Product::select(['id', 'name', 'price', 'stock_quantity', 'created_at'])
->where('status', 'active') // インデックスが設定されたカラム
->orderBy('id') // プライマリーキーでソート(効率的)
->chunk(1000, function ($products) {
// 処理
});
バッファリングの制御
public function exportWithBuffering()
{
return response()->stream(function () {
// 出力バッファリングを無効化
if (ob_get_level()) {
ob_end_clean();
}
$handle = fopen('php://output', 'w');
Product::chunk(1000, function ($products) use ($handle) {
foreach ($products as $product) {
fputcsv($handle, [$product->id, $product->name]);
}
// 定期的にフラッシュ
fflush($handle);
});
fclose($handle);
});
}
この技術、御社の業務にも活かせます
業務システム開発
Laravelを使った業務システムの構築・改修を20年以上の経験でサポートします
※ 通常1営業日以内にご返信します
まとめと次のステップ
大容量データのCSV出力問題は、適切な手法を用いることで確実に解決できます。今回ご紹介した改善手法により、あるクライアントでは50万件のデータ処理が10分程度で安定して完了するようになり、「データ分析の効率が大幅に向上した」とご評価いただきました。
重要なポイントは以下の通りです:
- メモリ効率: チャンク処理で一度に読み込むデータ量を制限
- 時間管理: ストリーミング処理とタイムアウト対策
- 非同期化: キューを活用したバックグラウンド処理
- エラーハンドリング: 文字化けや処理中断への対策
実装の際は、まず現在のサーバー環境(メモリ制限、実行時間制限)を確認し、データ量に応じて適切な手法を選択することが大切です。