Зберігання 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
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.