📜 ⬆️ ⬇️

Storing php sessions in Redis with locks

The standard mechanism for storing user session data in php is storage in files. However, when an application is running on multiple servers for load balancing, there is a need to store session data in a storage accessible by each application server. In this case, Redis is well suited for storing sessions.

The most popular solution is the phpredis extension. It is enough to install the extension and configure php.ini and the sessions will be automatically saved in Redis without changing the application code.

However, this solution has the disadvantage of not blocking the session.

When using the standard session storage mechanism in files, an open session locks the file until it is closed. With several simultaneous access to the session, new requests will wait until the previous one closes the session. However, when using phpredis, there is no such blocking mechanism. With multiple asynchronous requests, a race occurs at the same time, and some data recorded in the session may be lost.
')
It is easy to check. We send asynchronously 100 requests to the server, each of which writes its own parameter to the session, then we count the number of parameters in the session.

Test script
<?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; } 


As a result, we get that in the session there are not 100 parameters, but 60-80. The rest of the data we lost.
Of course, there will not be 100 simultaneous requests in real-world applications, but practice shows that even with two asynchronous simultaneous requests, the data written by one of the requests is quite often erased by the other. Thus, using the phpredis extension to store sessions is unsafe and can lead to data loss.

As one of the solutions to the problem - your SessionHandler , which supports locks.

Implementation


To set the session lock, set the lock key value to a randomly generated (based on uniqid) value. The value must be unique so that any parallel query cannot access.

  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; } 

The value is set with the NX flag, that is, installation occurs only if there is no such key. If such a key exists, make another attempt after some time.

You can also use a limited key lifetime in radish, however, the script runtime can be changed after the key is installed, and the parallel process can access the session before it is finished working with it in the current script. When the script is completed, the key is deleted in any case.

When unlocking a session when the script is finished, use the Lua script to delete the key:

  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; } 

You cannot use the DEL command, because with it you can delete a key set by another script. Such a script guarantees deletion only if the lock key corresponds to a unique value set by the current script.

Full class code
 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(); } } 


Connection


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

Result


After connecting our SessionHandler, our test script confidently displays 100 parameters per session. At the same time, despite the blocking, the total processing time for 100 requests grew slightly. In actual practice, such a number of simultaneous requests will not be. However, the running time of the script is usually more significant, and with simultaneous requests there can be a noticeable wait. Therefore, you need to think about reducing the time to work with the script session (call session_start () only if you need to work with the session and session_write_close () when you finish working with it)

Links


» Link to the githaba repository
» Redis Blocking Page

Source: https://habr.com/ru/post/318836/


All Articles