Наиболее популярное решение — расширение 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
Комментарии (8)
zerkms
31.12.2016 11:22+3Реализацию, в которой есть sleep, чисто технически называть «спинлоком» уже нельзя.
zerkms
31.12.2016 11:31+5Ну а теперь касаемо содержательной части статьи: если абстракция не подходит для работы, то может она выбрана неправильно?
К чему это я: если приходится синхронизировать объект для данных, для которых синхронизация не нужна, то может выбрать абстракцию/хранилище, которые больше подойдут для задачи? В приведённом примере просто работа с redis, минуя прокладку в виде «сессий» проблему «решила бы». Точнее, в этом случае «проблема» даже не существовала бы как класс.dmitry-suffi
31.12.2016 16:06Проблема возникла, так как необходимо было поддерживать проекты, использующие сессии, написанные еще до подключения redis. При масштабировании проектов и использовании нескольких серверов с php потребовался нестандартный обработчик сессий. Данное решение позволяет хранить сессии в redis, не меняя уже написанного кода по работе с сессиями.
Для переписывания на использование redis напрямую, минуя сессии, нужно время, вероятно это будет реализовано в будущем.
Fortop
01.01.2017 10:35Теоретически сессии в пхп это механизм хранения состояния для пользователя, чтобы решить вопрос stateless http.
Какие ещё гонки в этом случае? Поведение, когда конкретный пользователь генерирует 100 одновременных http запросов, которым требуется общее состояние — неадекватно.
С большой долей вероятности у вас это должны быть или разные пользователи.
Или это у вас не состояние, а данные, которые должны быть в БДromy4
01.01.2017 23:04Например, пользователь открыл в двух вкладках приложение, которое запоминает историю посещений. Какая из историй более правильная?
Fortop
01.01.2017 23:08Никаких гонок в этом случае не возникает.
Сессия справляется.
Основная «сложность» это определить как себя должно вести приложение.
Например тот же гугл ведёт историю пользователя в разрезе устройств, но она доступна отовсюду с устройств этого пользователя
akzhan
Поскольку вы используете Redis, могли бы разбить монолитную сессию на набор сущностей, которые можно обновлять атомарно, а не в пределах документа. Брать весь набор можно, например, через mget.
А так вы специально занижаете производительность системы за счет ненужных блокировок.
ololoepepe
Если менять нужно по одному значению за раз, а получать все скопом, то лучше воспользоваться Set-ом, и, соответственно, hset, hgetall.