Ошибка выглядит примерно так и всегда сразу же останавливает выполнение скрипта:

 PHP Fatal error:  Allowed memory size of 33554432 bytes exhausted (tried to allocate 33554440 bytes) in /var/www/app/report.php on line 3
 

Быстрое решение проблемы — увеличить  memory_limit . Более правильный подход — выяснить, почему скрипту требуется так много памяти, поскольку в большинстве случаев он удерживает в ней данные, которые можно было бы обрабатывать по частям. Ниже описано, как читать это сообщение, как правильно увеличить лимит и как сократить потребление памяти.

Чтение сообщения об ошибке

  •  Allowed memory size of 33554432 bytes — это ваш  memory_limit  в байтах. Разделите это число на 1 048 576, и вы получите 32 МБ.
  •  tried to allocate 33554440 bytes — это размер конкретного выделения памяти, которое завершилось сбоем, а не общий объем используемой памяти. Большое число здесь обычно указывает на создание или увеличение крупного массива либо строки. Массивы в PHP удваивают свой размер при перераспределении, поэтому цикл, добавляющий элементы в массив, вызывает ошибку при попытке запросить объем памяти, примерно равный текущему размеру всего массива.
  •  in ... on line N — строка, на которой сработал лимит, что не всегда совпадает с местом, где память была растрачена впустую. Строка, на которой происходит сбой — это просто та строка, которая запросила память в момент, когда её совсем не осталось. Реальная причина часто находится выше по коду.

Простой пример, который падает с лимитом в 32 МБ:

 $a = [];
for ($i = 0; $i < 10000000; $i++) {
    $a[] = $i;
}
 
 php -d memory_limit=32M a.php
 

Этот код выводит ту самую ошибку, показанную выше, включая удвоенное выделение памяти.

Шаг 1. Найдите лимит, который применяется на самом деле

CLI, PHP-FPM и Apache могут использовать разные значения  php.ini , поэтому значение, которое вы видите в терминале, может отличаться от того, с которым запускается ваш веб-запрос.

 php --ini                    # which php.ini the CLI loads
php -r 'echo ini_get("memory_limit");'
php-fpm8.3 -i | grep memory_limit    # FPM's value (binary name varies by distro)
 

На машине, где я проводил тестирование, CLI-версия  php.ini  устанавливала  memory_limit  в значение  -1  (без ограничений), в то время как стандартное значение PHP по умолчанию, проверяемое с помощью  php -n , составляет  128M . Это частая причина ситуации «из командной строки всё работает, а в браузере падает»: в CLI лимитов нет, а у веб-сервера они есть.

Шаг 2. Увеличьте его в правильном месте

Где Как Область действия
 php.ini   memory_limit = 256M  Всё, что использует этот ini-файл. После изменения перезапустите FPM или Apache.
Командная строка  php -d memory_limit=512M script.php  Только этот запуск
В самом скрипте  ini_set('memory_limit', '256M');  Текущий запрос или процесс
Файл пула FPM  php_admin_value[memory_limit] = 256M  Один пул

 ini_set()  сработал в моём тесте: скрипт, запущенный с лимитом в 8 МБ, самостоятельно увеличил его до 64 МБ, а затем выделил память под строку размером 30 МБ. Имейте в виду, что согласно мануалу PHP, значение, заданное через  php_admin_value  в конфигурации пула FPM, впоследствии нельзя изменить с помощью  ini_set() . Поэтому, если ваш  ini_set  игнорируется в веб-среде, проверьте файл пула. Я не тестировал FPM в данном сценарии.

Composer часто исчерпывает память и имеет собственный флаг:

 COMPOSER_MEMORY_LIMIT=-1 composer update
# or
php -d memory_limit=-1 $(which composer) update
 

Избегайте установки  memory_limit = -1  для веб-запросов. Лимит нужен именно для того, чтобы один вышедший из-под контроля запрос не уронил весь сервер. Указывайте конкретное число, основываясь на реальных замерах.

Шаг 3. Используйте меньше памяти

Увеличение лимита лишь маскирует проблему до тех пор, пока объём данных снова не вырастет. Ниже приведены типичные антипаттерны, потребляющие гораздо больше памяти, чем необходимо. Цифры ниже получены в PHP 8.3.6 на текстовом файле размером 70 МБ с 1,5 миллионами строк:

