Как правильно реализовать пользовательский сеанс, сохраняемый в PHP + MySQL? - PullRequest
10 голосов
/ 21 июня 2009

Я пытаюсь реализовать пользовательский сеанс, сохраняемый в PHP + MySQL. Большинство вещей тривиально - создайте свою таблицу БД, сделайте ваши функции чтения / записи, вызовите session_set_save_hander() и т. Д. Есть даже несколько учебных пособий, которые предлагают примеры реализации для вас. Но так или иначе все эти учебные пособия упустили из виду одну крошечную деталь об персистентности сеанса - блокировка . И вот тут-то и начинается настоящее веселье!

Я посмотрел на реализацию session_mysql PECL-расширения PHP. Это использует функции MySQL get_lock() и release_lock(). Кажется, хорошо, но мне не нравится, как это происходит. Блокировка получается в функции read и снимается в функции write . Но что, если функция записи никогда не вызывается? Что если скрипт каким-то образом падает, но соединение MySQL остается открытым (из-за пула или чего-то еще)? Или что, если скрипт попадет в тупик?

У меня просто возникла проблема , когда скрипт открыл сеанс, а затем попытался flock() файл через общую папку NFS, в то время как другой компьютер (на котором размещался файл) тоже делал то же самое , В результате вызов flock() -over-NFS блокировал сценарий примерно на 30 секунд при каждом вызове. И это было в цикле из 20 итераций! Поскольку это была внешняя операция, тайм-ауты сценариев PHP не применялись, и сеанс блокировался более чем на 10 минут при каждом доступе к этому сценарию. И, если повезет, это был сценарий, который каждые 5 секунд опрашивался приставкой AJAX ... Major showtopper.

У меня уже есть некоторые идеи о том, как реализовать это лучше, но мне бы очень хотелось услышать, что предлагают другие люди. У меня не было такого большого опыта работы с PHP, чтобы знать, какие тонкие грани вырисовываются в тени, что однажды может поставить под угрозу все это.


Добавлено:

Ладно, похоже, никому нечего предложить. Хорошо, вот моя идея. Я хотел бы высказать мнение о том, где это может пойти не так.

  1. Создание таблицы сеансов с механизмом хранения InnoDB. Это должно обеспечить некоторую правильную блокировку строк даже в кластерных сценариях. В таблице должны быть столбцы ID , Данные , LastAccessTime , LockTime , LockID . Я опускаю типы данных здесь, потому что они следуют совершенно напрямую из данных, которые должны храниться в них. ID будет идентификатором сеанса PHP. Данные будут, конечно, содержать данные сеанса. LastAccessTime будет меткой времени, которая будет обновляться при каждой операции чтения / записи и будет использоваться GC для удаления старых сеансов. LockTime будет меткой времени последней блокировки, полученной в сеансе, а LockID будет GUID блокировки.
  2. Когда запрашивается операция read , будут предприняты следующие действия:
    1. Выполнить INSERT IGNORE INTO sessions (id, data, lastaccesstime, locktime, lockid) values ($sessid, null, now(), null, null); - это создаст строку сеанса, если ее там нет, но ничего не делать, если она уже присутствует;
    2. Создать случайный идентификатор блокировки в переменной $ guid;
    3. Выполнить UPDATE sessions SET (lastaccesstime, locktime, lockid) values (now(), now(), $guid) where id=$sessid and (lockid is null or locktime < date_add(now(), INTERVAL -30 seconds)); - это атомарная операция, которая либо получит блокировку строки сеанса (если она не заблокирована или срок действия блокировки истек), либо ничего не будет делать.
    4. Проверьте с помощью mysql_affected_rows(), была ли получена блокировка или нет. Если он был получен - продолжайте. Если нет - повторяйте операцию каждые 0,5 секунды. Если через 40 секунд блокировка все еще не получена, выведите исключение.
  3. Когда запрашивается операция write , выполните UPDATE sessions SET (lastaccesstime, data, locktime, lockid) values (now(), $data, null, null) where id=$sessid and lockid=$guid; Это еще одна атомарная операция, которая обновит строку сеанса новыми данными и снимет блокировку, если она все еще имеет блокировку, но ничего не сделает, если замок уже снят.
  4. Когда запрашивается операция gc, просто удалите все строки с lastaccesstime слишком старым.

