Хранение php-сессий в Redis с блокировками

в 15:38, , рубрики: php, phpredis, redis, session

Стандартный механизм хранения данных пользовательских сессий в php — хранение в файлах. Однако при работе приложения на нескольких серверах для балансировки нагрузки, возникает необходимость хранить данные сессий в хранилище, доступном каждому серверу приложения. В этом случае для хранения сессий хорошо подходит Redis.

Наиболее популярное решение — расширение phpredis. Достаточно установить расширение и настроить php.ini и сессии будут автоматически сохраняться в Redis без изменения кода приложений.

Однако такое решение имеет недостаток — отсутствие блокировки сессии.

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

Это легко проверить. Отправляем на сервер асинхронно 100 запросов, каждый из которых пишет в сессию свой параметр, затем считаем количество параметров в сессии.

Тестовый скрипт

<?php

session_start();

$cmd = $_GET['cmd'] ?? ($_POST['cmd'] ?? '');

switch ($cmd) {
    case 'result':
        echo(count($_SESSION));
        break;
    case "set":
        $_SESSION['param_' . $_POST['name']] = 1;
        break;
    default:
        $_SESSION = [];
        echo '<script src="https://code.jquery.com/jquery-1.11.3.js"></script>
<script>
$(document).ready(function() {
    for(var i = 0; i < 100; i++) {
    $.ajax({
        type: "post",
        url: "?",
        dataType: "json",
        data: {
            name: i,
            cmd: "set"
        }
    });
    }

    res = function() {
        window.location = "?cmd=result";
    }

    setTimeout(res, 10000);
});
</script>
';
        break;
}

В результате получаем, что в сессии не 100 параметров, а 60-80. Остальные данные мы потеряли.
В реальных приложениях конечно 100 одновременных запросов не будет, однако практика показывает, что даже при двух асинхронных одновременных запросах данные, записываемые одним из запросов, довольно часто затираются другим. Таким образом, использование расширения phpredis для хранения сессий небезопасно и может привести к потере данных.

Как один из вариантов решения проблемы — свой SessionHandler, поддерживающий блокировки.

Реализация

Чтобы установить блокировку сессии, установим значение ключа блокировки в случайно сгенерированное (на основе uniqid) значение. Значение должно быть уникальным, чтобы любой параллельный запрос не мог получить доступ.

    protected function lockSession($sessionId)
    {
        $attempts = (1000000 * $this->lockMaxWait) / $this->spinLockWait;
        $this->token = uniqid();
        $this->lockKey = $sessionId . '.lock';
        for ($i = 0; $i < $attempts; ++$i) {
            $success = $this->redis->set(
                $this->getRedisKey($this->lockKey),
                $this->token,
                [
                    'NX',
                ]
            );
            if ($success) {
                $this->locked = true;
                return true;
            }
            usleep($this->spinLockWait);
        }
        return false;
    }

Значение устанавливается с флагом NX, то есть установка происходит только в случае, если такого ключа нет. Если же такой ключ существует, делаем повторную попытку через некоторое время.

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

При разблокировке сессии при завершении работы скрипта для удаления ключа используем Lua-сценарий:

    private function unlockSession()
    {
        $script = <<<LUA
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end
LUA;
        $this->redis->eval($script, array($this->getRedisKey($this->lockKey), $this->token), 1);
        $this->locked = false;
        $this->token = null;
    }

Использовать команду DEL нельзя, так как с помощью нее можно удалить ключ, установленный другим скриптом. Такой сценарий же гарантирует удаление только в случае, если ключу блокировки соответствует уникальное значение, установленное текущим скриптом.

Полный код класса

class RedisSessionHandler implements SessionHandlerInterface
{
    protected $redis;

    protected $ttl;

    protected $prefix;

    protected $locked;

    private $lockKey;

    private $token;

    private $spinLockWait;

    private $lockMaxWait;

    public function __construct(Redis $redis, $prefix = 'PHPREDIS_SESSION:', $spinLockWait = 200000)
    {
        $this->redis = $redis;
        $this->ttl = ini_get('gc_maxlifetime');
        $iniMaxExecutionTime = ini_get('max_execution_time');
        $this->lockMaxWait = $iniMaxExecutionTime ? $iniMaxExecutionTime * 0.7 : 20;
        $this->prefix = $prefix;
        $this->locked = false;
        $this->lockKey = null;
        $this->spinLockWait = $spinLockWait;
    }

    public function open($savePath, $sessionName)
    {
        return true;
    }

    protected function lockSession($sessionId)
    {
        $attempts = (1000000 * $this->lockMaxWait) / $this->spinLockWait;
        $this->token = uniqid();
        $this->lockKey = $sessionId . '.lock';
        for ($i = 0; $i < $attempts; ++$i) {
            $success = $this->redis->set(
                $this->getRedisKey($this->lockKey),
                $this->token,
                [
                    'NX',
                ]
            );
            if ($success) {
                $this->locked = true;
                return true;
            }
            usleep($this->spinLockWait);
        }
        return false;
    }

    private function unlockSession()
    {
        $script = <<<LUA
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end
LUA;
        $this->redis->eval($script, array($this->getRedisKey($this->lockKey), $this->token), 1);
        $this->locked = false;
        $this->token = null;
    }

    public function close()
    {
        if ($this->locked) {
            $this->unlockSession();
        }
        return true;
    }

    public function read($sessionId)
    {
        if (!$this->locked) {
            if (!$this->lockSession($sessionId)) {
                return false;
            }
        }
        return $this->redis->get($this->getRedisKey($sessionId)) ?: '';
    }

    public function write($sessionId, $data)
    {
        if ($this->ttl > 0) {
            $this->redis->setex($this->getRedisKey($sessionId), $this->ttl, $data);
        } else {
            $this->redis->set($this->getRedisKey($sessionId), $data);
        }
        return true;
    }

    public function destroy($sessionId)
    {
        $this->redis->del($this->getRedisKey($sessionId));
        $this->close();
        return true;
    }

    public function gc($lifetime)
    {
        return true;
    }

    public function setTtl($ttl)
    {
        $this->ttl = $ttl;
    }

    public function getLockMaxWait()
    {
        return $this->lockMaxWait;
    }

    public function setLockMaxWait($lockMaxWait)
    {
        $this->lockMaxWait = $lockMaxWait;
    }

    protected function getRedisKey($key)
    {
        if (empty($this->prefix)) {
            return $key;
        }
        return $this->prefix . $key;
    }
    
    public function __destruct()
    {
        $this->close();
    }
}

Подключение

$redis = new Redis();
if ($redis->connect('11.111.111.11', 6379) && $redis->select(0)) {
    $handler = new suffiRedisSessionHandlerRedisSessionHandler($redis);
    session_set_save_handler($handler);
}

session_start();

Результат

После подключения нашего SessionHandler наш тестовый скрипт уверенно показывает 100 параметров в сессии. При этом несмотря на блокировки общее время обработки 100 запросов выросло незначительно. В реальной практике такого количества одновременных запросов не будет. Однако время работы скрипта обычно более существенно, и при одновременных запросах может быть заметное ожидание. Поэтому нужно думать о сокращении времени работы с сессией скрипта (вызове session_start() только при необходимости работы с сессией и session_write_close() при завершении работы с ней)

Ссылки

» Ссылка на репозиторий на гитхабе
» Страница о блокировках Redis

Автор: dmitry-suffi

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js