Хранение сессии в базе php

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

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

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

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

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

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

  '; 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, то есть установка происходит только в случае, если такого ключа нет. Если же такой ключ существует, делаем повторную попытку через некоторое время.

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

Читайте также:  Show and hide div with JavaScript

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

 private function unlockSession() < $script = 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 = 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() при завершении работы с ней)

Источник

Использование MySQL для хранения данных сессий

Для высоко нагруженных проектов использование файлов для хранения файлов сессий становится недопустимым.

Читайте также:  font-kerning

В этой статье мы рассмотрим использвоание БД MySql для хранения данных сессий.


    все настройки сессий производятся до старта сессии, поэтому необходимо отменить автостарт сессий:

ini_set('session.auto_start', '0');
ini_set('session.save_handler', 'user');
  • files — значение по умолчанию, PHP использует стандартные функции обработки сессий, сессии храняться в файлах, необходимо определить ini_set(‘session.save_path’, путь); место для хранения файлов сессий.;
  • mm — PHP использует стандартные функции обработки сессий, сессии храняться в памяти;
  • user — позволяет переопределять стандартные функции обработки сессий, и соответственно в этих функциях указывать, где мы будем хранить сессии и как мы будем их обрабатывать.
session_set_save_handler ( "sess_open", "sess_close", "sess_read", "sess_write", "sess_destroy", "sess_gc");
  • sess_open — открывает сессию. Функция создает уникальное ID сессии. Требует для своей работы два параметра ‘session.save_path’ и ‘session.name’. Т.к. мы храним сессии в базе, то ‘session.save_path’ нам не нужен, а вот ‘session.name’ можно определить вместо стандартного — ‘PHPSESSID’. Итак дописываем в конфигурацию:
ini_set('session.gc_maxlifetime', XXX); ini_set('session.cookie_lifetime', YYY);

‘sess_gc’ не всегда вызывается при инициализации сессии, есть еще одна настройка которая управляет этим параметром — ‘session.gc_probability’. Этот параметр определяет вероятность запуска ‘sess_gc’ в процентах, соответственно валидные значения 1-100. Значение по умолчанию 1%. Т.е. это означает, что с вероятностью в 1%, при открытии новой странице сайта, будет происходить очистка сессионной таблицы, по моему опыту оптимально значение 5-10. Добавляем к конфигурации:

ini_set ('session.gc_probability', 5);
CREATE TABLE "session" ( session_id character varying(32) NOT NULL, session_user_id integer DEFAULT 0 NOT NULL, session_counter integer DEFAULT 0 NOT NULL, session_ip character varying(16), session_agent character varying(255), session_last integer DEFAULT 0 NOT NULL, session_created integer DEFAULT 0 NOT NULL, session_data text );
CREATE TABLE "user" ( user_id character varying(32) NOT NULL, user_ip character varying(16), user_agent character varying(255), /* могут быть и другие поля */ );

session.php — Хранение данных сессии в MySQL таблице и функции работы с сессиями на PHP.

