如何在PHP + MySQL中正确实现自定义会话持久性?

时间:2009-06-20 20:01:39

标签: php mysql session persistence

我正在尝试在PHP + MySQL中实现自定义会话持久化。大多数东西都是微不足道的 - 创建数据库表,创建读/写函数,调用session_set_save_hander()等等。甚至有几个教程为您提供示例实现。但不知何故,所有这些教程都方便地忽略了一个关于会话持久性的细节 - 锁定。现在,真正的乐趣开始了!

我查看了PHP的session_mysql PECL扩展的实现。它使用MySQL的函数get_lock()release_lock()。看起来不错,但我不喜欢它的做法。锁定在读取函数中获取,并在 write 函数中释放。但是如果写函数永远不会被调用怎么办?如果脚本以某种方式崩溃,但MySQL连接保持打开(由于池或其他东西)怎么办?或者如果脚本进入致命的僵局会怎么样?

我只是had a problem,其中一个脚本打开一个会话,然后尝试通过NFS共享flock()一个文件,而另一个计算机(托管该文件)也做同样的事情。结果是flock() - over-NFS调用在每次调用时阻止脚本大约30秒。它是在20次迭代的循环中!由于这是一个外部操作,PHP的脚本超时不适用,并且每次访问此脚本时会话都被锁定超过10分钟。幸运的是,这是一个每5秒钟由AJAX shoutbox进行轮询的脚本......主要的showstopper。

我已经对如何以更好的方式实现它有一些想法,但我真的很想听听别人的建议。我没有那么多使用PHP的经验来了解哪些细微的边缘情况在阴影中隐约可能有一天会危及整个事物。


加了:

好的,似乎没有人有任何建议。那么,这是我的想法。我想知道这可能出错的地方。

  1. 使用InnoDB存储引擎创建会话表。这应该确保即使在集群方案下也能正确锁定行。该表应包含 ID 数据 LastAccessTime LockTime LockID 列。我在这里省略了数据类型,因为它们直接来自需要存储在其中的数据。 ID 将是PHP会话的ID。 数据当然会包含会话数据。 LastAccessTime 将是一个时间戳,将在每次读/写操作时更新,并将由GC用于删除旧会话。 LockTime 将是会话中获取的最后一次锁定的时间戳, LockID 将是锁定的GUID。
  2. 当请求读取操作时,将执行以下操作:
    1. 执行INSERT IGNORE INTO sessions (id, data, lastaccesstime, locktime, lockid) values ($sessid, null, now(), null, null); - 如果会话行不存在,则会创建会话行,但如果会话行已存在则不执行任何操作;
    2. 在变量$ guid;
    3. 中生成随机锁定ID
    4. 执行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)); - 这是一个原子操作,它将获得会话行的锁定(如果它没有锁定或锁定已过期),或者什么都不做。
    5. 检查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太旧的所有行。
  5. 有人能看到这个漏洞吗?

4 个答案:

答案 0 :(得分:2)

我只想添加(您可能已经知道)PHP的默认会话存储(使用文件)会锁定会话文件。显然,使用文件进行会话有很多缺点,这可能就是您查看数据库解决方案的原因。

答案 1 :(得分:2)

确定。答案会更长一些 - 耐心! 1)无论我要写什么,都是基于我过去几天所做的实验。可能有一些旋钮/设置/内部工作我可能不知道。如果您发现错误/或不同意,请大声呼喊!

2)第一次澄清 - 当会话数据为READ和WRITEN时

即使您的脚本中有多个$ _SESSION读取,会话数据也将被准确读取一次。会话读取是基于每个脚本的。此外,数据提取基于session_id而不是密钥发生。

2)第二次澄清 - 在SCRIPT结束时总是写下来

A)对会话save_set_handler的写入总是被触发,即使对于只有&#34; read&#34;来自会话,从不做任何写作。 B)只在脚本结束时或者显式调用session_write_close时,只会触发一次写入。同样,写入基于session_id而不是键

3)第三次澄清:为什么我们需要锁定

  • 这大惊小怪的是什么?
  • 我们真的需要锁定会话吗?
  • 我们真的需要一个包含READ + WRITE的大锁吗

解释Fuss

