Файловые сессии PHP убили наш сервер в 23:00. Вот что мы сделали
Наш сервер лёг в 23:00. Прямо во время маркетинговой акции.
CPU — 40%. Память — в норме. Запросов — примерно на 30% больше обычного. Nginx отвечает. PHP-FPM молчит. Новые запросы просто висят.
Мы смотрели в мониторинг 20 минут и не понимали, что происходит. Потом открыли FPM status page.
Что показал PHP-FPM
pm.status_path в Bitrix включается одной строкой в nginx.conf. Когда открыли — все 128 воркеров в статусе Reading headers или Running, очередь из 340 ожидающих запросов.
Это значит: у FPM закончились воркеры. Каждый занят, новые запросы ждут.
Но почему? Нагрузка не запредельная. Мы такое видели раньше — сервер справлялся.
Запустил strace на нескольких застрявших процессах:
flock(14, LOCK_EX) = 0
write(14, "...")
flock(14, LOCK_UN)
Файловый lock. На файлах сессий.
Как PHP блокирует сессии по умолчанию
Дефолтный session.save_handler = files — это значит, каждая пользовательская сессия хранится в отдельном файле в /tmp/sess_<session_id>.
При вызове session_start() PHP берёт на этот файл exclusive lock — LOCK_EX. Пока запрос не закроет сессию (session_write_close() или конец скрипта), файл заблокирован.
Для одного запроса на одну вкладку — нормально. Но в реальности:
- Пользователь открывает страницу каталога → это 1 основной запрос + 3-5 AJAX-запросов (виджеты, корзина, рекомендации).
- Все эти запросы идут от одного пользователя → один
session_id→ один файл. - PHP-FPM запускает их параллельно → все пытаются взять
LOCK_EXна один файл. - Первый взял. Остальные стоят в очереди.
Тогда было 340 одновременных пользователей. У каждого — современный фронт с параллельными AJAX-запросами. Итого 340 × 4 = ~1300 «параллельных» запросов, половина из которых стояла на file lock.
Именно это и убило FPM. Не нагрузка — очереди на блокировку.
Почему это не ловится в спокойный день
До 100-150 одновременных пользователей сервер справлялся. Запросы успевали отработать быстрее, чем очередь успевала расти.
В момент акции мы перешли порог — и эффект стал мультипликативным: воркеры заняты, новые запросы ждут, пользователи делают retry, очередь растёт.
Я проверил lsof | grep sess_ и увидел несколько тысяч открытых файловых дескрипторов на файлы сессий. Вот тогда стало понятно.
Переход на Redis-сессии
Мы уже использовали Redis для кеша Bitrix. Добавить Redis как session handler — 20 минут работы.
В php.ini (или в php-fpm.d/*.conf):
session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379?weight=1&timeout=2"
Для Bitrix нужно убедиться, что сессии Bitrix тоже пойдут через PHP-сессию. В большинстве установок это уже так — Bitrix использует стандартный PHP session_start(). Проверить можно в bitrix/.settings.php: если 'session' не переопределён, значит всё идёт через php.ini.
Если у вас Redis для кеша и Redis для сессий — разнесите их по разным базам (параметр database в save_path). Иначе случайный FLUSHDB выкинет всех пользователей из авторизации.
; для сессий — database 1, для кеша — database 0
session.save_path = "tcp://127.0.0.1:6379?weight=1&timeout=2&database=1"
Подводные камни, которые стоили нам часа
Redis не имеет file lock — там lock реализован через SET NX с TTL. Это убирает очереди, но добавляет нюанс: если запрос долго держит сессию открытой, lock может истечь раньше завершения. Параметр redis.session.lock_expire решает это — мы поставили 30 секунд, для наших самых тяжёлых запросов хватает.
TTL сессий нужно синхронизировать явно. Дефолт gc_maxlifetime = 1440 в Redis сам по себе не применяется:
session.gc_maxlifetime = 1440
redis.session.lock_expire = 30
Ещё одно: Redis persistence. Если Redis перезапустится без appendonly yes — все сессии умрут, пользователей выкинет. В dev это терпимо, в prod — нет.
Что изменилось в цифрах
До переключения:
- p95 latency на страницах каталога: 3.8s
- Пиковое количество занятых FPM-воркеров: 127/128
- Количество ошибок 504 в час акции: ~1100
После:
- p95: 0.7s
- Занятые воркеры в том же трафике: 38-42/128
- Ошибок 504: 0
Сервер справился с пиком на следующий день без изменений в железе.
Что теперь делаю на каждом проекте
session.save_handler — первая строчка в списке проверок при заходе на новый PHP-проект. Вместе с OPcache hit ratio и N+1 в iblock-запросах. Это три вещи, которые стабильно убивают production Bitrix без каких-либо очевидных симптомов в спокойный день.
Подробнее про OPcache — в отдельной статье про мисконфигурацию OPcache в production. Там схожий паттерн: дефолтные настройки, которые нормально работают на малом трафике и ломаются в пик.
Файловые сессии — не баг. Это разумный дефолт для сайта, где 20 человек одновременно. Когда вы перешли порог 200+ параллельных пользователей с современным SPA-фронтом — file lock становится тем же, чем OPcache без revalidate_freq: тихой миной.
Redis — не серебряная пуля. Но для этой конкретной задачи он убирает проблему полностью.