Может ли кто-нибудь увидеть недостатки с этим?

Ответы [ 4 ]

2 голосов
/ 03 мая 2012

Хорошо. Ответ будет немного длиннее - так что терпение! 1) Все, что я собираюсь написать, основано на экспериментах, которые я провел за последние пару дней. Могут быть какие-то ручки / настройки / внутренняя работа, о которых я не могу знать. Если вы заметили ошибки или не согласны, пожалуйста, кричите!

2) Первое уточнение - КОГДА ДАННЫЕ СЕССИИ ПРОЧИТАЮТСЯ и ЗАПИСАНЫ

Данные сеанса будут прочитаны ровно один раз, даже если у вас есть несколько чтений $ _SESSION внутри вашего скрипта. Чтение из сеанса является для каждого сценария. Более того, выборка данных происходит на основе session_id, а не ключей.

2) Второе уточнение - НАПИСАНИЕ ВСЕГДА ВЫЗЫВАЕТСЯ В КОНЦЕ СЦЕНАРИИ

A) Запись в сеанс save_set_handler всегда запускается, даже для сценариев, которые только «читают» из сеанса и никогда не делают никаких записей. Б) Запись запускается только один раз, в конце скрипта или если вы явно вызываете session_write_close. Опять же, запись основана на session_id, а не на ключах

3) Третье уточнение: ПОЧЕМУ НАМ НУЖНА Блокировка

  • Что это за суета?
  • Нам действительно нужны блокировки на сеансе?
  • Нужна ли нам упаковка большого замка ЧИТАТЬ + ЗАПИСАТЬ

Объяснить суету

script1

  • 1: $ x = S_SESSION ["X"];
  • 2: сон (20);
  • 3: если ($ x == 1) {
  • 4: // сделать что-то
  • 5: $ _SESSION ["X"] = 3;
  • 6:}
  • 4: выход;

Сценарий 2

  • 1: $ x = $ _SESSION ["X"];
  • 2: if ($ x == 1) {$ _SESSION ["X"] = 2; }
  • 3: выход;

Несоответствие заключается в том, что сценарий 1 выполняет что-то на основе значения переменной сеанса (строка: 3), которое изменилось другим сценарием, когда сценарий 1 уже выполнялся. Это пример скелета, но он иллюстрирует суть. Тот факт, что вы принимаете решения, основанные на чем-то, что уже не ПРАВДА.

при использовании блокировки сеанса PHP по умолчанию (блокировка уровня запроса) script2 будет блокироваться в строке 1, поскольку он не может прочитать файл, который скрипт 1 начал читать в строке 1. Таким образом, запросы к данным сеанса сериализуются. Когда script2 читает значение, оно гарантированно читает новое значение.

Разъяснение 4: СИНХРОНИЗАЦИЯ СЕССИИ PHP ОТЛИЧАЕТСЯ ОТ ПЕРЕМЕННОЙ СИНХРОНИЗАЦИИ

Многие люди говорят о синхронизации сессий PHP, как если бы это было похоже на синхронизацию переменных, запись в память происходит, как только вы перезаписываете значение переменной, а следующее чтение в любом скрипте извлечет новое значение. Как мы видим из уточнения № 1 - это не так. Сценарий использует значения, прочитанные в начале сценария по всему сценарию, и даже если какой-либо другой сценарий изменил значения, работающий сценарий не будет знать о новых значениях до следующего обновления. Это очень важный пункт.

