Мы регулярно сталкиваемся с ситуацией, когда клиенту нужно импортировать 50 000 строк из CSV за разумное время, не обрушив сервер. Без чёткой стратегии batch-обработки такие задачи приводят к OOM и бесконечным таймаутам. Наш опыт показывает: правильная архитектура сокращает время обработки в 10 раз и исключает потерю данных. При этом не имеет значения, работаете ли вы с десятками или сотнями тысяч записей — паттерны остаются теми же.
Ключевые проблемы batch-обработки
Память. Загружать весь CSV в массив — верный путь к исчерпанию памяти. Правильный паттерн — потоковое чтение чанками. Мы используем LazyCollection Laravel, который читает файл построчно без загрузки в память.
Частичные ошибки. Если из 10 000 строк 50 невалидны — останавливать весь процесс неправильно. Наша логика: пропускаем плохие строки, пишем в лог c контекстом (например, 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-процесса — мы найдем узкие места и предложим оптимизацию.