Подход Пиковое потребление памяти
 $lines = file('big.txt');  216 МБ (падает при лимите в 64 МБ)
 while (($line = fgets($h)) !== false)  2 МБ
Генератор, возвращающий по одной строке за раз 2 МБ

Обратите внимание, что файл весит 70 МБ, но  file()  требует втрое больше памяти, поскольку каждая строка превращается в отдельную строку PHP со своими накладными расходами. Генератор позволяет сохранить код читаемым, обрабатывая элементы по одному:

 function lines(string $path): Generator
{
    $h = fopen($path, 'r');
    try {
        while (($line = fgets($h)) !== false) {
            yield $line;
        }
    } finally {
        fclose($h);
    }
}

foreach (lines('big.txt') as $line) {
    // process one line
}
 

Тот же принцип применим к данным, которые генерируются, а не читаются из файла. При создании массива из 2 миллионов небольших значений с помощью  iterator_to_array()  пиковое потребление памяти составило 126 МБ, в то время как при прямой итерации по генератору оно составило всего 2 МБ. Если вам нужно пройтись в цикле всего один раз, не собирайте данные в массив заранее.

Результаты из базы данных (Laravel)

Самый частый источник проблем в приложениях на Laravel — это вызов  Model::all()  или  ->get()  для большой таблицы, из-за чего каждая строка моментально преобразуется в объект модели. Вместо этого обрабатывайте данные пачками:

 // Fixed-size batches, safe when you update rows while looping
User::query()->chunkById(1000, function ($users) {
    foreach ($users as $user) {
        // ...
    }
});

// Same idea, one model at a time through a lazy collection
foreach (User::query()->lazyById(1000) as $user) {
    // ...
}
 

Я не запускал эти примеры с базой данных специально для этой статьи, это документированные методы Eloquent. Отдавайте предпочтение  chunkById  или  lazyById  вместо  chunk , если в процессе цикла вы меняете колонку, по которой фильтруете данные, поскольку  chunk  использует смещения (offsets) и может пропустить строки. Если вам нужно всего несколько колонок, выбирайте их через  select() . Экспорт всей таблицы в CSV должен выполняться в фоновой очереди с потоковой передачей, а не в рамках обычного HTTP-запроса.

Шаг 4. Выясните, что именно потребляет память

Если причина неочевидна, логируйте показатели памяти при достижении лимита. Функция завершения работы (shutdown function) продолжает выполняться даже после этой фатальной ошибки, поэтому она может зафиксировать состояние системы на момент сбоя:

 register_shutdown_function(function () {
    $e = error_get_last();
    if ($e && str_contains($e['message'], 'Allowed memory size')) {
        error_log(sprintf(
            'OOM peak=%.1f MB at %s:%d',
            memory_get_peak_usage(true) / 1048576,
            basename($e['file']),
            $e['line']
        ));
    }
});
 

В моём тесте этот код вывел  peak=16.0 MB  и строку, на которой произошел сбой, для скрипта с лимитом в 16 МБ. Чтобы найти причину, вызывайте  memory_get_usage()  до и после подозрительных участков кода и смотрите, где происходит резкий скачок. Если этого недостаточно, профилировщики вроде Xdebug или Blackfire дают полную картину, хотя я и не использовал их в данной статье.

Вот что стоит проверить в первую очередь:

  • Запросы без лимитов, результаты которых собираются в массив «для обработки позже».
  •  file_get_contents()  или  file()  при работе с загружаемыми пользователями файлами или экспортом.
  • Обработка изображений: раскодированный растр в памяти занимает гораздо больше места, чем файл на диске, так как его размер зависит от габаритов в пикселях, а не от размера файла.
  • Бесконечный цикл или бесконечная рекурсия, непрерывно добавляющие данные. Если потребление памяти растет бесконечно независимо от заданного лимита, дело в этом.
  • Долго работающие процессы, такие как воркеры очередей, где утечки памяти накапливаются от задачи к задаче. Встроенный в Laravel  queue:work  имеет опцию  --memory  (по умолчанию 128 МБ), которая заставляет воркер завершить работу и перезапуститься, когда потребление памяти становится слишком большим.

Источники


Оригинал опубликован на DEV Talk.