Используется глобальный массив $user[] с полями из таблиц БД session, user.
Подразумевается что соединение с MySQL уже установлено и определено в глобальной переменной $db.
Текущая информация сохраняется в глобальной переменной $session.

 function sess_close () function sess_read ($session_id) < global $db, $user, $session; if (strlen ($session_id) != 32) < error_log ("sess_read(): Invalid SessionID = ".$session_id); return ''; >$sql = "SELECT `session_id`, `session_user_id`, `session_counter`, `session_ip`, `session_agent`, `session_data` FROM `session` WHERE `session_id` = '".$db->sql_escape($session_id)."' AND `session_last` > '".(time() - live_sess_time)."'"; $result = $db->sql_query ($sql); if ($db->sql_numrows ($result) == 1) < $session = $db->sql_fetchrow ($result); if ($session AND $session['session_ip'] == $user['user_ip'] AND $session['session_agent'] == $user['user_agent']) < // выборка информации о пользователе. TODO замените при необходимости на свою . $sql = "SELECT * FROM `user` WHERE `user_id` = '".$db->sql_escape($session['session_user_id'])."' LIMIT 1"; $result = $db->sql_query ($sql); if(!$result) < $result = $db->sql_error ($result); error_log ('sess_read(): Failed to read user info - '.$result['message']); return ''; > else < $user_data = $db->sql_fetchrow ($result); $user = array_merge ($user, $user_data, $session); // слить три массива в один unset($user['session_data']); return $session['session_data']; > > else < if (isset($_REQUEST[session_name()])) sess_destroy($_REQUEST[session_name()]); return ''; >> elseif (!$result) < $result = $db->sql_error ($result); error_log ('sess_read(): Failed to read sessions - '.$result['message']); return ''; > else < $session = NULL; if (isset($_REQUEST[session_name()])) sess_destroy($_REQUEST[session_name()]); return ''; >> function sess_write ($session_id, $session_data) < global $db, $user, $session; if (strlen ($session_id) != 32) < error_log ('sess_write(): Invalid Session return false; >if (4294967295 < strlen($session_data)) < error_log ('sess_write(): Session data too large. '.$session_id.'(max. 4294967295) ->'.strlen($session_data)); if (isset($_REQUEST[session_name()])) sess_destroy($_REQUEST[session_name()]); return false; > if ($session AND $session['session_ip'] != $user['user_ip']) < if (isset($_REQUEST[session_name()])) sess_destroy($_REQUEST[session_name()]); return false; >if ($session) < $sql = "UPDATE `session` SET `session_user_id` = '".intval ($session['session_user_id'])."', `session_last` = '".time ()."', `session_counter` = '".intval(++$session['session_counter'])."', `session_data` = '".$db->sql_escape($session_data)."' WHERE `session_id` = '".$db->sql_escape($session_id)."' LIMIT 1"; > else < $sql = "INSERT INTO `session` (`session_id`, `session_created`, `session_last`, `session_ip`, `session_agent`, `session_data`) VALUES ('".$db->sql_escape ($session_id)."', ".time().", ".time().", '".$db->sql_escape ($user['user_ip'])."', '".$db->sql_escape ($user['user_agent'])."', '".$db->sql_escape ($session_data)."')"; > $result = $db->sql_query ($sql); if (!$result) < $result = $db->sql_error ($result); error_log ('sess_write(): Failed to INSERT/UPDATE session. '.$result['message']." 
Query: ".$sql); return false; > return true; > function sess_destroy ($session_id) < global $db; $sql = "DELETE FROM `session` WHERE `session_id` = '".$db->sql_escape ($session_id)."'"; $result = $db->sql_query ($sql); if (!$result) < $result = $db->sql_error ($result); error_log ('sess_destory(): Failed to DELETE session. '.$result['message']); return false; > return true; > function sess_gc ($sess_gc_maxlifetime) < global $db; $sql = "DELETE FROM `session` WHERE `session_last` < '".(time () - $sess_gc_maxlifetime)."'"; $result = $db->sql_query ($sql); if (!$result) < $result = $db->sql_error ($result); error_log ('sess_gc(): Failed to DELETE old sessions.'.$result['message']); return false; > $sql = "OPTIMIZE TABLE `session` "; $result = $db->sql_query ($sql); if (!$result) < $result = $db->sql_error ($result); error_log ('sess_gc(): Failed to OPTIMIZE sessionstable.'.$result['message']); return false; > return true; > session_set_save_handler ("sess_open", "sess_close", "sess_read", "sess_write", "sess_destroy", "sess_gc"); // Можно активировать при проблемах register_shutdown_function ('session_write_close'); session_start (); ?>

При этом в базе остаются записи только о тех сессиях, которые сейчас активны, просроченные сессии удаляются, изменяя значение ‘session.gc_probability’ вы можете подобрать тот балланс который подходит Вашему сайту. Таким образом регулировать размер таблицы сессий.

Источник

Оцените статью