Импорт XLSX на 1 000 000 строк без memory_limit в 2 ГБ: потоковое чтение в PHP

aVadim • 03.09.2026 15:22 •

Если вы хоть раз импортировали большой 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 — следующая строка разбирается только тогда, когда вы её запросили;
  • библиотека только читает данные и не тащит за собой инфраструктуру для записи и редактирования.

Установка:

bash
composer require avadim/fast-excel-reader

Минимальный пример потокового чтения:

php
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():

php
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 тысяч.

Для сравнения запустите тот же файл через «загрузить всё в массив»:

php
$rows = $excel->readRows(); // НЕ делайте так с большими файлами

readRows() удобен для небольших файлов, но собирает весь результат в массив — на миллионе строк вы снова упрётесь в память, теперь уже на стороне результата, а не парсера.

Практические приёмы для больших файлов

1. Обрабатывайте строки по одной и не копите их

Плохо:

php
$all = [];
foreach ($sheet->nextRow() as $rowData) {
    $all[] = transform($rowData); // память растёт с каждой строкой
}
save($all);

Хорошо:

php
$batch = [];
foreach ($sheet->nextRow() as $rowData) {
    $batch[] = transform($rowData);
    if (count($batch) >= 1000) {
        save($batch);   // сохранили порцию
        $batch = [];    // и освободили память
    }
}
if ($batch) {
    save($batch);
}

Память ограничена размером батча, а не размером файла.

2. Пропускайте пустые строки и мусор на уровне ридера

По умолчанию библиотека уже пропускает полностью пустые строки. Можно ужесточить правила — например, считать пустыми ячейки с пустыми строками и пробелами:

php
foreach ($sheet->nextRow([], Excel::TRIM_STRINGS | Excel::TREAT_EMPTY_STRING_AS_EMPTY_CELL) as $rowData) {
    if (!$rowData) {
        continue;   // строка из одних пробелов пришла пустым массивом
    }
    // дальше — только реальные данные
}

Важная деталь: флаги действуют на уровне ячеек, а не строк. Полностью пустые строки ридер пропускает и без флагов, а вот строка, где лежат пробелы и пустые строки, с этими флагами придёт как пустой массив — поэтому в цикле и стоит if (!$rowData) continue;. Всё равно дешевле, чем чистить значения вручную после чтения.

3. Читайте только нужную область

Если данные занимают конкретный диапазон, незачем сканировать весь лист:

php
$sheet->setReadArea('B4:F100000');
foreach ($sheet->nextRow() as $rowData) {
    // только колонки B..F, начиная со строки 4
}

4. Прерывайте чтение, когда данные закончились

Полный контроль над процессом даёт readCallback() — верните true, и чтение остановится:

php
$excel->readCallback(function ($row, $col, $val) {
    // обработка ячейки
    if ($row > 500000) {
        return true; // стоп — дальше не читаем
    }
    return false;
});

5. Пошаговое чтение вместо foreach

Если строки нужно читать не единым циклом (например, вперемешку с другой логикой), есть пара reset() + readNextRow():

php
$sheet->reset();
$header = $sheet->readNextRow();  // первая строка

while ($rowData = $sheet->readNextRow()) {
    // следующие строки — по требованию
}

А что с CSV и старым XLS?

Тот же потоковый подход работает для всех поддерживаемых форматов:

php
// 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 и ранний выход из чтения позволяют выжать максимум и из скорости.
bash
composer require avadim/fast-excel-reader

Документация и примеры — в репозитории на GitHub.

Комментарии (0)