Сессии PHP. Часть 6. Блокировки: файлы, БД, хитрости.
Работа с данными сессии кажется очень простой - открыл, прочел, записал, закрыл. Но если пользователь, не дожидаясь окончания работы предыдущего запроса, пошлет еще запрос, то у этих параллельно работающих скриптов может возникнуть конфликт с данными сессии.
Например, первый скрипт прочел данные, второй тоже, первый записал данные, второй перезаписал поверх. Т.е. данные, которые записал в сессию первый скрипт, будут потеряны - второй скрипт ничего о них не знал.
Такая ситуация может быть критичной. Для борьбы с этим нужно делать взаимную блокировку скриптов. Это аналогично параллельным процессам, обращающимся к одному и тому же ресурсу и использующим блокировки для временного запрета доступа к этому ресурсу.
Рассмотрим вопрос подробнее.
Блокировки файлов.
Для блокирования файла воспользуемся рекомендательной блокировкой - flock(). Рекомендательной она называется потому, что работает только в том случае, если все программы, обращающиеся к файлу, будут ее использовать. Программу, которая не будет использовать flock(), блокировка никак не коснется.
Рекомендательная блокировка имеет ряд ограничений: может не работать на некоторых сетевых файловых системах (т.е. хранить файлы с данными сессий следует на локальном диске), она не поддерживается на старых файловых системах (FAT). Подробнее читайте здесь.
Вот модифицированные функции обработчика из 4-ой части статьи.
function session_read($sid)
{
$file = "$this->_path/$sid";
if (!($this->_fd = @fopen($file, 'rb+')))
die("Не получается создать или открыть файл!");
flock($this->fd,LOCK_EX, true); // Блокируем
$data = fread($this->_fd, filesize($file));
touch($file); // Изменяем время модификации файла на текущее
return $data;
}
function session_write($sid, $data)
{
rewind($fd);
ftruncate($fd, 0);
$res = fwrite($this->_fd, $data);
fclose($this->_fd); // блокировка автоматически снимается
return $res;
}
Блокировки в БД.
Добавим в таблицу session (см. 5-ую часть статьи) поле locked.
CREATE TABLE sessions
(
sid CHAR(32) NOT NULL PRIMARY KEY,
atime TIMESTAMP,
locked TINYINT NOT NULL DEFAULT 0,
data TEXT NOT NULL DEFAULT ''
);
Модифицированные функции из 5-ой части:
function session_read($sid)
{
$lock_tries = 50;
// Спервка попробуем создать строку. Если она уже есть, mysql_affected_rows() вернет 0.
mysql_query("INSERT IGNORE INTO session (sid, locked) VALUES ('$sid', 1)");
if (!mysql_affected_rows())
while ($lock_tries)
{
mysql_query("UPDATE session SET locked = 1, atime = NOW() " .
"WHERE sid = '$sid' AND " .
"(locked = 0 OR atime < DATE_ADD(NOW(), INTERVAL -$lock_time_max SECOND))");
if (mysql_affected_rows())
break; // Строку удалось пометить заблокированной
// Проверка: пока мы тут ждем, кто-то мог удалить строку...
$res = mysql_query("SELECT 1 FROM session WHERE sid = '$sid'");
$row = mysql_fetch_row($res);
mysql_free_result($res);
if (!$row)
{
// Так и есть. Создаем заново.
mysql_query("INSERT IGNORE INTO session (sid, locked) VALUES ('$sid', 1)");
if (mysql_affected_rows())
break; // Если получилось - выходим из цикла
}
$lock_tries--;
if ($lock_tries)
usleep($lock_try_timeout); // Немного подождем...
}
// Удалось ли заблокировать?
if (!$lock_tries)
die("Ну сколько можно ждать?");
$res = mysql_query("SELECT data FROM session WHERE sid = '$sid'");
$row = mysql_fetch_row($res);
mysql_free_result($res);
return $row[0];
}
function session_write($sid, $data)
{
$data = mysql_escape_string($data);
mysql_query("REPLACE INTO session (sid, locked, data) VALUES ('$sid', 0, '$data')");
}
function session_delete($sid)
{
return mysql_query("DELETE FROM session WHERE sid = '$sid'");
}
function session_gc($lifetime)
{
mysql_query("DELETE FROM session WHERE atime < DATE_ADD(NOW(), INTERVAL -$lifetime SECOND)");
}
$lock_time_max используется для обработки ситуации, когда скрипт, заблокировавший строку, по какой-либо причине не разблокировал ее. Выбирать его следует исходя из максимального времени работы скрипта (без учета ожидания блокировки в функции session_read()). Думаю, для большинства задач достаточно 10 секунд.
$lock_try_timeout - время между попытками заблокировать строку. Я выбрал 0.25 секунды (значение 250000).
$lock_tries - число попыток.
Величина ($lock_tries * $lock_try_timeout) должна быть не меньше $lock_time_max - чтобы ожидание не было напрасным.
Некоторые хитрости.
Как видно из приведенного кода, вариант с файлами значительно меньше и проще, но БД работает быстрее, и управлять удобнее. Кроме того, сервер БД может находиться на другом компьютере.
Чтобы сократить время, в течение которого строка находится в заблокированном состоянии, после обновления данных сессии следует выполнить функцию session_write_close(). Это уменьшит ожидание для параллельного запроса и снизит вероятность ситуации, когда из-за медленно работающего скрипта параллельный скрипт не смог дождаться своей очереди и умер.
При старте сессии PHP проверяет допустимость идентификатора - он может содержать только латинские буквы и цифры. Т.е. необходимости в дополнительном контроле нет.