SCRIPT1

  • 1:$ x = S_SESSION [&#34; X&#34;];
  • 2:睡觉(20);
  • 3:if($ x == 1){
  • 4://做点什么
  • 5:$ _SESSION [&#34; X&#34;] = 3;
  • 6:}
  • 4:退出;

脚本2

  • 1:$ x = $ _SESSION [&#34; X&#34;];
  • 2:if($ x == 1){$ _SESSION [&#34; X&#34;] = 2; }
  • 3:退出;

不一致是脚本1正在基于会话变量(line:3)执行某些操作,该值在脚本1已经运行时已由另一个脚本更改。这是一个骨架的例子,但它说明了这一点。事实上,你正在根据不再为真的东西作出决定。

当您使用PHP默认会话锁定(请求级别锁定)时,脚本2将阻止第1行,因为它无法从脚本1开始在第1行读取的文件中读取。因此,会话数据的请求被序列化。当script2读取值时,保证读取新值。

澄清4:PHP会话同步与可变同步不同

很多人都在谈论PHP会话同步,好像它就像一个变量同步,只要你覆盖变量值就会发生写入内存位置,并且任何脚本中的下一次读取都将获取新值。正如我们从澄清#1看到的那样 - 事实并非如此。该脚本在整个脚本中使用脚本开头读取的值,即使某些其他脚本更改了值,运行脚本也不会知道新值,直到下次刷新。这是一个非常重要的点。

另外,请记住,即使使用PHP大锁定,会话中的值也会更改。比如说,&#34;首先完成的脚本会覆盖价值&#34;不是很准确。价值变化并不坏,我们追求的是不一致,即不应该在我不知情的情况下改变。

澄清5:我们真的需要大锁?

现在,我们真的需要Big Lock(请求级别)吗?答案就像数据库隔离一样,它取决于你想做什么。使用$ _SESSION的默认实现,恕我直言,只有大锁才有意义。如果我要使用我在整个脚本开头读到的值,那么只有大锁才有意义。如果我将$ _SESSION实现更改为&#34;始终&#34; fetch&#34; fresh&#34;价值,那么你不需要大锁。

假设我们实现了像对象版本控制这样的会话数据版本控制方案。现在,脚本2写入将成功,因为脚本-1还没有写入点。 script-2写入会话存储并将版本增加1.现在,当脚本1尝试写入会话时,它将失败(第5行) - 我认为这不是可取的,尽管可行。

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

从(1)和(2)开始,无论您的脚本有多复杂,X读取和Y写入会话,

  • 会话处理程序read()和write()方法只被调用一次
  • 并且他们总是被称为

现在,网上有自定义的PHP会话处理程序,它们尝试执行&#34;变量&#34; -level锁定等等。我仍然试图找出其中一些。但是,我并不赞成复杂的计划。

假设带有$ _SESSION的PHP脚本应该提供网页并以毫秒为单位进行处理,我认为额外的复杂性并不值得。 Like Peter Zaitsev mentions here,写入后选择提交更新应该可以解决问题。

这里我包含了我为实现锁定而编写的代码。用一些&#34; Race simulation&#34;来测试它会很不错。脚本。我相信它应该有效。我在网上找到的正确实现并不多。如果你能指出错误就好了。我用裸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并更新或插入。我还从open()中删除了gc函数,因为它在每次页面加载时不必要地触发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 :(得分:0)

  

如果获得了锁定,请检查mysql_affected_rows()。如果获得 - 继续。如果不是 - 每0.5秒重新尝试一次操作。如果在40秒内仍未获得锁定,则抛出异常。

我看到阻止脚本执行的问题,这种连续检查锁。你建议每次会话初始化时,PHP运行最多40秒才能查找此锁(如果我正确读取它。)

建议

如果您有群集环境,我强烈建议 memcached 。它支持服务器/客户端关系,因此所有集群实例都可以遵循memcached服务器。它没有你担心的锁定问题,而且速度很快。从他们的页面引用:

  

无论您使用什么数据库(MS-SQL,Oracle,Postgres,MySQL-InnoDB等),在RDBMS中实现ACID属性都会产生大量开销,尤其是涉及磁盘时,这意味着查询是要阻止。对于不符合ACID的数据库(如MySQL-MyISAM),该开销不存在,但读取线程会阻塞写入线程。 memcached永远不会阻止。

否则,如果您仍然致力于RDBMS会话存储(并担心锁定将成为问题),您可以尝试基于粘性会话标识符进行某种分片(在这里抓住吸管。)什么都不知道关于你的架构,这与我能得到的具体相关。

答案 3 :(得分:0)

我的问题是为什么要锁定?为什么不让最后一次写入成功?您不应该将会话数据用作缓存,因此写入往往不常见,并且在实践中永远不会相互踩踏。