Кроме того, имейте в виду, что значения в сеансе изменяются даже при большой блокировке PHP. Сказать что-то вроде «скрипт, который первым заканчивает, перезапишет значение», не очень точно. Изменение значения не плохо, то, что мы ищем, это несоответствие, а именно, оно не должно меняться без моего ведома.

УТОЧНЕНИЕ 5: ДЕЙСТВИТЕЛЬНО НУЖНО БОЛЬШОЙ БЛОКИРОВКА?

Теперь, действительно ли нам нужен Big Lock (уровень запроса)? Ответ, как и в случае изоляции БД, заключается в том, что это зависит от того, как вы хотите что-то делать. С реализацией по умолчанию $ _SESSION, IMHO, имеет смысл только большая блокировка. Если я собираюсь использовать значение, которое я прочитал в начале всего скрипта, тогда имеет смысл только большая блокировка. Если я изменю реализацию $ _SESSION на «всегда», выбираю «свежее» значение, тогда вам не понадобится БОЛЬШАЯ БЛОКИРОВКА.

Предположим, мы реализуем схему управления версиями данных сеанса, например, управление версиями объекта. Теперь запись сценария 2 будет успешной, потому что сценарий 1 еще не пришел к точке записи. script-2 записывает в хранилище сеансов и увеличивает версию на 1. Теперь, когда скрипт 1 пытается записать в сеанс, он завершится неудачей (строка 5) - я не думаю, что это желательно, хотя выполнимо.

===================================

Из (1) и (2) следует, что независимо от того, насколько сложен ваш скрипт, с X читает и Y записывает в сессию,

  • методы обработчика сессии read () и write () вызываются только один раз
  • и они всегда называются

Теперь в сети есть пользовательские обработчики сеансов PHP, которые пытаются выполнить блокировку на «переменном» уровне и т. Д. Я все еще пытаюсь выяснить некоторые из них. Однако я не сторонник сложных схем.

Предполагая, что PHP-скрипты с $ _SESSION должны обслуживать веб-страницы и обрабатываться за миллисекунды, я не думаю, что дополнительная сложность того стоит. Как Петр Зайцев упоминает здесь , выбор для обновления с коммитом после записи должен сработать.

Здесь я включаю код, который я написал для реализации блокировки. Было бы неплохо протестировать его с помощью некоторых скриптов "Race simulation". Я считаю, что это должно работать. В сети не так много правильных реализаций. Было бы хорошо, если бы вы могли указать на ошибки. Я сделал это с голыми mysqli.

<?php
namespace com\indigloo\core {

    use \com\indigloo\Configuration as Config;
    use \com\indigloo\Logger as Logger;

    /*
     * @todo - examine row level locking between read() and write()
     *
     */
    class MySQLSession {

        private $mysqli ;

        function __construct() {

        }

        function open($path,$name) {
            $this->mysqli = new \mysqli(Config::getInstance()->get_value("mysql.host"),
                            Config::getInstance()->get_value("mysql.user"),
                            Config::getInstance()->get_value("mysql.password"),
                            Config::getInstance()->get_value("mysql.database")); 

            if (mysqli_connect_errno ()) {
                trigger_error(mysqli_connect_error(), E_USER_ERROR);
                exit(1);
            }

            //remove old sessions
            $this->gc(1440);

            return TRUE ;
        }

        function close() {
            $this->mysqli->close();
            $this->mysqli = null;
            return TRUE ;
        }

        function read($sessionId) {
            Logger::getInstance()->info("reading session data from DB");
            //start Tx
            $this->mysqli->query("START TRANSACTION"); 
            $sql = " select data from sc_php_session where session_id = '%s'  for update ";
            $sessionId = $this->mysqli->real_escape_string($sessionId);
            $sql = sprintf($sql,$sessionId);

            $result = $this->mysqli->query($sql);
            $data = '' ;

            if ($result) {
                $record = $result->fetch_array(MYSQLI_ASSOC);
                $data = $record['data'];
            } 

            $result->free();
            return $data ;

        }

