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

Posted

技术标签:

【中文标题】如何在 PHP + MySQL 中正确实现自定义会话持久化器?【英文标题】:How to properly implement a custom session persister in PHP + MySQL? 【发布时间】:2010-11-04 13:52:09 【问题描述】:

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

我查看了PHP的session_mysqlPECL扩展的实现。这使用了 MySQL 的函数 get_lock()release_lock()。看起来不错,但我不喜欢它的做法。锁在read 函数中获取,并在write 函数中释放。但是如果 write 函数永远不会被调用呢?如果脚本以某种方式崩溃,但 MySQL 连接保持打开状态(由于池化或其他原因)怎么办?或者如果脚本进入致命的死锁怎么办?

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

我已经对如何以更好的方式实施它有了一些想法,但我真的很想听听其他人的建议。我没有太多使用 PHP 的经验,无法知道隐藏在阴影中的微妙边缘情况有朝一日会危及整个事情。


添加:

好吧,似乎没有人有什么建议。好吧,这就是我的想法。我想对这可能出错的地方提出一些意见。

    使用 InnoDB 存储引擎创建会话表。即使在集群情况下,这也应该确保对行进行适当的锁定。该表应包含 IDDataLastAccessTimeLockTimeLockID 列.我在这里省略了数据类型,因为它们直接来自需要存储在其中的数据。 ID 将是 PHP 会话的 ID。 Data 当然会包含会话数据。 LastAccessTime 将是一个时间戳,它将在每次读/写操作时更新,并被 GC 用来删除旧会话。 LockTime 将是在会话中获得的最后一个锁的时间戳,LockID 将是锁的 GUID。 当请求 read 操作时,将执行以下操作:
      执行INSERT IGNORE INTO sessions (id, data, lastaccesstime, locktime, lockid) values ($sessid, null, now(), null, null); - 如果会话行不存在,这将创建会话行,但如果它已经存在,则不执行任何操作; 在变量$guid中生成随机锁id; 执行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)); - 这是一个原子操作,它将获得会话行上的锁(如果它未锁定或锁已过期),或者什么也不做。 检查mysql_affected_rows()是否获得了锁。如果获得 - 继续。如果不是 - 每 0.5 秒重新尝试一次操作。如果 40 秒后仍未获得锁,则抛出异常。
    当请求 write 操作时,执行UPDATE sessions SET (lastaccesstime, data, locktime, lockid) values (now(), $data, null, null) where id=$sessid and lockid=$guid; 这是另一个原子操作,它将用新数据更新会话行,如果它仍然有锁,则删除锁,但什么也不做如果锁已经被拿走了。 当请求 gc 操作时,只需删除所有 lastaccesstime 太旧的行。

谁能看出这个缺陷?

【问题讨论】:

嗨 Vilx - 你有 session_mysql PECL 的源代码吗?你说的链接没有找到。我也在研究这个问题,尝试按照mysqlperformanceblog.com/2007/03/27/… 中的建议使用 select-update @rjha94 - 该链接对我有用。 看看你能不能在下面我的解决方案中戳出任何漏洞。 【参考方案1】:

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

2) 第一个说明 - 读取和写入会话数据时

即使您的脚本中有多次 $_SESSION 读取,会话数据也只会被读取一次。从会话中读取是基于每个脚本的。此外,数据获取是基于 session_id 而不是键发生的。

2) 第二个说明 - 写在脚本末尾总是调用

A) 对会话 save_set_handler 的写入总是被触发,即使对于只从会话中“读取”并且从不进行任何写入的脚本也是如此。 B) 写入只触发一次,在脚本结束时或者如果您明确调用 session_write_close。同样,写入是基于 session_id 而不是键

3) 第三个说明:为什么我们需要锁定

这是怎么回事? 我们真的需要锁定会话吗? 我们真的需要一个大锁包装 READ + WRITE

解释大惊小怪

脚本1

