Статья
Версия для печати
Обсудить на форуме
Сессии PHP. Часть 6.


Блокировки: файлы, БД, хитрости.

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

Блокировки файлов.

Для блокирования файла воспользуемся рекомендательной блокировкой - flock(). Рекомендательной она называется потому, что работает только в том случае, если все программы, обращающиеся к файлу, будут ее использовать. Программу, которая не будет использовать flock(), блокировка никак не коснется.
Рекомендательная блокировка имеет ряд ограничений: может не работать на некоторых сетевых файловых системах (т.е. хранить файлы с данными сессий следует на локальном диске), она не поддерживается на старых файловых системах (FAT). Подробнее читайте здесь: http://www.php.net/manual/en/function.flock.php .
Вот модифицированные функции обработчика из 4-ой части статьи.

Код: (PHP)
<?php
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.

Код: (SQL)
CREATE TABLE sessions
(
    sid CHAR(32) NOT NULL PRIMARY KEY,
    atime TIMESTAMP,
    locked TINYINT NOT NULL DEFAULT 0,
    DATA TEXT NOT NULL DEFAULT ''
);

Модифицированные функции из 5-ой части:

Код: (PHP)
<?php
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 проверяет допустимость идентификатора - он может содержать только латинские буквы и цифры. Т.е. необходимости в дополнительном контроле нет.

Роман Чернышов (RXL) 27.05.2006
Версия для печати
Обсудить на форуме