        function write($sessionId,$data) {

            $sessionId = $this->mysqli->real_escape_string($sessionId);
            $data = $this->mysqli->real_escape_string($data);

            $sql = "REPLACE INTO sc_php_session(session_id,data,updated_on) VALUES('%s', '%s', now())" ;
            $sql = sprintf($sql,$sessionId, $data);

            $stmt = $this->mysqli->prepare($sql);
            if ($stmt) {
                $stmt->execute();
                $stmt->close();
            } else {
                trigger_error($this->mysqli->error, E_USER_ERROR);
            }
            //end Tx
            $this->mysqli->query("COMMIT"); 
            Logger::getInstance()->info("wrote session data to DB");

        }

        function destroy($sessionId) {
            $sessionId = $this->mysqli->real_escape_string($sessionId);
            $sql = "DELETE FROM sc_php_session WHERE session_id = '%s' ";
            $sql = sprintf($sql,$sessionId);

            $stmt = $this->mysqli->prepare($sql);
            if ($stmt) {
                $stmt->execute();
                $stmt->close();
            } else {
                trigger_error($this->mysqli->error, E_USER_ERROR);
            }
        }

        /* 
         * @param $age - number in seconds set by session.gc_maxlifetime value
         * default is 1440 or 24 mins.
         *
         */
        function gc($age) {
            $sql = "DELETE FROM sc_php_session WHERE updated_on < (now() - INTERVAL %d SECOND) ";
            $sql = sprintf($sql,$age);
            $stmt = $this->mysqli->prepare($sql);
            if ($stmt) {
                $stmt->execute();
                $stmt->close();
            } else {
                trigger_error($this->mysqli->error, E_USER_ERROR);
            }

        }

    }
}
?>

Чтобы зарегистрировать обработчик сеанса объекта,

$sessionHandler = new \com\indigloo\core\MySQLSession();
session_set_save_handler(array($sessionHandler,"open"),
                            array($sessionHandler,"close"),
                            array($sessionHandler,"read"),
                            array($sessionHandler,"write"),
                            array($sessionHandler,"destroy"),
                            array($sessionHandler,"gc"));

ini_set('session_use_cookies',1);
//Defaults to 1 (enabled) since PHP 5.3.0
//no passing of sessionID in URL
ini_set('session.use_only_cookies',1);
// the following prevents unexpected effects 
// when using objects as save handlers
// @see http://php.net/manual/en/function.session-set-save-handler.php 
register_shutdown_function('session_write_close');
session_start();

Вот еще одна версия, сделанная с помощью PDO. Этот проверяет наличие sessionId и выполняет обновление или вставку. Я также удалил функцию gc из open (), так как она без необходимости запускает SQL-запрос при каждой загрузке страницы. Устаревшая сессия может быть легко выполнена с помощью скрипта cron. Это должна быть версия для использования, если вы используете PHP 5.x. Дайте мне знать, если найдете какие-либо ошибки!

=========================================

namespace com\indigloo\core {

    use \com\indigloo\Configuration as Config;
    use \com\indigloo\mysql\PDOWrapper;
    use \com\indigloo\Logger as Logger;

    /*
     * custom session handler to store PHP session data into mysql DB
     * we use a -select for update- row leve lock 
     *
     */
    class MySQLSession {

        private $dbh ;

        function __construct() {

        }

        function open($path,$name) {
            $this->dbh = PDOWrapper::getHandle();
            return TRUE ;
        }

        function close() {
            $this->dbh = null;
            return TRUE ;
        }

        function read($sessionId) {
            //start Tx
            $this->dbh->beginTransaction(); 
            $sql = " select data from sc_php_session where session_id = :session_id  for update ";
            $stmt = $this->dbh->prepare($sql);
            $stmt->bindParam(":session_id",$sessionId, \PDO::PARAM_STR);
            $stmt->execute();
            $result = $stmt->fetch(\PDO::FETCH_ASSOC);
            $data = '' ;
            if($result) {
                $data = $result['data'];
            }

            return $data ;
        }