1: $x = S_SESSION["X"]; 2:睡眠(20); 3: if($x == 1) 4: //做点什么 5: $_SESSION["X"] = 3 ; 6: 4:退出;

脚本 2

1: $x = $_SESSION["X"]; 2: if($x == 1) $_SESSION["X"] = 2 ; 3:退出;

不一致之处在于脚本 1 正在执行基于会话变量 (line:3) 值的操作,该值已在脚本 1 已经运行时被另一个脚本更改。这是一个框架示例,但它说明了这一点。事实上,您正在根据不再正确的事物做出决定。

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

说明 4:PHP 会话同步不同于变量同步

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

另外,请记住,即使使用 PHP 大锁定,会话中的值也会发生变化。说“首先完成的脚本将覆盖值”之类的说法不是很准确。值变化还不错,我们追求的是不一致,即不应该在我不知情的情况下发生变化。

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

现在,我们真的需要 Big Lock(请求级别)吗?与数据库隔离的情况一样,答案是它取决于您想要如何做事。对于 $_SESSION 的默认实现,恕我直言,只有大锁才有意义。如果我要使用我在整个脚本开头读到的值,那么只有大锁才有意义。如果我将 $_SESSION 实现更改为“始终”获取“新鲜”值,那么您不需要 BIG LOCK。

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

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

从 (1) 和 (2) 可以看出,无论您的脚本多么复杂,X 读取和 Y 写入会话,

会话处理程序 read() 和 write() 方法只调用一次 它们总是被调用

现在,网上有一些自定义 PHP 会话处理程序尝试执行“变量”级锁定等。我仍在尝试找出其中的一些。但是我不赞成复杂的方案。

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

这里我包含了我为实现锁定而编写的代码。用一些“比赛模拟”脚本来测试它会很好。我相信它应该工作。我在网上找到的正确实现并不多。如果您能指出错误,那就太好了。我用裸 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);
            

        

    

?>

要注册对象会话Handler,

$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();
        

    

?>

【讨论】:

在你写的时候,你应该考虑INSERT ... ON DUPLICATE KEY UPDATE或者只是UPDATE,因为REPLACE确实删除了一行并插入了一个新行。 我们无论如何都会在退出时删除那个 session_row,所以我认为再删除一次应该没问题;)但是是的,我应该插入/更新而不是替换。我同意插入 + 更新会比删除 + 插入更好 我添加了一个 PDO 版本来处理@Marcus Adams 提出的问题。 这里一直更新代码有点困难,所以我打算把代码移到github上,gist.github.com/2689318【参考方案2】:

我只是想补充一点(您可能已经知道)PHP 的默认会话存储(使用文件)确实会锁定会话文件。显然,将文件用于会话有很多缺点,这可能就是您寻找数据库解决方案的原因。

【讨论】:

将会话移动到数据库通常是为了让一组 Web 服务器共享会话数据。【参考方案3】:

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

我发现通过持续检查锁定来阻止脚本执行存在问题。你建议 PHP 在每次会话初始化时运行最多 40 秒来寻找这个锁(如果我没看错的话。)

推荐

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

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

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

【讨论】:

我认为您误解了这里所说的内容。相关的不是数据库级锁定,而是会话级锁定。因此,我们正在寻找一种方法来避免对会话数据进行不一致和无序的更新。【参考方案4】:

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

【讨论】:

内置的PHP会话系统每次写入,即使数据没有改变。在实践中,会话数据确实会被践踏,尤其是在现代标签时代。 它每次都调用write,但实际上您不必每次都写入数据库。比如 if($new !== $old) write_to_db();

以上是关于如何在 PHP + MySQL 中正确实现自定义会话持久化器?的主要内容,如果未能解决你的问题,请参考以下文章

如何在.Net中正确实现自定义计时器

在 Rails 4 中正确实现 Parsley.js 自定义远程验证器

如何在php中正确实现结构化菜单

在PHP中正确实现模型类

如何在python的类中正确实现辅助函数

如何在iOS7中正确定位后退按钮