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

Стандартный механизм хранения данных пользовательских сессий в 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 \suffi\RedisSessionHandler\RedisSessionHandler($redis);
    session_set_save_handler($handler);
}

session_start();

Результат


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

Ссылки


» Ссылка на репозиторий на гитхабе
» Страница о блокировках Redis
  • +12
  • 16,2k
  • 8
Поделиться публикацией

Комментарии 8

    +4

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


    А так вы специально занижаете производительность системы за счет ненужных блокировок.

      +1

      Если менять нужно по одному значению за раз, а получать все скопом, то лучше воспользоваться Set-ом, и, соответственно, hset, hgetall.

      +3
      Реализацию, в которой есть sleep, чисто технически называть «спинлоком» уже нельзя.
        +5
        Ну а теперь касаемо содержательной части статьи: если абстракция не подходит для работы, то может она выбрана неправильно?

        К чему это я: если приходится синхронизировать объект для данных, для которых синхронизация не нужна, то может выбрать абстракцию/хранилище, которые больше подойдут для задачи? В приведённом примере просто работа с redis, минуя прокладку в виде «сессий» проблему «решила бы». Точнее, в этом случае «проблема» даже не существовала бы как класс.
          0
          Проблема возникла, так как необходимо было поддерживать проекты, использующие сессии, написанные еще до подключения redis. При масштабировании проектов и использовании нескольких серверов с php потребовался нестандартный обработчик сессий. Данное решение позволяет хранить сессии в redis, не меняя уже написанного кода по работе с сессиями.
          Для переписывания на использование redis напрямую, минуя сессии, нужно время, вероятно это будет реализовано в будущем.
          0
          Теоретически сессии в пхп это механизм хранения состояния для пользователя, чтобы решить вопрос stateless http.

          Какие ещё гонки в этом случае? Поведение, когда конкретный пользователь генерирует 100 одновременных http запросов, которым требуется общее состояние — неадекватно.

          С большой долей вероятности у вас это должны быть или разные пользователи.
          Или это у вас не состояние, а данные, которые должны быть в БД
            0
            Например, пользователь открыл в двух вкладках приложение, которое запоминает историю посещений. Какая из историй более правильная?
              0
              Никаких гонок в этом случае не возникает.
              Сессия справляется.

              Основная «сложность» это определить как себя должно вести приложение.

              Например тот же гугл ведёт историю пользователя в разрезе устройств, но она доступна отовсюду с устройств этого пользователя

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое