Блокировки сессий в PHP и их отладка

Блог · 3 февраля 2016

Буквально несколько дней назад на сайте 1С-Битрикс была опубликована статья Николая Рыжонина о возможностях сократить случаи блокировки сессий для долгих обработок запросов. Мы же в своей статье решили в целом пройтись подробнее - почему эти блокировки происходят, почему PHP-процессы "залипают" на инициализации сессий - session_start() - и как с этим бороться.

Время от времени у наших клиентов в процессе отладки запросов, которые долго выполняются, возникает вопрос - почему сессии начинают долго работать? Выглядит проблема примерно так - неожиданно пользователь, пытаясь открывать новые страницы на сайте, не может дождаться их открытия, а если пользователь генерирует большое число таких запросов, это происходит уже у всех посетителей сайта. Apache/PHP-FPM забиваются процессами, ожидающими снятия блокировки с файла сессий, и часто - помогает только перезапуск этих сервисов.

Для того, чтобы разобраться, в чем же причина таких блокировок и как с ними бороться, давайте разберемся, как в PHP организована работа сессий.

Стандартный обработчик сессий в PHP реализует их хранение в файлах путем хранения сериализованного содержимого массива $_SESSION в файле в директории, заданной переменной session.save_path. При старте сессии функцией session_start PHP либо создает и открывает, либо повторно открывает файл сессии. При этом PHP выставляет глобальную блокировку на чтение этого файла на время работы с ним. Сделано это преднамеренно - в противном случае в условиях многопоточности несколько скриптов могут открыть один и тот же файл с сессией, изменить значения ее переменных, а затем закрыть и записать сессию в файл, и таким образом данные одного из скриптов будут утеряны.
Объяснить это можно простым примером: пусть есть скрипты 1.php и 2.php и некая сессия в которой уже выставлены следующие переменные:

$_SESSION['aaa']='111'; $_SESSION['bbb']='222'; $_SESSION['ccc']='333';

1.php:

<?php
session_start();
$_SESSION['aaa']='aaa';
//теперь массив сессии в этом скрипте:
//$_SESSION['aaa']='aaa';
//$_SESSION['bbb']='222';
//$_SESSION['ccc']='333';
?> 

2.php:

<?php
session_start();
sleep(10);
$_SESSION['bbb']='bbb';
//теперь массив сессии в этом скрипте:
//$_SESSION['aaa']='111';
//$_SESSION['bbb']='bbb';
//$_SESSION['ccc']='333';
?>

Если бы в PHP не была реализована блокировка сессий, а скрипты выполнились бы одновременно, результаты выполнения работы скрипта 1.php не были бы сохранены в сессии.

Все это в принципе приемлемо, до той поры, пока скрипты выполняются несколько сотен миллисекунд (что типично для веба), однако активное использование AJAX-а, активная разработка, сложные внутренние портальные системы создают большой риск для длительной блокировки сессий, источник которой приходится довольно нетривиально ловить.

Для примера - довольно часто мы видим, как некие сложные процессы, выполнение которых занимает длительное время, реализуют через AJAX-запрос. Пользователь заходит на страницу, где запускает, например, процесс импорта данных из внешнего сервиса. Импорт происходит в скрипте, через AJAX-запрос. Пока импорт происходит, пользователь может открыть и другие страницы. Если вдруг работа внешнего сервиса замедляется, а скрипт, занимающийся импортом использует сессии - файл сессий останется заблокированным на время выполнения этого скрипта. Соответственно ни одна страница сайта для этого пользователя не будет открыта до завершения его работы. Скрипт выполняется десятки секунд? Все это время сайт будет полностью недоступен для пользователя.

Ловить подобную проблему, если она начинает происходить массово, очень сложно. Если мы решим просто отследить, где висят запросы пользователя - большая часть запросов окажется висящими в состоянии session_start() - раздраженные пользователи пытаются снова и снова открыть недоступные страницы. Для того, чтобы ловить подобныю проблемы, мы рекомендуем использовать профилирующий модуль XHProf от фейсбука с последующей фильтрацией результатов его работы. XHProf сохраняет результаты работы в файл с сериализованным массивом статистики, который потом сам же использует для профилировки. Таким образом мы получаем набор файлов с результатами выполнения каждого запроса пользователя. Для того чтобы выбрать нужные нам результаты, необходимо открыть каждый файл и проверить, какое время выполнения занимала функция session_start() - нас интересуют только те, где выполнения скрипта было долгим, а выполнение этой функции - коротким, те самые скрипты, которые привели к блокировке. Мы для фильтрации используем следующий скрипт:

$thresholdTotal = 5*1000000;//five seconds in microseconds
$thresholdSession = 0.05*1000000;

$pattern = '*.xhprof';
$baseUrl = 'https://www.site.ru/xhprof/index.php';

foreach (glob($pattern) as $file) {

if (!is_array($data = unserialize(file_get_contents($file)))) {
continue;
};

if (!array_key_exists('main()', $data) || !array_key_exists('wt', $data['main()'])) {
continue;
}
$total = $data['main()']['wt'];

if (!array_key_exists('run_init::main/include.php==>session_start', $data) || !array_key_exists('wt', $data['run_init::main/include.php==>session_start'])) {
$sessionStart = 0;
} else {
$sessionStart = $data['run_init::main/include.php==>session_start']['wt'];
}

if ($total > $thresholdTotal && $sessionStart < $thresholdSession) {
$parts = explode('.', $file, 2);
$run = $parts[0];
$src = str_replace('.xhprof', '', $parts[1]);
$total /= 1000000;
$total = round($total, 3);
echo date('Y-m-d H:i:s', filectime($file)).' - '.$baseUrl.'?run='.$run.'&source='.$src.' '.$total.PHP_EOL;
}
}

В результате его выполнения мы получим список профилировки скриптов, которые выполнялись дольше чем 5 секунд, но при этом не были заблокированными session_start()-ами.

Как же решить проблему блокировки сессий, если вам нужно выполнять долгие запросы?

В идеальном мире такие вещи как формирование отчетов, импорт и тому подобное должны происходить вне контекста веб-сервера. Пользователь, создавая запрос на генерацию отчета, должен, по сути, лишь выставить в очередь задачу на формирование такого отчета, и далее скрипт должен проверять флаг готовности этой задачи - сама задача должна быть выполнена, например, серверным демоном, который выполняет очередь таких задач.

В качестве простого решения - если долгий процесс можно выполнять без работы с сессией, следует вызвать перед ним функцию session_write_close() и тем самым снять блокировку. Если же избежать выполнения долгих процессов в пользовательской сессии невозможно, единственным вариантом остается создание собственного обработчика сессий, не создающего блокировки на время работы с сессиями (и соответственно учет возможной перезаписи/потери данных сессии).

Поделиться записью