Ми регулярно стикаємося з ситуацією, коли клієнту потрібно імпортувати 50 000 рядків з CSV за розумний час, не обрушивши сервер. Без чіткої стратегії batch-обробки такі завдання призводять до OOM і нескінченних таймаутів. Наш досвід показує: правильна архітектура скорочує час обробки в 10 разів і виключає втрату даних. При цьому не має значення, чи працюєте ви з десятками або сотнями тисяч записів — паттерни залишаються тими ж.
Ключові проблеми batch-обробки
Пам'ять. Завантажувати весь CSV у масив — вірний шлях до вичерпання пам'яті. Правильний паттерн — потокове читання чанками. Ми використовуємо LazyCollection Laravel, який читає файл рядково без завантаження в пам'ять.
Часткові помилки. Якщо з 10 000 рядків 50 невалідні — зупиняти весь процес неправильно. Наша логіка: пропускаємо погані рядки, пишемо в лог з контекстом (наприклад, email помилкового запису), і продовжуємо.
Відтворюваність. Якщо процес впав на 7000-му рядку — ми не починаємо заново. Laravel Batch дозволяє продовжити з місця зупинки, зберігаючи вже оброблені чанки.
Паралелізм. Послідовна обробка 50 000 записів по 100 мс кожен займе майже 1,5 години. Розбивка на паралельні Job'и (оптимальний чанк — 500 записів) скорочує це до 5–10 хвилин на 4 воркерах.
| Типова проблема | Рішення в нашій практиці |
|---|---|
| OOM при завантаженні | Потокове читання LazyCollection + чанки |
| Зупинка при першій помилці | allowFailures() + логування контексту |
| Неможливість відновлення | Збереження стану в job_batches |
| Повільна послідовна обробка | Паралельні Job'и з чанками по 500 |
Як уникнути витоку пам'яті при імпорті великих CSV?
Ми застосовуємо паттерн «Batch → Chunks → Jobs». Після завантаження файлу майстер-завдання розбиває дані на чанки, кожен чанк обробляється окремим Job'ом паралельно. Після завершення всіх Job'ів запускається завдання агрегації результатів.
namespace App\Services; use Illuminate\Bus\Batch; use Illuminate\Support\Facades\Bus; use Illuminate\Support\LazyCollection; class CsvImportService { private const CHUNK_SIZE = 500; public function startImport(string $filePath, int $importId): string { $jobs = []; LazyCollection::make(function () use ($filePath) { $handle = fopen($filePath, 'r'); $header = fgetcsv($handle); while (($row = fgetcsv($handle)) !== false) { yield array_combine($header, $row); } fclose($handle); }) ->chunk(self::CHUNK_SIZE) ->each(function ($chunk, $index) use (&$jobs, $importId) { $jobs[] = new ProcessCsvChunkJob( importId: $importId, chunkIndex: $index, rows: $chunk->values()->toArray() ); }); $batch = Bus::batch($jobs) ->name("csv-import-{$importId}") ->allowFailures() ->then(function (Batch $batch) use ($importId) { Import::find($importId)?->update(['status' => 'completed']); ImportCompletedEvent::dispatch($importId); }) ->catch(function (Batch $batch, \Throwable $e) use ($importId) { Import::find($importId)?->update([ 'status' => 'partially_failed', 'error_message' => $e->getMessage(), ]); }) ->finally(function (Batch $batch) use ($importId) { $import = Import::find($importId); $import?->update([ 'total_jobs' => $batch->totalJobs, 'failed_jobs' => $batch->failedJobs, 'finished_at' => now(), ]); }) ->onQueue('batch-processing') ->dispatch(); Import::find($importId)?->update(['batch_id' => $batch->id]); return $batch->id; } } Job обробки чанка
class ProcessCsvChunkJob implements ShouldQueue { use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 3; public int $timeout = 120; public int $backoff = 10; public function __construct( private int $importId, private int $chunkIndex, private array $rows ) {} public function handle(): void { if ($this->batch()?->cancelled()) { return; } $successCount = 0; $errors = []; foreach ($this->rows as $lineNum => $row) { try { $this->processRow($row); $successCount++; } catch (\Throwable $e) { $errors[] = [ 'chunk' => $this->chunkIndex, 'line' => $lineNum, 'data' => array_slice($row, 0, 3), 'error' => $e->getMessage(), ]; } } ImportChunkResult::create([ 'import_id' => $this->importId, 'chunk_index' => $this->chunkIndex, 'processed' => count($this->rows), 'succeeded' => $successCount, 'failed' => count($errors), 'errors' => $errors, ]); Import::where('id', $this->importId)->increment('processed_rows', count($this->rows)); Import::where('id', $this->importId)->increment('success_rows', $successCount); } private function processRow(array $row): void { $validated = validator($row, [ 'email' => 'required|email', 'name' => 'required|string|max:255', ])->validate(); User::updateOrCreate( ['email' => $validated['email']], ['name' => $validated['name']] ); } } Що робити, якщо процес перервався?
Laravel Batch зберігає стан у таблиці job_batches. Завершені чанки позначені як виконані, незавершені відновлюються автоматично після перезапуску воркера. Для примусового рестарту можна запросити незавершені індекси з ImportChunkResult і повторно диспатчити Job'и.
Прогрес у реальному часі виводиться через endpoint:
public function progress(int $importId): JsonResponse { $import = Import::findOrFail($importId); $batch = $import->batch_id ? Bus::findBatch($import->batch_id) : null; return response()->json([ 'status' => $import->status, 'processed_rows' => $import->processed_rows, 'success_rows' => $import->success_rows, 'total_rows' => $import->total_rows, 'percentage' => $import->total_rows > 0 ? round($import->processed_rows / $import->total_rows * 100, 1) : 0, 'batch' => $batch ? [ 'total_jobs' => $batch->totalJobs, 'pending_jobs' => $batch->pendingJobs, 'failed_jobs' => $batch->failedJobs, 'progress' => $batch->progress(), ] : null, ]); } Обмеження навантаження
Для batch-черги потрібен окремий пул воркерів з обмеженим паралелізмом, щоб не забити всю БД або CPU:
[program:batch-worker] command=php artisan queue:work --queue=batch-processing --max-jobs=50 --sleep=3 --timeout=120 numprocs=4 autostart=true autorestart=true numprocs=4 — чотири воркери, кожен обробляє чанки послідовно. --max-jobs=50 — після 50 завдань воркер перезапускається, звільняючи пам'ять.
| Розмір чанка | Час обробки 50 000 записів | Ризик витоку пам'яті |
|---|---|---|
| 100 | ~20 хвилин | Низький |
| 500 | ~10 хвилин | Низький |
| 1000 | ~8 хвилин | Середній |
| 5000 | ~6 хвилин | Високий |
Вибір 500 як оптимального компромісу між швидкістю та стабільністю.
Процес роботи
- Аналітика: вивчаємо формат файлів, об'єм даних, вимоги до швидкості.
- Проектування: обираємо розмір чанка, конфігуруємо черги.
- Реалізація: пишемо код з чанками, обробкою помилок, прогресом та відновленням.
- Тестування: проганяємо на тестових даних з емуляцією збоїв.
- Деплой: налаштовуємо воркери, моніторинг, передаємо документацію.
Що входить у реалізацію під ключ
- Архітектура batch-обробки з чанками та паралельними Job'ами.
- Endpoint прогресу та відновлення після збоїв.
- Детальне логування помилок для аналізу.
- Документація з розгортання та експлуатації.
- Навчання команди роботі з системою.
- Підтримка протягом місяця після запуску.
Строки та вартість
Базова реалізація імпорту CSV з чанками та прогресом — від 1 робочого дня. Додавання відновлення, детального логу та підтримки XLSX/JSON — ще від 1–2 днів. Вартість розраховується індивідуально — пишіть, ми оцінимо ваш проект. Більше 5 років ми займаємося веб-розробкою, реалізували понад 50 проектів з batch-обробкою — гарантуємо стабільність та масштабованість рішення.
| Параметр | Послідовна обробка | Пакетна (наша реалізація) |
|---|---|---|
| 50 000 записів | ~1,5 години | ~5–10 хвилин |
| Витоки пам'яті | Імовірні при великому об'ємі | Виключені (чанки по 500) |
| Обробка помилок | Зупинка всього процесу | Пропуск проблемних рядків |
| Відновлення | Тільки з початку | З місця зупинки |
Чому Bus::batch()?
Laravel Bus::batch() надає вбудовану підтримку групових завдань: відстеження статусу, часткові помилки, ланцюжки колбеків. Це позбавляє від написання власного планувальника та знижує ризик помилок.
Отримайте консультацію — напишіть нам, і ми протягом дня підготуємо архітектуру для вашого сценарію. Замовте аудит вашого batch-процесу — ми знайдемо вузькі місця та запропонуємо оптимізацію.







