💀 Как один ORM-запрос съел 300 МБ навсегда

История из нашего проекта: неоптимальный запрос через ORM загрузил ~100К записей из базы. Worker в RoadRunner вырос с 80 до ~500 МБ... и больше никогда не вернулся обратно.

Звучит как утечка памяти? Спойлер: это не баг, а особенность архитектуры PHP.

💡 Неочевидный факт: PHP никогда не возвращает память ОС в long-running процессах

Zend Memory Manager выделяет память большими блоками — чанки по ~2-4 МБ. Когда вы делаете unset($data) или переменная выходит из scope — память освобождается ВНУТРИ PHP, но операционная система об этом не узнаёт.

Чанки остаются закреплёнными за процессом до его завершения. Это сделано ради производительности: повторное использование уже выделенной памяти быстрее, чем постоянные запросы к ОС.

Почему это критично для RoadRunner, Laravel Queue, демонов?

В PHP-FPM каждый запрос = новый процесс, память автоматически очищается. В long-running процессах один worker обрабатывает тысячи задач — и каждый "пик" памяти становится новым baseline навсегда.

Ну а теперь про то, как себя обезопасить от всего этого?

  • Использовать батчинг для выборок
  • Ограничивать объём данных в памяти в рамках одной операции
  • Обрабатывать данные потоково, без загрузки всего набора сразу
  • Выносить тяжёлые операции в отдельные функции для автоматического освобождения локальных переменных
  • Применять gc_mem_caches() для очистки внутренних пулов, и молиться, что поможет именно в вашем случае)
  • Анализировать участки кода, создающие единовременные всплески в десятки МБ. Лучше не игнорировать всплески памяти, надеясь что “само освободится”

Что можно сделать на уровне PHP воркеров: 1. Можно использовать ротацию по количеству задач, например в RoadRunner pool.max_jobs=1000, Laravel Queue --max-jobs=1000. После выполнения 1000 задач, воркер будет перезапущен. Но не стоит полагаться на ротацию как на замену оптимизации, ведь пик может возникнуть в первых же запросах. 2. В RoadRunner — при необходимости включать soft-лимиты по памяти, при достижении которого воркер перезапускается.

Куда ещё может утекать память:

  • Статические переменные, накапливающие данные
  • Глобальные массивы, в которых растёт состояние
  • Singleton-сервисы с неограниченными кешами
  • ORM-кеши, которые не сбрасываются
  • Списки подписчиков/слушателей, которые не удаляются
  • Циклические ссылки объектов друг на друга.
💀 Как один ORM-запрос съел 300 МБ навсегда
История из нашего проекта: неоптимальный запрос через ORM загрузил ~100К записей из базы. Worker в RoadRunner вырос с 80 до ~500 МБ.. | Сетка — социальная сеть от hh.ru