Большие файлы в PHP: чтение и запись без падения по памяти
Скрипт на PHP падает с Allowed memory size exhausted при обработке файла на 500 МБ. Разбираем, почему file_get_contents и fetchAll съедают всю память и какие подходы держат постоянный расход независимо от размера файла.

Знакомая картина: скрипт разбирает выгрузку заказов, на файле в 5 МБ работает мгновенно, а на файле в 800 МБ падает с Fatal error: Allowed memory size of 134217728 bytes exhausted. Причина почти всегда одна — файл целиком загружается в память функцией вроде file_get_contents() или file(). Ниже — четыре подхода к работе с большими файлами в PHP 8.x, у каждого своя цена: что выигрываете по памяти и чем платите по скорости или удобству.
Почему очевидное решение не работает
Строки в PHP хранятся в памяти целиком. Когда вы пишете $data = file_get_contents('export.csv') для файла на 800 МБ, интерпретатор сразу выделяет под строку около 800 МБ (плюс накладные расходы на структуру zval). Дальше explode("\n", $data) удваивает расход: теперь в памяти и исходная строка, и массив строк, каждый элемент которого — отдельный zval со своими накладными.
Лимит задаётся директивой memory_limit в php.ini. Поднять его до -1 (без ограничения) можно, но это лечит симптом: на файле вдвое больше вы упрётесь в физическую память сервера и получите OOM-killer вместо понятной ошибки PHP. Правильный путь — читать файл потоком, порциями, чтобы расход памяти не зависел от его размера.
Мерило хорошего решения для больших файлов простое: расход памяти остаётся постоянным при росте файла с 10 МБ до 10 ГБ. Если растёт вместе с файлом — вы просто отложили падение.
Подход 1. Построчное чтение через fgets
Базовый способ: открыть файл, читать по одной строке, обрабатывать, забывать. В памяти в каждый момент — только текущая строка.
<?php
// PHP 8.x. Построчная обработка CSV без загрузки файла целиком
$handle = fopen('export.csv', 'rb');
if ($handle === false) {
throw new RuntimeException('Не удалось открыть файл');
}
$lineNumber = 0;
while (($line = fgets($handle)) !== false) {
$lineNumber++;
$row = str_getcsv(rtrim($line, "\r\n"));
// здесь обрабатываете $row: пишете в БД, считаете агрегаты и т.д.
}
fclose($handle);
echo "Обработано строк: {$lineNumber}\n";
Расход памяти здесь измеряется килобайтами независимо от размера файла. Минус — str_getcsv на каждую строку не всегда корректно разбирает поля с переносами строк внутри кавычек.
Подход 2. fgetcsv для корректного разбора CSV
Если в CSV встречаются поля с запятыми, кавычками или переносами внутри значений, ручной разбор через fgets + str_getcsv ломается. fgetcsv читает по одной логической записи и правильно обрабатывает экранирование.
<?php
// PHP 8.x. Корректный разбор CSV потоком
$handle = fopen('export.csv', 'rb');
$header = fgetcsv($handle); // первая строка — заголовки
while (($fields = fgetcsv($handle)) !== false) {
// сопоставляем значения с именами колонок
$record = array_combine($header, $fields);
// $record['order_id'], $record['amount'] и т.д.
}
fclose($handle);
Важно про версии. В PHP 8.1 параметр $escape у fgetcsv получил уведомление об устаревании поведения по умолчанию, а с PHP 9.0 значение по умолчанию для экранирования меняется. Если пишете под смешанный парк версий — передавайте четвёртый аргумент $escape явно и сверяйтесь с документацией функции для вашей версии.
Подход 3. Генераторы для отделения чтения от обработки
Когда логика обработки сложная, смешивать её с циклом чтения неудобно. Генератор (yield) позволяет вынести чтение в отдельную функцию, которая отдаёт строки по одной, не накапливая их в массиве.
<?php
// PHP 8.x. Генератор возвращает записи по одной, память не растёт
function readCsvRecords(string $path): Generator
{
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new RuntimeException("Не открыть файл: {$path}");
}
try {
$header = fgetcsv($handle);
while (($fields = fgetcsv($handle)) !== false) {
yield array_combine($header, $fields);
}
} finally {
fclose($handle); // закроется даже при исключении в потребителе
}
}
// Использование выглядит как обычный foreach по массиву,
// но в памяти всегда одна запись
foreach (readCsvRecords('export.csv') as $record) {
// обработка одной записи
}
Плюс генератора — код обработки читается как работа с обычным массивом, но расход памяти постоянный. Блок finally гарантирует закрытие дескриптора, даже если в теле foreach выбросится исключение.
Подход 4. SplFileObject для объектного стиля
Если вам ближе объектный код, SplFileObject из стандартной библиотеки умеет итерироваться построчно и разбирать CSV встроенными средствами.
<?php
// PHP 8.x. Построчная итерация через SplFileObject
$file = new SplFileObject('export.csv', 'rb');
$file->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY);
foreach ($file as $fields) {
if ($fields === [null]) {
continue; // пустая последняя строка
}
// $fields — массив полей текущей записи
}
Объект сам закрывает файл, когда выходит из области видимости. Расход памяти такой же низкий, как у fgets.
Сравнение подходов
| Подход | Память | Разбор CSV | Когда выбирать |
|---|---|---|---|
| file_get_contents | = размеру файла | ручной | только для файлов заведомо в пределах пары МБ |
| fgets + str_getcsv | постоянная | ненадёжный при кавычках | простой построчный формат без спецсимволов |
| fgetcsv | постоянная | корректный | обычная обработка CSV с записью в БД |
| Генератор + fgetcsv | постоянная | корректный | сложная логика, хочется отделить чтение от обработки |
| SplFileObject | постоянная | встроенный | объектный стиль, интеграция с ООП-кодом |
Запись большого файла на диск
Симметричная проблема: сборка большого ответа в переменную перед записью тоже съедает память. Не копите строку — пишите порциями сразу в открытый дескриптор.
<?php
// PHP 8.x. Пишем большой CSV порциями, не накапливая в памяти
$out = fopen('report.csv', 'wb');
fputcsv($out, ['order_id', 'amount', 'status']); // заголовок
foreach (readCsvRecords('export.csv') as $record) {
// трансформируем и сразу пишем строку
fputcsv($out, [
$record['order_id'],
$record['amount'],
$record['status'],
]);
}
fclose($out);
Так и на чтении, и на записи память держится постоянной — вы работаете конвейером: одна запись пришла, преобразовалась, ушла.
Отдача большого файла в браузер
Если файл нужно отдать пользователю на скачивание, не читайте его в переменную и не эхойте. Функция readfile() отдаёт содержимое потоком, но при включённом выходном буфере PHP всё равно может накапливать данные. Перед отдачей закройте буферизацию:
- Отправьте заголовки
Content-TypeиContent-Disposition: attachment. - Сбросьте и отключите выходной буфер:
while (ob_get_level()) { ob_end_clean(); }. - Отдайте файл через
readfile($path).
Для очень крупных файлов лучше не гонять их через PHP вовсе, а делегировать веб-серверу через X-Sendfile (Apache) или X-Accel-Redirect (Nginx) — PHP только проверяет права и отдаёт заголовок, а файл читает сам сервер.
Где эти подходы ломаются
- Дескрипторы не закрываются. Если исключение вылетает между
fopenиfcloseбезtry/finally, дескриптор освободится только по завершении скрипта. В долгоживущем процессе (воркер очереди) это утечка. Всегда закрывайте вfinally. - Запись в БД внутри цикла без транзакций. Память вы сэкономили, но по одному
INSERTна строку залочит базу на часы. Вставляйте пачками по 500–1000 строк в одной транзакции. - max_execution_time. Обработка гигабайтного файла легко выходит за 30 секунд по умолчанию. Для CLI-скриптов лимит времени по умолчанию снят, но веб-запрос оборвётся. Тяжёлые файлы обрабатывайте в очереди или из CLI.
- Кодировка и BOM. Выгрузки из Excel часто идут в Windows-1251 с BOM в начале файла. BOM попадёт в первое поле заголовка и сломает
array_combine. Срезайте BOM у первой строки и при необходимости конвертируйте кодировку черезmb_convert_encoding.
Что настроить дальше
Проверьте, откуда запускается обработка: если из веб-запроса — вынесите в CLI-скрипт или очередь, чтобы не зависеть от max_execution_time и таймаута веб-сервера. Замерьте реальный расход памяти вызовом memory_get_peak_usage(true) в конце скрипта — он должен оставаться примерно одинаковым для файлов разного размера. Если растёт вместе с файлом, где-то ещё копится массив, и потоковую обработку вы не довели до конца.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: PHP Manual: fgetcsv, PHP Manual: Generators overview
Частые вопросы
У меня PHP 7.4, а не 8.x — код сработает?
Скрипт всё равно падает по памяти, хотя я читаю через fgetcsv — почему?
Можно ли просто поднять memory_limit и не возиться с потоками?
В чём разница между fgets и fgetcsv, если оба читают построчно?
Как отдать большой файл на скачивание, не убив память?
Первое поле заголовка приходит с мусором в начале — что это?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.