Zend Engine и opcache под нагрузкой: что ломается первым
На пиковом трафике PHP-приложение начинает есть память и отдавать пятисотки не из-за кода, а из-за настроек opcache. Разбираем, где именно ломается, что и как мерить, чем расплачивается каждый вариант.

Классический сценарий: PHP 8.2 под php-fpm, магазин на пиковой распродаже, и в какой-то момент вместо страниц приходят 500-е. В логах — Fatal error: Out of memory и всплески времени ответа с 40 мс до 900 мс на ровном месте. Код не менялся, релиза не было. Проблема не в приложении, а в том, как Zend Engine компилирует скрипты и как opcache хранит результат этой компиляции в разделяемой памяти. Под нагрузкой первым ломается именно этот слой, и ломается тихо.
Разбираем механизм: как исходник превращается в опкоды, что opcache кэширует и почему, и три места, которые деградируют раньше самого приложения — переполнение памяти кэша, рестарты по фрагментации и просадка от постоянной ревалидации файлов.
Что делает Zend Engine на каждый запрос
PHP не интерпретирует исходник построчно. Zend Engine — виртуальная машина внутри PHP — проходит три стадии: лексер разбивает текст на токены, парсер строит абстрактное синтаксическое дерево (AST), компилятор превращает AST в опкоды1 — низкоуровневые инструкции, которые исполняет VM.
Стадии до исполнения (лексинг, парсинг, компиляция) детерминированы: один и тот же файл всегда даёт одни и те же опкоды. Значит, их можно посчитать один раз и переиспользовать. Этим и занимается opcache: он держит скомпилированные опкоды в общей памяти (SHM), и на повторных запросах Zend Engine пропускает компиляцию, сразу переходя к исполнению.
Без opcache каждый запрос заново компилирует все подключённые файлы. На фреймворке с сотнями классов это десятки миллисекунд чистого CPU на компиляцию — до того, как выполнится хоть одна строка бизнес-логики.
Пока трафик низкий, всё это незаметно. Под нагрузкой проявляются ограничения хранилища опкодов.
Место первое: переполнение memory_consumption
opcache выделяет фиксированный кусок разделяемой памяти под опкоды — параметр opcache.memory_consumption (в мегабайтах). Когда закэшированные скрипты перестают в него влезать, кэш переходит в состояние, где новые файлы уже не кэшируются, а старые не вытесняются: у opcache нет LRU-вытеснения по одному файлу, он умеет только полный сброс.
Практический эффект: часть файлов исполняется из кэша, а часть компилируется на каждом запросе заново. На распродаже, когда в дело вступают редко используемые контроллеры и шаблоны, доля некэшированных скриптов растёт — и время ответа расползается неравномерно, что сбивает с толку при диагностике.
Как это увидеть
Состояние кэша отдаёт функция opcache_get_status(). Ключевые поля — заполнение памяти и число закэшированных ключей:
<?php
// diag.php — вызвать на боевом хосте, где работает opcache (PHP 8.x)
$status = opcache_get_status(false);
$mem = $status['memory_usage'];
$used = $mem['used_memory'];
$free = $mem['free_memory'];
$wasted = $mem['wasted_memory'];
printf("Занято: %.1f MB\n", $used / 1048576);
printf("Свободно: %.1f MB\n", $free / 1048576);
printf("Фрагментация: %.1f%%\n", $mem['current_wasted_percentage']);
$keys = $status['opcache_statistics'];
printf("Ключей: %d из %d\n", $keys['num_cached_keys'], $keys['max_cached_keys']);
printf("Промахи: %d\n", $keys['misses']);
printf("Рестарты OOM: %d\n", $keys['oom_restarts']);
Смотрите на два числа. Если free_memory близко к нулю, а misses растёт при стабильном числе файлов — памяти не хватает. Если oom_restarts больше нуля — кэш уже переполнялся и сбрасывался целиком.
Место второе: рестарты по фрагментации
Второй лимит скрыт — это max_accelerated_files, максимальное число слотов под скрипты. Значение округляется вверх до ближайшего простого числа из внутреннего набора. Если реальных PHP-файлов в приложении больше, чем слотов, часть просто не попадёт в кэш, сколько бы памяти вы ни выдали.
Отдельная беда — фрагментация. При деплое старые версии файлов помечаются как устаревшие, но память под них не освобождается сразу — она превращается в wasted_memory. Когда доля "мусора" превышает opcache.max_wasted_percentage (по умолчанию 5%), opcache инициирует полный рестарт: сбрасывает весь кэш и компилирует всё заново.
Полный рестарт под нагрузкой — это момент, когда все воркеры одновременно теряют кэш и начинают компилировать. CPU подскакивает, время ответа проваливается на несколько секунд, часть запросов упирается в таймаут. На графике это выглядит как периодические пики, привязанные к деплоям или к достижению порога фрагментации.
Самая частая причина "загадочных" пиков латентности на PHP — не приложение и не база, а полный рестарт opcache, который никто не мониторит.
Что с этим делать
- Считайте реальное число файлов:
find /path/to/app -name '*.php' | wc -l, и ставьтеopcache.max_accelerated_filesс запасом сверху. - Задайте памяти с запасом на две версии кода одновременно — во время деплоя в кэше временно живут и старая, и новая.
- Не деплойте перезаписью файлов на месте. Используйте атомарный деплой через симлинк на новый релиз и перезапуск php-fpm — тогда новый пул воркеров стартует с чистым кэшем, а старый доживает свои запросы.
Место третье: ревалидация файлов на каждом запросе
По умолчанию opcache проверяет, не изменился ли файл на диске, сверяя mtime — параметр opcache.validate_timestamps=1 с интервалом opcache.revalidate_freq. Каждая такая проверка — это системный вызов stat() к файловой системе.
На локальном SSD это дёшево. На сетевой файловой системе (NFS, сетевые диски в облаке) или при большом числе подключаемых файлов на запрос сотни stat() складываются в заметную задержку, которая растёт линейно с трафиком. Под пиком это может дать больше просадки, чем сама компиляция.
Как мерить
Влияние ревалидации проще всего оценить сравнением двух конфигураций на одинаковой нагрузке. Прогоните нагрузочный тест (например, ab или wrk) с validate_timestamps=1 и с validate_timestamps=0 на одном и том же наборе запросов и сравните p95 времени ответа. Разница и есть цена ревалидации на вашей файловой системе. Абсолютные цифры зависят от диска и числа файлов, поэтому мерить надо на своём стенде, а не брать чужие.
Сравнение стратегий по timestamps
| Настройка | Что выигрываете | Чем платите | Когда выбирать |
|---|---|---|---|
| validate_timestamps=1, revalidate_freq>0 | Изменения файлов подхватываются без рестарта | stat() на каждый файл раз в интервал | Разработка, стейджинг |
| validate_timestamps=0 | Ноль stat(), максимум скорости | Изменения на диске игнорируются, нужен рестарт php-fpm или opcache_reset() | Прод с атомарным деплоем |
Границы: где подход ломается
- validate_timestamps=0 без дисциплины деплоя. Если кто-то правит файл прямо на боевом сервере, изменение не подхватится вообще — до перезапуска php-fpm. Это чинит проблему производительности и создаёт проблему "почему хотфикс не применился".
- opcache_reset() под нагрузкой. Ручной сброс из скрипта деплоя делает ровно то же, что рестарт по фрагментации: обнуляет кэш для всех воркеров разом. На пике это провал латентности.
- Preloading (opcache.preload). Начиная с PHP 7.4 можно загрузить классы в память при старте fpm. Это ускоряет холодный старт, но при ошибке в preload-скрипте master-процесс php-fpm не поднимется вообще — отказ всего сервиса вместо деградации.
- JIT. С PHP 8.0 opcache умеет JIT-компиляцию в машинный код. На веб-запросах с обычной бизнес-логикой выигрыш часто в пределах погрешности, а отладка усложняется. JIT — отдельная тема, здесь его не разбираем.
Что настроить и что мерить дальше
Минимальный набор для прода на PHP 8.x: выдать memory_consumption с запасом на две версии кода, поднять max_accelerated_files выше реального числа файлов, на проде с атомарным деплоем перейти на validate_timestamps=0 и перезапускать php-fpm при выкатке.
Дальше — поставьте на мониторинг три метрики из opcache_get_status(): процент занятой памяти, oom_restarts и current_wasted_percentage. Алерт на рост первого и ненулевой второй ловит переполнение до того, как оно превратится в 500-е. Точные значения параметров и их поведение в вашей версии сверяйте с разделом OPcache в официальной документации PHP — дефолты менялись между релизами.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: PHP Manual: OPcache Configuration, PHP Manual: opcache_get_status
Частые вопросы
У меня PHP 7.4, а не 8.x — эти настройки те же?
Как понять, что причина именно в opcache, а не в базе или в коде?
Я выставил validate_timestamps=0, а хотфикс не применяется. Что делать?
Стоит ли включать JIT ради производительности веб-приложения?
Почему opcache не вытесняет отдельные старые файлы, как обычный кэш?
Как посчитать, сколько памяти и слотов реально нужно?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.