Память в PHP на ревью: что искать в pull request
Утечка памяти в PHP-скрипте редко видна в диффе на десять строк. Разбираем, какие места в pull request проверять, чтобы не отправить в прод воркер, который падает по OOM через час работы.

Долгоживущий PHP-процесс — консольный воркер очереди, крон-скрипт на миллион строк, демон на ReactPHP или Swoole — падает не сразу. Он держит нагрузку час, два, потом ловит Allowed memory size of 134217728 bytes exhausted и умирает посреди обработки задачи. В обычном веб-запросе такое почти не видно: процесс живёт доли секунды и умирает, освобождая всё разом. На ревью же именно эти места проще всего пропустить — они выглядят безобидно. Разберём, на что смотреть в pull request, чтобы отловить рост памяти до того, как его найдёт мониторинг в три часа ночи.
Речь про PHP 8.x. Считаем, что читатель уже пишет на PHP и понимает, что такое сборщик мусора и ссылки — теория ниже только там, где без неё замечание не сформулировать.
Почему это вообще проблема в PHP
PHP освобождает память двумя механизмами. Основной — подсчёт ссылок (refcount): как только на переменную не осталось ссылок, память под неё возвращается сразу. Второй — циклический сборщик мусора1, который собирает объекты, ссылающиеся друг на друга по кругу. Он запускается не постоянно, а по достижении внутреннего порога буфера возможных корней цикла.
Из этого следуют две категории замечаний на ревью. Первая — код держит ссылку на данные, которые уже не нужны, и refcount не падает до нуля. Вторая — код создаёт циклы ссылок в горячем цикле быстрее, чем GC успевает их собирать, или GC отключён.
В обычном контроллере утечка на 200 КБ незаметна: запрос закончился — память вернулась. В воркере, который обрабатывает 50 000 задач подряд, те же 200 КБ на задачу — это плюс 10 ГБ к моменту, когда кто-то заметит.
Что искать в диффе
1. Накопление в свойстве или статике внутри цикла
Классика: объект-обработчик живёт всё время работы воркера, а по ходу дела складывает что-то в своё свойство «на всякий случай» — для логов, кеша, дедупликации.
// Плохо: $this->processed растёт вместе с числом задач
class TaskWorker
{
private array $processed = [];
public function handle(Task $task): void
{
// ...обработка...
$this->processed[$task->id] = $task; // копится всю жизнь процесса
}
}Замечание на ревью формулируется конкретно: «$processed ничем не очищается и растёт линейно с числом задач — при долгом воркере это утечка. Нужно либо ограничить размер (LRU / срез последних N), либо не хранить весь Task, а только id, либо очищать после батча». Не «обратите внимание на память», а что именно течёт и чем платит система.
2. Статические кеши и синглтоны без вытеснения
Статическое свойство живёт до конца процесса. Мемоизация вида «сходил в БД — запомнил в static $cache[$key]» в веб-запросе безопасна, а в воркере с уникальными ключами превращается в неограниченный рост.
// Опасно в долгоживущем процессе
function resolveUser(int $id): User
{
static $cache = [];
return $cache[$id] ??= User::find($id); // ключи не повторяются — кеш не вытесняется
}Вопрос к автору: «Сколько уникальных значений $id пройдёт через процесс за смену? Если тысячи — кешу нужен предел размера или сброс по событию».
3. Замыкания, которые тащат за собой лишнее
Анонимная функция захватывает переменные через use по значению или по ссылке, а стрелочная (fn) автоматически захватывает всё из окружения по значению. Если замыкание живёт долго (положили в свойство, в реестр коллбэков, в очередь), оно держит и всё захваченное.
// Замыкание держит весь $heavyReport, хотя нужен один id
$reportId = $heavyReport->id;
$callbacks[] = function () use ($reportId) { // держим только int
dispatch(new NotifyJob($reportId));
};
// вместо use ($heavyReport)На ревью смотрите, что именно попало в use, и живёт ли само замыкание дольше одной итерации.
4. Циклические ссылки при отключённом GC
Если в коде или в конфигурации воркера встречается gc_disable() — это красный флаг. Иногда его ставят «для скорости», не зная, что тогда циклы ссылок не собираются вообще и память течёт на любых двусторонних связях (родитель ↔ дочерний узел, наблюдатель ↔ субъект).
class Node
{
public ?Node $parent = null;
/** @var Node[] */
public array $children = [];
public function addChild(Node $child): void
{
$child->parent = $this; // цикл: parent -> child -> parent
$this->children[] = $child;
}
}Такой граф соберётся циклическим GC, только пока он включён. Замечание: «Здесь двусторонние ссылки. Убедитесь, что gc_enable() активен в воркере, либо рвите связь ($node->parent = null) перед тем, как отпустить поддерево».
5. Загрузка всего набора в память вместо потока
Самый частый и самый дорогой случай. fetchAll(), file_get_contents() на большом файле, SELECT без пагинации — данные целиком приезжают в один массив.
$rows = $pdo->query(...)->fetchAll()на таблице в миллионы строк — читайте курсором в циклеwhile ($row = $stmt->fetch()).json_decode(file_get_contents($path))на файле в гигабайт — нужен потоковый парсер.- Сборка гигантской строки в цикле конкатенацией — пишите порциями в поток вывода.
Как проверить подозрение прямо в PR
Если замечание спорное, не спорьте на словах — попросите замер. Точку роста памяти показывает memory_get_usage(), а пик за всё время — memory_get_peak_usage(). Простейший способ увидеть тренд в воркере:
// Логировать раз в N итераций, чтобы увидеть тренд, а не разовое значение
$processed = 0;
foreach ($tasks as $task) {
$worker->handle($task);
if (++$processed % 1000 === 0) {
// true — реально занятая процессом память, не отданная обратно ОС
$mb = round(memory_get_usage(true) / 1048576, 1);
error_log("processed={$processed} mem={$mb}MB");
}
}Если mem монотонно растёт от батча к батчу и не выходит на плато — утечка подтверждена. Если колеблется вокруг стабильного значения — паника ложная, память живёт ровно столько, сколько нужно. Для более точной локализации — профилировщик уровня Xdebug или аллокационный трейс, но для ревью хватает и лога тренда.
Чек-лист замечаний по памяти на ревью
| Что видите в диффе | Риск | Что просить у автора |
|---|---|---|
| Запись в свойство/статику внутри цикла обработки | Линейный рост в долгом процессе | Ограничить размер или очищать после батча |
static $cache = [] с уникальными ключами | Неограниченный кеш | Предел размера / сброс по событию |
use (...) или fn с тяжёлым объектом, замыкание живёт долго | Удержание лишних данных | Захватывать минимум (id вместо объекта) |
gc_disable() + двусторонние ссылки | Циклы не собираются | Включить GC или рвать связи вручную |
fetchAll() / file_get_contents на больших данных | Пик памяти на весь набор | Курсор, потоковое чтение, пагинация |
Где подход ломается
Мониторинг тренда по memory_get_usage(true) не ловит фрагментацию: процесс может держать выделенные у ОС страницы, даже когда PHP-heap частично свободен, — плато на графике окажется выше суммы «живых» данных. Это не всегда утечка, а особенность аллокатора.
Второй граничный случай — расширения. Утечку в C-расширении (некорректно освобождённый ресурс в PDO-драйвере, image-библиотеке, curl-хендлы без curl_close()) PHP-профилировщик уровня Zend не покажет: память течёт вне управляемой кучи. Здесь помогает только наблюдение за RSS процесса на уровне ОС и curl_close()/явное освобождение ресурсов.
Третий — короткоживущий FPM-процесс. Всё, что описано выше как «утечка», в обычном веб-запросе безвредно: процесс умирает и всё отдаёт. Не тормозите PR с контроллером требованием чистить массив, который живёт 40 миллисекунд, — это будет придирка, а не замечание.
Что делать после мерджа
Ревью ловит подозрения, но подтверждает их прод. Настройте на воркерах метрику RSS процесса и memory_get_peak_usage(true) в конце каждой смены — рост от деплоя к деплою виднее любого код-ревью. Для очередей включите ограничение задач на процесс (перезапуск воркера после N задач), чтобы медленная утечка не копилась бесконечно, пока вы её ищете. И держите memory_limit осознанным: слишком высокий прячет утечку, слишком низкий роняет процесс на легитимном пике.
Проектная работа: От проблемы до защиты: продукт, этапы, вопросы комиссии
Спросить за 15 ₽Источники: PHP Manual: Garbage Collection, PHP Manual: memory_get_usage
Частые вопросы
У меня PHP 7.4, а не 8.x — замечания те же?
Я добавил gc_collect_cycles() в цикл — это правильное решение?
Память растёт, но профилировщик не показывает где — что дальше?
Стоит ли блокировать merge из-за роста памяти в веб-контроллере?
unset() гарантированно освобождает память?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.