laravel:data-chunking-large-datasets について
大きなクエリ結果を小分けに処理するLaravel向けスキルです。Eloquentのチャンク処理や反復メソッドを取り上げ、メモリ使用、更新時の注意点、よくある問題を説明します。
主な機能
- 01chunk()、chunkById()、lazy()、cursor()の解説
- 02メモリ使用を抑えたバッチ処理
- 03エクスポート、メール配信、削除の例
- 04クエリとインデックスの最適化のヒント
- 05チャンク処理のテスト
活用シーン
- 01大規模データセットのCSV出力
- 02多数の購読者へのメール配信
- 03レコードのバッチ移行や変換
- 04古いデータベースレコードの整理
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ jpcaparas/ superpowers-laravel --skill laravel:data-chunking-large-datasetsインストール後、Claude Code で/laravel:data-chunking-large-datasetsを入力して読み込まれたか確認します。
詳細
大規模データセットのチャンク処理
大規模なデータセットを管理しやすいチャンクに分割して、メモリ消費を抑え、パフォーマンスを向上させながら効率的に処理します。
問題:メモリの枯渇
// BAD: Loading all records into memory
$users = User::all(); // Could be millions of records!
foreach ($users as $user) {
$user->sendNewsletter();
}
// BAD: Even with select, still loads everything
$emails = User::pluck('email'); // Array of millions of emails
foreach ($emails as $email) {
Mail::to($email)->send(new Newsletter());
}
解決策:チャンク処理の方法
1. chunk() を使った基本的なチャンク処理
// Process 100 records at a time
User::chunk(100, function ($users) {
foreach ($users as $user) {
$user->calculateStatistics();
$user->save();
}
});
// With conditions
User::where('active', true)
->chunk(200, function ($users) {
foreach ($users as $user) {
ProcessUserJob::dispatch($user);
}
});
2. より安全な更新のための ID によるチャンク処理
// Prevents issues when modifying records during iteration
User::where('newsletter_sent', false)
->chunkById(100, function ($users) {
foreach ($users as $user) {
$user->update(['newsletter_sent' => true]);
Mail::to($user)->send(new Newsletter());
}
});
// With custom column
Payment::where('processed', false)
->chunkById(100, function ($payments) {
foreach ($payments as $payment) {
$payment->process();
}
}, 'payment_id'); // Custom ID column
3. メモリ効率を高める遅延コレクション
// Uses PHP generators, minimal memory footprint
User::where('created_at', '>=', now()->subDays(30))
->lazy()
->each(function ($user) {
$user->recalculateScore();
});
// With chunking size control
User::lazy(100)->each(function ($user) {
ProcessRecentUser::dispatch($user);
});
// Filter and map with lazy collections
$results = User::lazy()
->filter(fn($user) => $user->hasActiveSubscription())
->map(fn($user) => [
'id' => $user->id,
'revenue' => $user->calculateRevenue(),
])
->take(1000);
4. 前方向のみの反復処理に使うカーソル
// Most memory-efficient for simple forward iteration
foreach (User::where('active', true)->cursor() as $user) {
$user->updateLastSeen();
}
// With lazy() for additional collection methods
User::where('verified', true)
->cursor()
->filter(fn($user) => $user->hasCompletedProfile())
->each(fn($user) => SendWelcomeEmail::dispatch($user));
実際の使用例
大規模な CSV のエクスポート
class ExportUsersCommand extends Command
{
public function handle()
{
$file = fopen('users.csv', 'w');
// Write headers
fputcsv($file, ['ID', 'Name', 'Email', 'Created At']);
// Process in chunks to avoid memory issues
User::select('id', 'name', 'email', 'created_at')
->chunkById(500, function ($users) use ($file) {
foreach ($users as $user) {
fputcsv($file, [
$user->id,
$user->name,
$user->email,
$user->created_at->toDateTimeString(),
]);
}
// Optional: Show progress
$this->info("Processed up to ID: {$users->last()->id}");
});
fclose($file);
$this->info('Export completed!');
}
}
一括メールキャンペーン
class SendCampaignJob implements ShouldQueue
{
public function handle()
{
$campaign = Campaign::find($this->campaignId);
// Process subscribers in chunks
$campaign->subscribers()
->where('unsubscribed', false)
->chunkById(50, function ($subscribers) use ($campaign) {
foreach ($subscribers as $subscriber) {
SendCampaignEmail::dispatch($campaign, $subscriber)
->onQueue('emails')
->delay(now()->addSeconds(rand(1, 10)));
}
// Prevent rate limiting
sleep(2);
});
}
}
データの移行/変換
class MigrateUserData extends Command
{
public function handle()
{
$bar = $this->output->createProgressBar(User::count());
User::with(['profile', 'settings'])
->chunkById(100, function ($users) use ($bar) {
DB::transaction(function () use ($users, $bar) {
foreach ($users as $user) {
// Complex transformation
$newData = $this->transformUserData($user);
NewUserModel::create($newData);
$bar->advance();
}
});
});
$bar->finish();
$this->newLine();
$this->info('Migration completed!');
}
}
古いレコードのクリーンアップ
class CleanupOldLogs extends Command
{
public function handle()
{
$deletedCount = 0;
ActivityLog::where('created_at', '<', now()->subMonths(6))
->chunkById(1000, function ($logs) use (&$deletedCount) {
$ids = $logs->pluck('id')->toArray();
// Batch delete for efficiency
ActivityLog::whereIn('id', $ids)->delete();
$deletedCount += count($ids);
$this->info("Deleted {$deletedCount} records so far...");
// Give database a breather
usleep(100000); // 100ms
});
$this->info("Total deleted: {$deletedCount}");
}
}
適切な方法の選び方
| 方法 | ユースケース | メモリ使用量 | 備考 |
|---|---|---|---|
chunk() | 一般的な処理 | 中程度 | フィルター対象の列を変更すると、レコードがスキップまたは重複する可能性があります |
chunkById() | 反復処理中の更新 | 中程度 | 変更を伴う処理により安全です |
lazy() | 大規模な結果の処理 | 低い | LazyCollection を返します |
cursor() | 単純な前方向の反復処理 | 最低 | Generator を返します |
each() | 単純な操作 | 高い(すべてを読み込みます) | 大規模なデータセットでは避けてください |
パフォーマンス最適化のヒント
1. 必要な列だけを選択する
User::select('id', 'email', 'name')
->chunkById(100, function ($users) {
// Process with minimal data
});
2. インデックスを使う
// Ensure indexed columns in where clauses
User::where('status', 'active') // status should be indexed
->where('created_at', '>', $date) // created_at should be indexed
->chunkById(200, function ($users) {
// Process efficiently
});
3. 適切な場合は Eloquent イベントを無効にする
User::withoutEvents(function () {
User::chunkById(500, function ($users) {
foreach ($users as $user) {
$user->update(['processed' => true]);
}
});
});
4. 一括更新には生のクエリを使う
// Instead of updating each record
User::chunkById(100, function ($users) {
$ids = $users->pluck('id')->toArray();
// Bulk update with raw query
DB::table('users')
->whereIn('id', $ids)
->update([
'last_processed_at' => now(),
'processing_count' => DB::raw('processing_count + 1'),
]);
});
5. 大規模な処理をキューに登録する
class ProcessLargeDataset extends Command
{
public function handle()
{
User::chunkById(100, function ($users) {
ProcessUserBatch::dispatch($users->pluck('id'))
->onQueue('heavy-processing');
});
}
}
class ProcessUserBatch implements ShouldQueue
{
public function __construct(
public Collection $userIds
) {}
public function handle()
{
User::whereIn('id', $this->userIds)
->get()
->each(fn($user) => $user->process());
}
}
チャンク処理のテスト
test('processes all active users in chunks', function () {
// Create test data
User::factory()->count(150)->create(['active' => true]);
User::factory()->count(50)->create(['active' => false]);
$processed = [];
User::where('active', true)
->chunkById(50, function ($users) use (&$processed) {
foreach ($users as $user) {
$processed[] = $user->id;
}
});
expect($processed)->toHaveCount(150);
expect(count(array_unique($processed)))->toBe(150);
});
test('handles empty datasets gracefully', function () {
$callCount = 0;
User::where('id', '<', 0) // No results
->chunk(100, function ($users) use (&$callCount) {
$callCount++;
});
expect($callCount)->toBe(0);
});
よくある落とし穴
-
chunk() の実行中にフィルター対象の列を変更する
// WRONG: May skip records User::where('processed', false) ->chunk(100, function ($users) { foreach ($users as $user) { $user->update(['processed' => true]); // Changes the WHERE condition! } }); // CORRECT: Use chunkById() User::where('processed', false) ->chunkById(100, function ($users) { foreach ($users as $user) { $user->update(['processed' => true]); } }); -
チャンクのコールバックの戻り値を処理しない
// Return false to stop chunking User::chunk(100, function ($users) { foreach ($users as $user) { if ($user->hasIssue()) { return false; // Stop processing } $user->process(); } }); -
データベース接続の制限を無視する
// Consider connection timeouts for long operations DB::connection()->getPdo()->setAttribute(PDO::ATTR_TIMEOUT, 3600); User::chunkById(100, function ($users) { // Long running process });
大規模なデータセットを扱う際は、メモリ使用量、クエリ効率、処理時間を常に考慮してください。データを適切にチャンク分割しましょう!
コメント
Markdown 対応