ロックを使用してPHPセッションをRedisに保存する

ユーザーセッションデータをphpに保存するための標準的なメカニズムは、ファイルストレージです。 ただし、負荷を分散するためにアプリケーションが複数のサーバーで実行されている場合、各アプリケーションサーバーからアクセス可能なストレージにセッションデータを保存する必要があります。 この場合、 Redisはセッションの保存に適しています。



最も一般的なソリューションはphpredis拡張機能です。 拡張機能をインストールしてphp.iniを構成するだけで、アプリケーションコードを変更せずにセッションが自動的にRedisに保存されます。



ただし、このソリューションには欠点があります-セッションをブロックしません。



セッションをファイルに保存するための標準メカニズムを使用する場合、開いているセッションは、閉じられるまでファイルをロックします。 セッションへの複数の同時アクセスにより、新しいリクエストは前のリクエストがセッションの処理を完了するまで待機します。 ただし、phpredisを使用する場合、そのようなロックメカニズムはありません。 いくつかの非同期リクエストでは、競合が同時に発生し、セッションに記録された一部のデータが失われる可能性があります。



これは簡単に確認できます。 サーバーに非同期で100個の要求を送信し、それぞれがパラメーターをセッションに書き込みます。次に、セッション内のパラメーターの数をカウントします。



テストスクリプト
<?php session_start(); $cmd = $_GET['cmd'] ?? ($_POST['cmd'] ?? ''); switch ($cmd) { case 'result': echo(count($_SESSION)); break; case "set": $_SESSION['param_' . $_POST['name']] = 1; break; default: $_SESSION = []; echo '<script src="https://code.jquery.com/jquery-1.11.3.js"></script> <script> $(document).ready(function() { for(var i = 0; i < 100; i++) { $.ajax({ type: "post", url: "?", dataType: "json", data: { name: i, cmd: "set" } }); } res = function() { window.location = "?cmd=result"; } setTimeout(res, 10000); }); </script> '; break; }
      
      







その結果、セッションには100個のパラメーターではなく、60〜80個のパラメーターがあります。 残りのデータは失われました。

実際のアプリケーションでは、もちろん100の同時リクエストはありませんが、実際には、非同期の同時リクエストが2つある場合でも、一方のリクエストによって記録されたデータがもう一方によって上書きされることがよくあります。 したがって、phpredis拡張機能を使用してセッションを保存することは安全ではなく、データが失われる可能性があります。



問題の解決策の1つとして、ロックをサポートする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フラグで設定されます。つまり、インストールはそのようなキーがない場合にのみ行われます。 そのようなキーが存在する場合、しばらくしてから再試行します。



大根では限られたキーの寿命を使用することもできますが、キーのインストール後にスクリプトの作業時間を変更することができ、現在のスクリプトでの作業が終了するまで並列プロセスはセッションにアクセスできます。 スクリプトの最後で、キーはいずれの場合でも削除されます。



スクリプトの終了時にセッションのロックを解除するとき、 Luaスクリプトを使用してキーを削除します。



  private function unlockSession() { $script = <<<LUA if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end LUA; $this->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 = <<<LUA if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end LUA; $this->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を接続した後、テストスクリプトは1つのセッションで100個のパラメーターを自信を持って表示します。 同時に、ブロッキングにもかかわらず、100リクエストの合計処理時間はわずかに増加しました。 実際には、それほど多くの同時リクエストはありません。 ただし、スクリプトの実行時間は通常より重要であり、同時リクエストでは顕著な期待が寄せられる場合があります。 したがって、スクリプトセッションでの作業に費やす時間を減らすことを検討する必要がありますセッションで作業する必要がある場合のみsession_start()を呼び出し、セッションで作業を終了するときにsession_write_close()を呼び出します)



参照資料



» githubリポジトリへのリンク

» Redis Lockページ



All Articles