OPcache в PHP: как устроен изнутри и что ломается первым
OPcache экономит компиляцию PHP-скриптов, но под нагрузкой сюрпризы прячутся в разделяемой памяти, таблице строк и рестартах. Разбираем механизм и границы.

Типичная картина: деплой на PHP-FPM прошёл, но часть воркеров минуту отдаёт старый код, а после нескольких выкаток в логах появляется opcache: cache is full и latency резко растёт. Виноват не «глючный кеш», а понятное поведение OPcache: как он хранит скомпилированный байткод в разделяемой памяти, когда делает рестарт и почему при исчерпании памяти или строкового буфера перестаёт кешировать новые файлы. Разберём внутреннее устройство на примере PHP 8.x и опции, которые определяют, переживёт ли кеш прод.
Что OPcache вообще кеширует
Без OPcache каждый HTTP-запрос заставляет PHP заново читать .php-файлы, парсить их в AST и компилировать в набор опкодов Zend VM. Эта работа повторяется на каждом запросе и на каждый файл, который к нему подключился. OPcache компилирует файл один раз, складывает готовые опкоды в разделяемую память (SHM1) и на следующих запросах отдаёт их напрямую, минуя лексер, парсер и компилятор.
Ключевое слово — разделяемая. Память выделяется один раз на весь процесс мастера FPM (через mmap или shmget, зависит от опции opcache.preferred_memory_model) и видна всем воркерам. Поэтому 40 воркеров FPM не держат 40 копий байткода одного контроллера — они смотрят в один регион.
Кеш опкодов — это не «ускоритель PHP вообще». Это способ не платить за компиляцию повторно. Если ваш код быстро исполняется, но много файлов подключается на запрос, выигрыш от OPcache больше, чем от любой микрооптимизации логики.
Внутри разделяемой памяти
Регион SHM, размер которого задаёт opcache.memory_consumption (в мегабайтах), делится на несколько логически разных областей:
- Область для скомпилированных скриптов — сами структуры
zend_persistent_script: опкоды, таблицы функций и классов файла, литералы. - Interned strings buffer — общий буфер интернированных строк. Его размер отдельно задаётся
opcache.interned_strings_buffer(в мегабайтах) и вычитается из общего пула. - Служебные структуры — хеш-таблица, которая отображает путь к файлу на скомпилированный скрипт.
Аллокатор внутри SHM устроен просто: память нарезается последовательно, освобождения отдельных скриптов «на месте» нет. Когда файл перекомпилировали (например, после изменения на диске), старая версия не удаляется точечно — она остаётся мусором до полного рестарта кеша. Именно из-за этого частые деплои постепенно заполняют память, даже если реальный объём кода не растёт.
Interned strings — тихий лимит
Каждый идентификатор в PHP — имя функции, класса, ключа массива, строковый литерал — при компиляции интернируется: вместо множества копий одной строки хранится одна, а остальные ссылаются на неё. Буфер этих строк общий на весь кеш. На больших кодовых базах с сотнями классов и тысячами литералов он переполняется раньше, чем основная память. Симптом: кеш вроде не заполнен по used_memory, но новые файлы не кешируются, а interned_strings_usage.free_memory близок к нулю.
Как файл попадает в кеш и когда обновляется
При первом обращении к скрипту OPcache компилирует его, кладёт в SHM и запоминает временную метку файла (mtime). На последующих запросах поведение зависит от двух опций:
opcache.validate_timestamps— если включено, OPcache сверяетmtimeфайла и при изменении перекомпилирует. Частота проверки задаётсяopcache.revalidate_freq(секунды): 0 — проверять на каждом запросе, 60 — не чаще раза в минуту.- Если
validate_timestampsвыключено, OPcache вообще не смотрит на диск. Обновить код можно только полным сбросом кеша.
На проде выключенный validate_timestamps даёт максимальную скорость (нет ни одного stat на файл), но требует явного сброса при деплое — иначе воркеры бесконечно крутят старый байткод.
Два вида рестарта — и почему это больно
Рестарт кеша — это сброс всей SHM и повторная компиляция всего с нуля. OPcache запускает его в двух случаях, и оба стоит понимать:
- OOM-рестарт — свободной памяти в SHM меньше
opcache.max_wasted_percentageот общего объёма (доля «мусора» от перекомпиляций). Кеш решает, что пора чиститься. - Hash-рестарт — число закешированных файлов упёрлось в
opcache.max_accelerated_files. Значение округляется вверх до ближайшего простого числа из набора, поэтому «поставил 10000 — получил 16229».
Проблема в механизме ожидания. Когда назначен рестарт, OPcache ждёт, пока все текущие воркеры завершат свои запросы, и только потом пересобирает кеш. Пока идёт ожидание, новые запросы могут обслуживаться без кеша — с полной компиляцией на каждом. Если рестарты частые, вы получаете периодические провалы производительности, которые сложно связать с деплоем.
Зависшие рестарты
Отдельная граница — долгоживущий запрос. Если один воркер завис на медленном внешнем вызове, рестарт не может завершиться, пока тот не отпустит память. Опция opcache.force_restart_timeout (по умолчанию 180 секунд) задаёт, сколько OPcache ждёт, прежде чем принудительно завершить залипший процесс и записать в лог сообщение вида opcache: Restart scheduled but stuck.
Что и как мерить
Реальные цифры зависят от вашей кодовой базы, поэтому вместо выдуманных чисел — что смотреть. Функция opcache_get_status() возвращает состояние кеша; выведите ключевые поля коротким скриптом:
<?php
// PHP 8.x. Запускать на том же SAPI, где работает прод (FPM),
// иначе увидите отдельный кеш CLI.
$status = opcache_get_status(false);
$mem = $status['memory_usage'];
$strings = $status['interned_strings_usage'];
$stats = $status['opcache_statistics'];
// Доля занятой основной памяти
printf("used: %.1f%%, wasted: %.1f%%\n",
100 * $mem['used_memory'] / ($mem['used_memory'] + $mem['free_memory'] + $mem['wasted_memory']),
100 * $mem['wasted_memory'] / ($mem['used_memory'] + $mem['free_memory'] + $mem['wasted_memory'])
);
// Свободное место в буфере интернированных строк
printf("interned free: %d bytes\n", $strings['free_memory']);
// Попадания в кеш и число рестартов
printf("hit rate: %.2f%%\n", $stats['opcache_hit_rate']);
printf("cached scripts: %d / max, oom restarts: %d, hash restarts: %d\n",
$stats['num_cached_scripts'],
$stats['oom_restarts'],
$stats['hash_restarts']
);
На что смотреть под нагрузкой:
- hit rate ниже 99% на стабильном проде без деплоев — сигнал, что файлы вылетают из кеша (мало памяти или упёрлись в лимит файлов).
- oom_restarts и hash_restarts растут между деплоями — кеш пересобирается сам, ищите причину в двух лимитах выше.
- interned free около нуля при не заполненной основной памяти — увеличивайте
interned_strings_buffer. - num_cached_scripts близко к округлённому
max_accelerated_files— поднимайте лимит с запасом.
Ключевые опции и их роль
| Опция | Что задаёт | Симптом, если мало |
|---|---|---|
opcache.memory_consumption | Размер SHM в МБ | OOM-рестарты, падение hit rate |
opcache.interned_strings_buffer | Буфер интернированных строк, МБ | Новые файлы не кешируются при свободной памяти |
opcache.max_accelerated_files | Лимит файлов (округляется до простого) | Hash-рестарты, вытеснение скриптов |
opcache.validate_timestamps | Сверять ли mtime файлов | Лишние stat на каждый запрос при 1 |
opcache.revalidate_freq | Как часто сверять mtime, сек | Задержка подхвата нового кода |
opcache.max_wasted_percentage | Доля мусора для OOM-рестарта | Частые рестарты при активных деплоях |
Точные значения по умолчанию и полный список директив зависят от версии PHP — сверяйтесь с официальной документацией opcache.configuration для вашей сборки, а не с чужим php.ini из статьи трёхлетней давности.
Где механизм ломается первым
- Деплой копированием поверх. Если вы перезаписываете файлы в той же директории при выключенном
validate_timestamps, воркеры продолжают отдавать старый байткод, пока кеш не сброшен. Сброс черезopcache_reset()провоцирует холодную компиляцию всего — краткий провал производительности сразу после деплоя. - Атомарный деплой через симлинк без учёта путей. OPcache кеширует по абсолютному пути. Если путь релиза меняется (новая папка
release-2024xxxx), кеш старого пути становится мусором, а новый компилируется с нуля. Директория растёт, память — тоже. - Общий кеш между несовместимыми приложениями. Один пул FPM с разными проектами делит одну SHM. Крупный монолит может вытеснять файлы соседа.
- CLI и FPM — разные кеши.
opcache_reset(), вызванный из CLI-скрипта, не трогает кеш FPM. Сбрасывать нужно в том же SAPI, где работает прод.
Preloading — отдельная история
С PHP 7.4 появился preloading (opcache.preload): часть классов компилируется и загружается в память один раз при старте мастера FPM и живёт там до перезапуска процесса, минуя обычный механизм инвалидации. Это отдельный слой поверх OPcache со своими граничными случаями — например, предзагруженные классы нельзя обновить без рестарта FPM. Здесь мы preloading не разбираем, только помечаем как смежную тему.
Что сделать дальше
Снимите opcache_get_status() с боевого FPM и посмотрите четыре показателя: hit rate, число рестартов, свободную память и свободный буфер строк. Если рестарты растут между деплоями — сначала поднимите память и лимит файлов с запасом, а не крутите max_wasted_percentage. Настройте деплой так, чтобы после выкатки был контролируемый прогрев кеша, а не холодная компиляция под живым трафиком. Дальше стоит посмотреть в сторону preloading и — для стабильных путей релизов — в сторону JIT в PHP 8.x, который работает поверх OPcache и включается отдельными директивами.
1 SHM (shared memory) — разделяемая память: регион, доступный сразу нескольким процессам. В OPcache в него кладут скомпилированный байткод, чтобы все воркеры FPM читали одну копию, а не хранили свою.
Магистр Excel и Google Sheets: ВПР, СУММЕСЛИМН, макросы VBA и скрипты Apps Script.
Спросить за 20 ₽Источники: PHP Manual: OPcache configuration directives, PHP Manual: opcache_get_status
Частые вопросы
У меня другая версия PHP — значения по умолчанию совпадут?
После деплоя часть пользователей видит старый код. Почему?
Новые файлы не кешируются, но по used_memory место есть. В чём дело?
Опасно ли выключать validate_timestamps на проде?
Стоит ли трогать opcache.max_wasted_percentage, чтобы убрать частые рестарты?
opcache_reset() из CLI-скрипта не сбросил кеш FPM. Это баг?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.