草庐IT

php - 如何在 PHP + MySQL 中正确实现自定义 session 持久器?

coder 2023-06-12 原文

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

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

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

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

补充:

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

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

  • 任何人都可以看到这个缺陷吗?

    最佳答案

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

    2)首先澄清 - 何时读取和写入 session 数据

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

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

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

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

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

  • 解释大惊小怪

    脚本1
  • 1: $x = S_SESSION["X"];
  • 2: sleep (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 正在根据 session 变量 (line:3) 值执行某些操作,而该值已在脚本 1 已运行时被另一个脚本更改。这是一个骨架示例,但它说明了这一点。您正在根据不再正确的事情做出决定的事实。

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

    说明 4:PHP session 同步与变量同步不同

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

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

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

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

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

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

    从 (1) 和 (2) 可以看出,无论您的脚本多么复杂,X 读取和 Y 写入 session ,
  • session 处理程序 read() 和 write() 方法只调用一次
  • 他们总是被称为

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

    假设带有 $_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);
                }
    
            }
    
        }
    }
    ?>
    

    要注册对象 session 处理程序,
    $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 查询。陈旧的 session 清理可以通过 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();
            }
    
        }
    }
    ?>
    

    关于php - 如何在 PHP + MySQL 中正确实现自定义 session 持久器?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/1022416/

    有关php - 如何在 PHP + MySQL 中正确实现自定义 session 持久器?的更多相关文章

    1. ruby - 如何在 Ruby 中顺序创建 PI - 2

      出于纯粹的兴趣,我很好奇如何按顺序创建PI,而不是在过程结果之后生成数字,而是让数字在过程本身生成时显示。如果是这种情况,那么数字可以自行产生,我可以对以前看到的数字实现垃圾收集,从而创建一个无限系列。结果只是在Pi系列之后每秒生成一个数字。这是我通过互联网筛选的结果:这是流行的计算机友好算法,类机器算法:defarccot(x,unity)xpow=unity/xn=1sign=1sum=0loopdoterm=xpow/nbreakifterm==0sum+=sign*(xpow/n)xpow/=x*xn+=2sign=-signendsumenddefcalc_pi(digits

    2. ruby - 如何在 buildr 项目中使用 Ruby 代码? - 2

      如何在buildr项目中使用Ruby?我在很多不同的项目中使用过Ruby、JRuby、Java和Clojure。我目前正在使用我的标准Ruby开发一个模拟应用程序,我想尝试使用Clojure后端(我确实喜欢功能代码)以及JRubygui和测试套件。我还可以看到在未来的不同项目中使用Scala作为后端。我想我要为我的项目尝试一下buildr(http://buildr.apache.org/),但我注意到buildr似乎没有设置为在项目中使用JRuby代码本身!这看起来有点傻,因为该工具旨在统一通用的JVM语言并且是在ruby中构建的。除了将输出的jar包含在一个独特的、仅限ruby​​

    3. ruby - 什么是填充的 Base64 编码字符串以及如何在 ruby​​ 中生成它们? - 2

      我正在使用的第三方API的文档状态:"[O]urAPIonlyacceptspaddedBase64encodedstrings."什么是“填充的Base64编码字符串”以及如何在Ruby中生成它们。下面的代码是我第一次尝试创建转换为Base64的JSON格式数据。xa=Base64.encode64(a.to_json) 最佳答案 他们说的padding其实就是Base64本身的一部分。它是末尾的“=”和“==”。Base64将3个字节的数据包编码为4个编码字符。所以如果你的输入数据有长度n和n%3=1=>"=="末尾用于填充n%

    4. ruby-on-rails - 如何在 ruby​​ 中使用两个参数异步运行 exe? - 2

      exe应该在我打开页面时运行。异步进程需要运行。有什么方法可以在ruby​​中使用两个参数异步运行exe吗?我已经尝试过ruby​​命令-system()、exec()但它正在等待过程完成。我需要用参数启动exe,无需等待进程完成是否有任何ruby​​gems会支持我的问题? 最佳答案 您可以使用Process.spawn和Process.wait2:pid=Process.spawn'your.exe','--option'#Later...pid,status=Process.wait2pid您的程序将作为解释器的子进程执行。除

    5. ruby-on-rails - 如何优雅地重启 thin + nginx? - 2

      我的瘦服务器配置了nginx,我的ROR应用程序正在它们上运行。在我发布代码更新时运行thinrestart会给我的应用程序带来一些停机时间。我试图弄清楚如何优雅地重启正在运行的Thin实例,但找不到好的解决方案。有没有人能做到这一点? 最佳答案 #Restartjustthethinserverdescribedbythatconfigsudothin-C/etc/thin/mysite.ymlrestartNginx将继续运行并代理请求。如果您将Nginx设置为使用多个上游服务器,例如server{listen80;server

    6. ruby - 如何在续集中重新加载表模式? - 2

      鉴于我有以下迁移:Sequel.migrationdoupdoalter_table:usersdoadd_column:is_admin,:default=>falseend#SequelrunsaDESCRIBEtablestatement,whenthemodelisloaded.#Atthispoint,itdoesnotknowthatusershaveais_adminflag.#Soitfails.@user=User.find(:email=>"admin@fancy-startup.example")@user.is_admin=true@user.save!ende

    7. ruby-on-rails - form_for 中不在模型中的自定义字段 - 2

      我想向我的Controller传递一个参数,它是一个简单的复选框,但我不知道如何在模型的form_for中引入它,这是我的观点:{:id=>'go_finance'}do|f|%>Transferirde:para:Entrada:"input",:placeholder=>"Quantofoiganho?"%>Saída:"output",:placeholder=>"Quantofoigasto?"%>Nota:我想做一个额外的复选框,但我该怎么做,模型中没有一个对象,而是一个要检查的对象,以便在Controller中创建一个ifelse,如果没有检查,请帮助我,非常感谢,谢谢

    8. ruby - 如何在 Ruby 中拆分参数字符串 Bash 样式? - 2

      我正在为一个项目制作一个简单的shell,我希望像在Bash中一样解析参数字符串。foobar"helloworld"fooz应该变成:["foo","bar","helloworld","fooz"]等等。到目前为止,我一直在使用CSV::parse_line,将列分隔符设置为""和.compact输出。问题是我现在必须选择是要支持单引号还是双引号。CSV不支持超过一个分隔符。Python有一个名为shlex的模块:>>>shlex.split("Test'helloworld'foo")['Test','helloworld','foo']>>>shlex.split('Test"

    9. ruby - 如何在 Lion 上安装 Xcode 4.6,需要用 RVM 升级 ruby - 2

      我实际上是在尝试使用RVM在我的OSX10.7.5上更新ruby,并在输入以下命令后:rvminstallruby我得到了以下回复:Searchingforbinaryrubies,thismighttakesometime.Checkingrequirementsforosx.Installingrequirementsforosx.Updatingsystem.......Errorrunning'requirements_osx_brew_update_systemruby-2.0.0-p247',pleaseread/Users/username/.rvm/log/138121

    10. ruby-on-rails - 如何在 ruby​​ 交互式 shell 中有多行? - 2

      这可能是个愚蠢的问题。但是,我是一个新手......你怎么能在交互式ruby​​shell中有多行代码?好像你只能有一条长线。按回车键运行代码。无论如何我可以在不运行代码的情况下跳到下一行吗?再次抱歉,如果这是一个愚蠢的问题。谢谢。 最佳答案 这是一个例子:2.1.2:053>a=1=>12.1.2:054>b=2=>22.1.2:055>a+b=>32.1.2:056>ifa>b#Thecode‘if..."startsthedefinitionoftheconditionalstatement.2.1.2:057?>puts"f

    随机推荐