Читаем гигантские файлы в PHP построчно без убийства сервера

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Вступление: «Фатальная ошибка» и 2 гигабайта логов

Представьте: вы — разработчик, и к вам приходит системный администратор с флешкой. На ней — лог-файл веб-сервера за последние полгода. Размер? Скромные 2.5 гигабайта. «Нужно найти все IP-адреса, с которых были попытки брутфорса», — говорит он и уходит пить кофе.

Вы открываете IDE и машинально пишете:

$lines = file('huge_log.txt');
foreach ($lines as $line) {
    // парсим...
}

Нажимаете «Запуск»... и видите в браузере белый экран, а в логах ошибок — Fatal Error: Allowed memory size exhausted. Знакомо? Это классическая ловушка, в которую попадались почти все PHP-разработчики.

Вопрос «How to read a large file line by line?» на Stack Overflow собрал сотни тысяч просмотров не просто так. Это одна из тех задач, где «наивное» решение убивает сервер, а правильный подход спасает проект. Сегодня мы разберем, как читать огромные файлы в PHP, не превышая лимит памяти и сохраняя высокую производительность. Поехали!

Почему file() и file_get_contents() — зло для больших данных

Давайте сразу развеем главный миф. Функции file() и file_get_contents() — это жадные функции. Они загружают весь файл в оперативную память целиком. Если ваш файл весит 2 ГБ, а лимит memory_limit в php.ini установлен в 128 МБ (стандартное значение для многих хостингов), вы гарантированно получите ошибку.

Более того, PHP хранит строки в кодировке, что может увеличить потребление памяти в 2-4 раза относительно размера файла на диске. То есть 2 ГБ логов могут «съесть» 6-8 ГБ оперативной памяти!

Вывод: если размер файла превышает 10-20% от вашего memory_limit, забудьте про file(). Нам нужен потоковый подход.

Правильный способ: fgets() и потоковое чтение

Самый надёжный и производительный метод — использовать fopen() в паре с fgets(). Эти функции читают файл не целиком, а по одной строке за раз. Представьте, что вы пьёте сок через трубочку — вы не выпиваете весь стакан за один глоток, а втягиваете понемногу. Точно так же работает потоковое чтение.

Вот базовый, но уже рабочий пример:

<?php
$handle = fopen('huge_log.txt', 'r');
if ($handle) {
    while (($line = fgets($handle)) !== false) {
        // Обрабатываем одну строку
        echo $line;
    }
    fclose($handle);
}
?>

Что здесь происходит? fopen() открывает файл и возвращает указатель (resource). fgets() каждый раз считывает одну строку до символа новой строки (\n). Память выделяется только под текущую строку, а не под весь файл. После обработки строка уничтожается сборщиком мусора (или перезаписывается).

Важные нюансы производительности

  • Размер буфера: fgets() по умолчанию читает до 8192 байт за раз. Если строки длиннее, функция сделает несколько итераций. Для абсолютного большинства логов этого достаточно.
  • Проверка на false: Всегда используйте строгое сравнение !== false. Почему? Потому что строка может быть пустой ("") или содержать "0", и while ($line = fgets($handle)) может преждевременно остановиться.
  • Закрытие файла: Всегда вызывайте fclose(). Да, PHP сделает это сам при завершении скрипта, но если вы обрабатываете файл в цикле или в фоновом скрипте, незакрытые дескрипторы — это утечка ресурсов.

Супер-заряд: Генераторы (yield) для повторного использования

Ручное управление fopen/fclose — это хорошо, но если вам нужно читать разные файлы в разных частях проекта, код начнет дублироваться. На помощь приходят генераторы — одна из самых элегантных фич PHP.

Генератор позволяет создать функцию, которая ведёт себя как итератор, но не загружает все данные в память. Внутри неё мы используем ключевое слово yield.

<?php
function readLargeFile(string $filename): Generator
{
    $handle = fopen($filename, 'r');
    if (!$handle) {
        throw new RuntimeException("Cannot open file: $filename");
    }

    while (($line = fgets($handle)) !== false) {
        yield rtrim($line, "\n\r"); // Убираем символы перевода строки
    }

    fclose($handle);
}

// Использование:
foreach (readLargeFile('huge_log.txt') as $line) {
    // $line уже "чистая" строка
    if (str_contains($line, '192.168.1.')) {
        echo $line . PHP_EOL;
    }
}
?>

Почему это круто?

  • Память расходуется только на одну строку.
  • Код переиспользуем — можно читать CSV, JSONL, логи.
  • Можно комбинировать с другими итераторами (например, фильтровать или маппить на лету).

Альтернатива: SplFileObject — ООП-подход

PHP предлагает встроенный ООП-класс SplFileObject, который делает то же самое, но с более приятным синтаксисом. Он реализует интерфейс Iterator, поэтому его можно использовать в foreach напрямую.

<?php
$file = new SplFileObject('huge_log.txt');

// Важно: сбрасываем флаги, чтобы не было проблем с пустыми строками
$file->setFlags(SplFileObject::DROP_NEW_LINE);

foreach ($file as $lineNumber => $line) {
    // $lineNumber — номер строки (начиная с 0)
    echo "Line $lineNumber: $line" . PHP_EOL;
}
?>

Плюсы: не нужно вручную управлять fopen/fclose, есть встроенные методы (например, seek() для перехода к определённой строке).

Минусы: работает немного медленнее, чем чистый fgets() в цикле, из-за накладных расходов на ООП. Для файлов размером более 10 ГБ я рекомендую использовать генератор с fgets().

Как измерить производительность и следить за памятью

Чтобы убедиться, что ваш код действительно оптимизирован, используйте встроенные функции:

<?php
echo "Начало: " . memory_get_usage                

Блоги

Рекомендаций по книгам