Импорт XLSX на 1 000 000 строк без memory_limit в 2 ГБ: потоковое чтение в PHP
Если вы хоть раз импортировали большой Excel-файл в PHP, вы почти наверняка видели это сообщение:
Fatal error: Allowed memory size of 134217728 bytes exhausted
Классический сценарий: клиент присылает выгрузку на 300–500 тысяч строк, вы открываете её через PhpSpreadsheet — и скрипт падает. Увеличиваете memory_limit до 512M — падает. До 2G — работает, но медленно, а на проде такой лимит ставить страшно. Знакомо?
В этой статье разберём, почему так происходит и как читать файлы практически любого размера с постоянным потреблением памяти с помощью avadim/fast-excel-reader.
Всё ниже проверено на версии 4.4.3.
Почему Excel-файлы «съедают» память
XLSX-файл — это ZIP-архив с XML-файлами внутри. Файл размером 10 МБ на диске в распакованном виде может превратиться в 100+ МБ XML. А дальше всё зависит от того, как библиотека этот XML читает.
Универсальные библиотеки вроде PhpSpreadsheet строят в памяти полную объектную модель книги: каждая ячейка — объект со значением, стилем, координатами и ссылками на соседние структуры. Это необходимо, когда вы хотите редактировать файл. Но при импорте данных это чистые накладные расходы: даже небольшой файл в несколько сотен килобайт может занять в памяти под 200 МБ, а файл на миллион строк не поместится ни в какой разумный лимит.
Отсюда и типичные «костыли», которые советуют друг другу на Stack Overflow:
setReadDataOnly(true)— помогает частично, объектная модель всё равно строится;- распилить файл на части перед импортом — работает, но это отдельный процесс и лишняя точка отказа;
ini_set('memory_limit', '-1')— до первого действительно большого файла.
Все они борются с симптомом. Причина — архитектура «загрузить всё, потом обработать».
Другой подход: поток вместо объектной модели
FastExcelReader решает задачу иначе. Он вообще не строит модель книги:
- XML листа читается узел за узлом через
XMLReader— в памяти в каждый момент находится только текущая строка; - итерация по строкам реализована на генераторах PHP — следующая строка разбирается только тогда, когда вы её запросили;
- библиотека только читает данные и не тащит за собой инфраструктуру для записи и редактирования.
Установка:
composer require avadim/fast-excel-reader
Минимальный пример потокового чтения:
use avadim\FastExcelReader\Excel;
$excel = Excel::open('huge-report.xlsx');
$sheet = $excel->sheet();
foreach ($sheet->nextRow() as $rowNum => $rowData) {
// $rowData — массив вида ['A' => ..., 'B' => ..., ...]
// в памяти сейчас только ЭТА строка
processRow($rowData);
}
nextRow() возвращает генератор. Сколько бы строк ни было в файле — тысяча или миллион — пиковое потребление памяти остаётся практически постоянным: оно определяется размером одной строки и общих структур (shared strings, стили), а не размером файла.
Измеряем: сколько памяти на самом деле нужно
Проверить легко — простой скрипт с memory_get_peak_usage():
use avadim\FastExcelReader\Excel;
$start = microtime(true);
$excel = Excel::open('huge-report.xlsx'); // файл на 1 000 000 строк
$count = 0;
foreach ($excel->sheet()->nextRow() as $rowData) {
$count++;
}
printf(
"Rows: %d\nTime: %.1f sec\nPeak memory: %.1f MB\n",
$count,
microtime(true) - $start,
memory_get_peak_usage(true) / 1024 / 1024
);
Наш замер на PHP 8.4: файл на 1 000 000 строк по четыре колонки, 20 МБ на диске — чтение заняло 491 секунду, пиковое потребление памяти 2 МБ. Не сотни и не десятки — два. Точные числа зависят от файла (количество уникальных строк в shared strings влияет сильнее всего), но главное — потребление памяти не растёт с количеством строк. Файл на 2 миллиона строк потребует памяти того же порядка, что и файл на 100 тысяч.
Для сравнения запустите тот же файл через «загрузить всё в массив»:
$rows = $excel->readRows(); // НЕ делайте так с большими файлами
readRows() удобен для небольших файлов, но собирает весь результат в массив — на миллионе строк вы снова упрётесь в память, теперь уже на стороне результата, а не парсера.
Практические приёмы для больших файлов
1. Обрабатывайте строки по одной и не копите их
Плохо:
$all = [];
foreach ($sheet->nextRow() as $rowData) {
$all[] = transform($rowData); // память растёт с каждой строкой
}
save($all);
Хорошо:
$batch = [];
foreach ($sheet->nextRow() as $rowData) {
$batch[] = transform($rowData);
if (count($batch) >= 1000) {
save($batch); // сохранили порцию
$batch = []; // и освободили память
}
}
if ($batch) {
save($batch);
}
Память ограничена размером батча, а не размером файла.
2. Пропускайте пустые строки и мусор на уровне ридера
По умолчанию библиотека уже пропускает полностью пустые строки. Можно ужесточить правила — например, считать пустыми ячейки с пустыми строками и пробелами:
foreach ($sheet->nextRow([], Excel::TRIM_STRINGS | Excel::TREAT_EMPTY_STRING_AS_EMPTY_CELL) as $rowData) {
if (!$rowData) {
continue; // строка из одних пробелов пришла пустым массивом
}
// дальше — только реальные данные
}
Важная деталь: флаги действуют на уровне ячеек, а не строк. Полностью пустые строки
ридер пропускает и без флагов, а вот строка, где лежат пробелы и пустые строки, с этими
флагами придёт как пустой массив — поэтому в цикле и стоит if (!$rowData) continue;.
Всё равно дешевле, чем чистить значения вручную после чтения.
3. Читайте только нужную область
Если данные занимают конкретный диапазон, незачем сканировать весь лист:
$sheet->setReadArea('B4:F100000');
foreach ($sheet->nextRow() as $rowData) {
// только колонки B..F, начиная со строки 4
}
4. Прерывайте чтение, когда данные закончились
Полный контроль над процессом даёт readCallback() — верните true, и чтение остановится:
$excel->readCallback(function ($row, $col, $val) {
// обработка ячейки
if ($row > 500000) {
return true; // стоп — дальше не читаем
}
return false;
});
5. Пошаговое чтение вместо foreach
Если строки нужно читать не единым циклом (например, вперемешку с другой логикой), есть пара reset() + readNextRow():
$sheet->reset();
$header = $sheet->readNextRow(); // первая строка
while ($rowData = $sheet->readNextRow()) {
// следующие строки — по требованию
}
А что с CSV и старым XLS?
Тот же потоковый подход работает для всех поддерживаемых форматов:
// CSV — построчное чтение через генератор
$csv = Excel::openCsv('huge.csv');
foreach ($csv->nextRow() as $rowData) { /* ... */ }
// Легаси XLS (Excel 97-2003) открывается тем же Excel::open() —
// формат определяется по сигнатуре файла, API идентичен
$excel = Excel::open('legacy.xls');
Один API — три формата, и везде константное потребление памяти.
Когда FastExcelReader не подойдёт
Честности ради: библиотека только читает файлы. Если нужно открыть XLSX, изменить пару ячеек и сохранить — это задача для FastExcelWriter или PhpSpreadsheet. Если нужно вычислять формулы — тоже не сюда: ридер возвращает текст формулы и последнее рассчитанное значение, но не пересчитывает их.
Но если ваша задача — «прочитать данные из Excel и что-то с ними сделать» (а это 90% реальных задач импорта), то потоковый ридер делает это на порядок быстрее и значительно экономнее по памяти.
Итог
- XLSX на сотни тысяч и миллионы строк — не повод поднимать
memory_limitи городить распил файлов. - Ключ к константной памяти — потоковое чтение:
XMLReader+ генераторы вместо объектной модели. nextRow()— ваш основной инструмент;readRows()— только для небольших файлов.- Батчи, read areas и ранний выход из чтения позволяют выжать максимум и из скорости.
composer require avadim/fast-excel-reader
Документация и примеры — в репозитории на GitHub.