PHP Session Locking in Redis: Custom SessionHandler Implementation
The phpredis extension lets you store PHP sessions in Redis without changing your app code. But it lacks locking. Unlike file storage, where an open session locks the file, Redis lets concurrent requests overwrite each other's data.
With 100 async requests each adding a session parameter, you lose 20–40 entries. Even two simultaneous requests often clobber data.
Test script to demo the race condition:
<?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>\n<script>\n$(document).ready(function() {\n for(var i = 0; i < 100; i++) {\n $.ajax({
type: "post",
url: "?",
dataType: "json",
data: {
name: i,
cmd: "set"
}
});\n }\n\n res = function() {\n window.location = "?cmd=result";\n }\n\n setTimeout(res, 10000);\n});\n</script>';
break;
}
Result: 60–80 parameters instead of 100. This makes phpredis unsuitable for production systems with concurrent access.
Redis Locking Mechanism
Custom SessionHandler with spinlock support. Locks use SET NX (set if not exists) and a unique token from uniqid().
lockSession() logic:
- Generate unique token
- Lock key:
{sessionId}.lock - Retry loop with usleep() delay up to 70% of max_execution_time
- Success only if key doesn't exist
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;
}
Unlock via Lua script for atomic token check:
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;
}
This prevents removing someone else's lock.
Full RedisSessionHandler Implementation
Class implements SessionHandlerInterface with lock logic:
- Constructor: Init Redis, TTL from gc_maxlifetime, lockMaxWait = 70% max_execution_time
- read(): Lock before reading
- write(): setex with TTL or set
- close(): Unlock
- destroy(): Delete session + close()
- gc(): Delegate to Redis
Full code:
class RedisSessionHandler implements \SessionHandlerInterface
{
// ... (full production-ready code ~250+ lines)
}
(Complete class code available in original article. Essential for production use.)
Setup and Optimization
Initialization:
$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();
Test results:
- All 100 parameters saved reliably
- Processing time increase minimal
Production optimizations:
- Call session_start() only when needed
- Use session_write_close() after finishing with session
- Tune spinLockWait (default 200μs)
- Set lockMaxWait based on script duration
Key Takeaways
- Standard phpredis loses data under concurrency without locks
- Custom SessionHandler with SET NX + Lua unlock fixes it
- Locking adds minimal overhead under real load
- Always close sessions timely with session_write_close()
- Session TTL from ini_get('gc_maxlifetime')
— Editorial Team
No comments yet.