        function write($sessionId,$data) {

            $sql = " select count(session_id) as total from sc_php_session where session_id = :session_id" ;
            $stmt = $this->dbh->prepare($sql);
            $stmt->bindParam(":session_id",$sessionId, \PDO::PARAM_STR);
            $stmt->execute();
            $result = $stmt->fetch(\PDO::FETCH_ASSOC);
            $total = $result['total'];

            if($total > 0) {
                //existing session
                $sql2 = " update sc_php_session set data = :data, updated_on = now() where session_id = :session_id" ;
            } else {
                $sql2 = "insert INTO sc_php_session(session_id,data,updated_on) VALUES(:session_id, :data, now())" ;
            }

            $stmt2 = $this->dbh->prepare($sql2);
            $stmt2->bindParam(":session_id",$sessionId, \PDO::PARAM_STR);
            $stmt2->bindParam(":data",$data, \PDO::PARAM_STR);
            $stmt2->execute();

            //end Tx
            $this->dbh->commit(); 
        }

        /*
         * destroy is called via session_destroy
         * However it is better to clear the stale sessions via a CRON script
         */

        function destroy($sessionId) {
            $sql = "DELETE FROM sc_php_session WHERE session_id = :session_id ";
            $stmt = $this->dbh->prepare($sql);
            $stmt->bindParam(":session_id",$sessionId, \PDO::PARAM_STR);
            $stmt->execute();

        }

        /* 
         * @param $age - number in seconds set by session.gc_maxlifetime value
         * default is 1440 or 24 mins.
         *
         */
        function gc($age) {
            $sql = "DELETE FROM sc_php_session WHERE updated_on < (now() - INTERVAL :age SECOND) ";
            $stmt = $this->dbh->prepare($sql);
            $stmt->bindParam(":age",$age, \PDO::PARAM_INT);
            $stmt->execute();
        }

    }
}
?>
2 голосов
/ 22 июля 2009

Я просто хотел добавить (и вы, возможно, уже знаете), что хранилище сессий PHP по умолчанию (которое использует файлы) блокирует файлы сессий. Очевидно, что использование файлов для сессий имеет множество недостатков, поэтому, вероятно, вы смотрите на решение для базы данных.

0 голосов
/ 14 декабря 2010

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

0 голосов
/ 22 июля 2009

Проверьте с помощью mysql_affered_rows (), была ли блокировка получена или нет. Если он был получен - продолжайте. Если нет - повторяйте операцию каждые 0,5 секунды. Если через 40 секунд блокировка все еще не получена, выведите исключение.

Я вижу проблему в блокировании выполнения скрипта с помощью этой постоянной проверки блокировки. Вы предлагаете, чтобы PHP выполнял до 40 секунд в поисках этой блокировки каждый раз, когда сеанс инициализируется (если я правильно читаю).

Рекомендации

Если у вас кластерная среда, я настоятельно рекомендую memcached . Он поддерживает отношения сервер / клиент, поэтому все кластерные экземпляры могут переноситься на сервер memcached. Он не имеет проблем с блокировкой, которых вы боитесь, и достаточно быстр. Цитата с их страницы:

Независимо от того, какую базу данных вы используете (MS-SQL, Oracle, Postgres, MySQL-InnoDB и т. Д.), Существует много накладных расходов при реализации свойств ACID в СУБД, особенно когда задействованы диски, что означает, что запросы собираюсь заблокировать. Для баз данных, которые не совместимы с ACID (например, MySQL-MyISAM), таких издержек не существует, но чтение потоков блокирует запись потоков. memcached никогда не блокируется.

В противном случае, если вы по-прежнему используете хранилище сеансов RDBMS (и беспокоитесь о том, что блокировка станет проблемой), вы можете попробовать какой-то шардинг, основанный на фиксированном идентификаторе сеанса (здесь цепляйтесь за соломинку). Ничего другого не зная о вашей архитектуре, это как можно более конкретно.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...