Утечки памяти в долгоживущем PHP-процессе: что ревьюить
Воркер съедает 512 МБ за час и падает по OOM. Разбираем, почему это происходит именно в долгоживущих PHP-процессах и какие паттерны в коде ищет глазами ревьюер.

Классический сценарий: очередь на Symfony Messenger или воркер на Swoole/RoadRunner живёт часами, обрабатывает сотни тысяч задач и медленно распухает по памяти. В стандартном PHP-FPM этого не видно — процесс живёт доли секунды и умирает, забирая всё с собой. В долгоживущем процессе каждый забытый объект остаётся до OOM Killer или до Fatal error: Allowed memory size ... exhausted. Разберём, что порождает рост памяти, как это увидеть в замерах и какие конструкции подсвечивать на ревью.
Почему в долгоживущем процессе течёт то, что не течёт в FPM
PHP освобождает память двумя механизмами. Основной — подсчёт ссылок (refcounting): как только на значение не осталось ссылок, память отдаётся немедленно. Второй — сборщик циклических ссылок1, который догоняет объекты, ссылающиеся друг на друга. В коротком запросе оба механизма почти не важны: процесс всё равно умрёт через 50 мс и вернёт память ОС. В воркере, который крутится в бесконечном while, важен каждый объект, переживший итерацию.
Ключевое отличие для ревьюера: в FPM «утечка» на одну корзину покупок безобидна. В воркере та же корзина, если ссылка на неё не обнулилась, копится по объекту на итерацию. При 100 000 задач это уже гигабайты.
Утечка в PHP-воркере — это почти всегда не баг рантайма, а объект, у которого осталась живая ссылка там, где вы про неё забыли: в статике, в контейнере или в замыкании.
Пять источников роста памяти — что ищем в коде
1. Статические свойства и глобальные реестры
Самый частый виновник. Статическое свойство живёт до конца процесса, а не до конца итерации. Кеш, накопленный в статике без ограничения размера, растёт линейно с числом обработанных задач.
// PHP 8.x — так делать нельзя в воркере
class PriceCache
{
// статический массив живёт весь процесс и не чистится
private static array $cache = [];
public static function get(int $productId): float
{
return self::$cache[$productId] ??= self::loadFromDb($productId);
}
}
На ревью: любой static array, в который что-то []= добавляется в цикле обработки, — красный флаг. Спрашивайте, где ограничение размера и где сброс между задачами.
2. Контейнер зависимостей, накапливающий состояние
DI-контейнер по умолчанию хранит созданные сервисы как синглтоны. Если сервис внутри себя копит данные (лог обработанных ID, буфер событий), он не пересоздаётся между задачами и растёт. В Symfony Messenger для этого есть сервисы, сбрасывающие состояние ядра между сообщениями — их подключают через конфигурацию reset_on_message; точный ключ и поведение сверяйте с документацией вашей версии Symfony, оно менялось.
3. Замыкания, держащие $this
Замыкание неявно захватывает $this объекта, в котором объявлено. Если такое замыкание положить в долгоживущий обработчик событий или в очередь колбэков, вы удерживаете весь объект со всеми его полями.
// Замыкание держит весь $this, включая тяжёлый $this->rawPayload
$dispatcher->on('done', function () {
$this->log($this->rawPayload); // rawPayload не освободится
});
// Захватываем только нужное — объект освободится
$payload = $this->rawPayload;
$dispatcher->on('done', static function () use ($payload) {
// static function не захватывает $this
});
4. Циклические ссылки при отключённом GC
Объект A ссылается на B, B — обратно на A. Refcounting такую пару не соберёт никогда, её убирает только циклический сборщик. Если кто-то в коде вызвал gc_disable() ради «производительности», такие пары копятся до OOM. Проверьте на ревью, что сборщик не выключен на весь процесс. Для контроля есть gc_collect_cycles() — принудительный проход, который можно звать после каждой N-й задачи.
5. Ресурсы и внешние хендлы
Открытые файлы, курсоры БД, соединения. PDO при построчной выборке большого результата с буферизацией на клиенте держит весь набор в памяти. Незакрытый файловый указатель в цикле — тоже рост, пока не сработает GC. Здесь ищем открытие внутри итерации без явного закрытия.
Как это увидеть, а не угадать
Не гадайте по коду — снимайте цифры. Минимальный набор для локализации:
memory_get_usage(true)— реально запрошенная у ОС память. Логируйте её каждые N итераций и стройте график: линейный рост означает утечку, пила вокруг стабильного уровня — норма.memory_get_peak_usage(true)— пик, чтобы поймать разовые всплески на тяжёлой задаче, а не постоянный рост.- Профилировщик с дифом кучи. Снимите снимок памяти на 100-й и на 10000-й итерации и сравните, каких объектов стало на порядок больше — это и есть виновник.
Схема замера в самом воркере (PHP 8.x):
declare(strict_types=1);
$iteration = 0;
while ($task = $queue->pop()) {
$handler->handle($task);
$iteration++;
// Каждые 1000 задач фиксируем память в лог
if ($iteration % 1000 === 0) {
$mb = round(memory_get_usage(true) / 1048576, 1);
error_log(sprintf('iter=%d rss=%sMB', $iteration, $mb));
}
// Освобождаем ссылку на задачу до следующей итерации
unset($task);
}
Если после unset и очистки переменных цикла память всё равно растёт линейно — утечка в чём-то долгоживущем: статике, контейнере или обработчике событий.
Замечания на ревью: конкретные формулировки
«Обратите внимание на память» — бесполезное замечание. Пишите предметно:
- «Статический
self::$cacheв цикле воркера не имеет предела размера. Добавьте LRU-ограничение или сбрасывайте между задачами — иначе линейный рост RSS». - «Замыкание в строке 42 захватывает
$this, а нужен только$id. Сделайтеstatic functionсuse ($id), чтобы не удерживать весь объект». - «PDO-выборка без
fetchпо строкам загрузит весь результат в память. Для больших наборов итерируйте курсором». - «Сервис-синглтон копит
$this->processedIds. В воркере он не пересоздаётся — нужен сброс между сообщениями».
Рефакторинг: три стратегии и их цена
| Подход | Что выигрываете | Чем платите |
|---|---|---|
| Перезапуск воркера по лимиту задач (например, каждые N сообщений) | Гарантированное освобождение памяти без поиска причины | Оверхед на прогрев процесса; маскирует, а не лечит утечку |
| Сброс состояния сервисов между задачами | Устраняет накопление в синглтонах, процесс живёт долго | Нужно знать все сервисы с состоянием; легко пропустить один |
| Точечное устранение (статика, замыкания, unset) | Лечит корень, память стабильна | Дороже по времени: нужен профайлинг и понимание кода |
На практике их комбинируют: точечно чинят найденные утечки, а перезапуск по лимиту оставляют как страховку от того, чего не нашли. RoadRunner и Symfony Messenger умеют перезапускать воркер по порогу памяти или числу задач — параметры и ключи конфигурации смотрите в документации конкретной версии, они различаются.
Где подход ломается
Не всякий рост RSS — утечка. PHP не всегда возвращает освобождённую память ОС: аллокатор может держать её в пуле для повторного использования. Поэтому смотрите на тренд, а не на разовый рост: если память выросла до 200 МБ и держится на этом уровне сотни тысяч итераций — это рабочий набор, а не течь.
Второй граничный случай — расширения на C (например, некоторые драйверы БД или image-библиотеки). Их память не видна счётчику memory_get_usage(), потому что она аллоцирована вне менеджера памяти PHP. Такую утечку ищут по RSS процесса на уровне ОС (ps, /proc/PID/status), а не через встроенные функции.
Что делать дальше
Перед выкатом воркера в прод настройте наблюдаемость: логируйте RSS каждые N задач и заведите алерт на линейный тренд. Ограничьте процесс по времени жизни или числу задач как страховку. Один раз пройдитесь профайлером с дифом кучи на нагрузочном прогоне — это дешевле, чем ловить OOM в три часа ночи. Инвалидация кешей и настройка воркер-пула — отдельные темы, здесь мы разбирали только рост памяти в самом процессе.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: PHP Manual: Garbage Collection, PHP Manual: memory_get_usage
Частые вопросы
У меня не Symfony, а самописный воркер на голом PHP — советы применимы?
Я поставил unset после каждой задачи, а память всё равно растёт. Что не так?
Можно просто перезапускать воркер каждые 1000 задач и не искать причину?
Стоит ли вызывать gc_collect_cycles() вручную в цикле?
memory_get_usage показывает стабильную память, а процесс всё равно растёт по ps — почему?
Отличается ли поведение памяти между PHP 7.x и 8.x?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.