Назад в блог

Четыре PHP-поведения, которые мы поймали только в проде Bitrix

Мы три дня искали, почему страницы каталога тормозят при конкурентных запросах. Профилировщик показывал: время уходит в ожидание. Nginx — чистый. MySQL — idle. FPM workers — свободны. Оказалось: session_start() ставит файловый lock, и все параллельные запросы одного пользователя выстраиваются в очередь. PHP ведёт себя именно так, как написано в документации. Просто эту страницу документации никто не читает перед деплоем в прод.

Это другой класс проблем, чем конфиг opcache.memory_consumption или pm.max_children. Все типичные инфра-проблемы Bitrix видно в мониторинге. Языковое поведение PHP в продакшн Bitrix — нет. Оно проявляется при нагрузке, выглядит как что угодно другое и не лечится перезагрузкой PHP-FPM.

Вот четыре поведения PHP, которые мы поймали только в production Bitrix.


Session locking: PHP выстраивает запросы в очередь

По умолчанию PHP хранит сессии в файлах и при session_start() берёт эксклюзивный flock на файл сессии. Пока один request держит сессию открытой — все остальные запросы того же пользователя (с тем же session ID) ждут.

В обычном приложении это незаметно: запросы идут один за другим, пользователь не делает параллельных вкладок. В Bitrix-каталоге под нагрузкой картина другая. Страница товара, Ajax-запрос наличия, фасетный фильтр — три параллельных запроса, один session ID. Первый берёт lock. Два остальных ждут.

У нас это выглядело так: TTFB у части запросов был 600–1400 мс вместо обычных 180 мс. strace на FPM-воркеры показывал: они живые, но блокированы на flock(). Не на MySQL, не на opcache — на файле сессии.

Первый вариант — Redis sessions. Переход описан в отдельной статье, он убирает и проблему блокировки, и риск падения при нагрузке. Минус: нужен Redis.

Второй — session_write_close() после того, как сессия прочитана. Если запрос не пишет в сессию, её незачем держать открытой. Вставить после блока авторизации — и lock уходит. Это рабочий quick fix, который не требует Redis.

Цифра из нашего проекта: после session_write_close() для страниц каталога (без записи в сессию) среднее время ожидания параллельных Ajax-запросов упало с 820 мс до 40 мс на той же инфраструктуре.


OPcache при деплое: старые opcode-ы держатся дольше, чем ожидаешь

OPcache компилирует PHP-файлы в opcode и кеширует в shared memory. При обновлении файла на диске OPcache не узнаёт об этом мгновенно — он сверяет timestamp по расписанию (opcache.revalidate_freq) или вовсе не проверяет, если opcache.validate_timestamps = 0.

В статье про конфигурацию OPcache мы разобрали базовые настройки. Здесь интереснее механика гонки при деплое.

Деплой Bitrix через FTP — это обновление файлов по одному. Пока половина файлов обновлена, а половина — нет, OPcache держит в памяти скомпилированные версии обеих. Запрос, который попадает на воркер с частично старым кешем, получает смешанное состояние: новые классы и старые методы в одной исполнении.

Симптомы: случайные PHP Fatal errors после деплоя, которые воспроизводятся не всегда и проходят сами через несколько минут. Разработчики обычно списывают на «что-то с кешем Bitrix» — но это OPcache.

Правильный деплой для production Bitrix: атомарный swap директорий (symlink-подход) плюс opcache_reset() сразу после переключения. Либо — opcache.validate_timestamps = 1 с коротким revalidate_freq = 2 во время деплойного окна, и возврат к 0 после.

Если деплой через FTP без атомарности — добавить в конец деплой-скрипта вызов opcache_reset() через HTTP или CLI. Без этого окно гонки может длиться до revalidate_freq секунд.


Copy-on-write для массивов: память удваивается без предупреждения

PHP использует copy-on-write: передавать массив в функцию без & дёшево — копия создаётся только при первой записи. Но при первой записи копия создаётся полная, и если массив большой — это удвоение памяти в один момент.

В Bitrix-каталоге из 28K SKU это проявилось так. Был обработчик выборки товаров — большой ассоциативный массив ($items) передавался в несколько вложенных функций для форматирования, подстановки цен, фасетов. Каждая функция читала массив без записи — пока мы не добавили туда нормализацию полей. После этого одна из функций стала модифицировать $items, и PHP создавал полную копию.

Массив ~14 МБ → временный пик ~28 МБ при каждом запросе к каталогу под нагрузкой. На 30 одновременных запросах это +420 МБ к пиковому потреблению памяти. FPM начинал убивать воркеры.

Диагностика простая: memory_get_peak_usage() до и после подозрительной функции. Если разница больше memory_get_usage() до вызова — функция создала копию.

Можно передавать по ссылке (&$items) если функция модифицирует. Либо разделить обработчики на read-only и write — держать write-операции в минимуме.

Для Bitrix-каталога мы ввели правило: функции форматирования всегда работают с копией (это дёшево, если не пишут), функции мутации — явно принимают и возвращают $items.


Деструкторы и register_shutdown_function: порядок, который нас удивил

PHP выполняет __destruct() объектов и функции из register_shutdown_function() при завершении скрипта. Порядок — не детерминированный: деструкторы вызываются в обратном порядке создания объектов, shutdown-функции — в порядке регистрации, но их перемешивание с деструкторами зависит от PHP-версии.

В Bitrix это выстрелило при переходе с PHP 7.4 на 8.1. У нас был класс-логгер с __destruct(), который писал финальный entry в файл. И shutdown-функция, которая закрывала database connection. На PHP 7.4 деструктор логгера успевал до закрытия соединения. На PHP 8.1 — нет: в части случаев shutdown-функция закрывала соединение раньше, и логгер падал с PDO: connection already closed.

Проблема: это воспроизводилось нестабильно, только под нагрузкой (когда PHP завершал скрипты через memory limit). В dev-окружении не было.

Правило, которое мы ввели: не закладываться на порядок вывоза при завершении скрипта. Критические операции — flush логов, запись финального состояния — делать в явном месте, до выхода из index.php, а не в деструкторе.


Как находить такие проблемы

Инфра-метрики (CPU, RAM, RPS) эти проблемы не покажут — или покажут следствие, а не причину.

strace -p <pid> -e trace=file,futex на FPM-воркер под нагрузкой. Session locking сразу виден как серия flock() с долгим ожиданием.

opcache_get_status() после деплоя — hits vs misses по файлам. Если hits резко упал, кеш сбросился или не подхватил новые файлы.

memory_get_peak_usage(true) в debug-режиме Bitrix на конкретных роутах. Разница между роутами, которые должны быть похожи — первый признак array CoW.

set_exception_handler() + set_error_handler() с полным stack trace в лог. В production деструктор часто падает молча.


Чеклист: четыре вопроса перед деплоем

Перед любым production-деплоем Bitrix я проверяю:

  1. Настроен ли Redis или session_write_close() для read-only роутов?
  2. Есть ли opcache_reset() в деплой-скрипте после обновления файлов?
  3. Есть ли memory_get_peak_usage() логирование на каталожных роутах?
  4. Нет ли критических операций в деструкторах, которые зависят от открытых соединений?

Четыре вопроса. Десять минут перед деплоем. Три дня потом — сэкономлено.

Все четыре поведения задокументированы в PHP manual. Это не баги — это язык, который работает именно так, как написано. Просто в production Bitrix написанное становится видимым.