PHP Redis 会话锁机制:自定义 SessionHandler 实现
phpredis 扩展允许你在不修改应用代码的情况下,将 PHP 会话存储到 Redis 中。但它缺少锁机制。与文件存储不同(打开会话文件时会锁定文件),Redis 允许多个并发请求相互覆盖数据。
如果有 100 个异步请求各自添加一个会话参数,你会丢失 20–40 个条目。即使两个同时请求,也常常导致数据丢失。
演示竞态条件的测试脚本:
<?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;
}
结果:只保存 60–80 个参数,而不是 100 个。这使得 phpredis 不适合有并发访问的生产环境。
Redis 锁机制
支持自旋锁的自定义 SessionHandler。锁使用 SET NX(仅当键不存在时设置)和 uniqid() 生成的唯一令牌。
lockSession() 逻辑:
- 生成唯一令牌
- 锁键:
{sessionId}.lock - 重试循环,使用 usleep() 延迟,最多等待 max_execution_time 的 70%
- 仅当键不存在时成功
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;
}
通过 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;
}
这能防止误删他人的锁。
完整 RedisSessionHandler 实现
类实现 SessionHandlerInterface,集成锁逻辑:
- 构造函数: 初始化 Redis,TTL 来自 gc_maxlifetime,lockMaxWait = max_execution_time 的 70%
- read(): 读取前加锁
- write(): 使用 setex 设置 TTL 或 set
- close(): 解锁
- destroy(): 删除会话 + close()
- gc(): 委托给 Redis
完整代码:
class RedisSessionHandler implements \SessionHandlerInterface
{
// ... (完整生产级代码 ~250+ 行)
}
(完整类代码见原文。生产环境必备。)
设置与优化
初始化:
$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();
测试结果:
- 100 个参数全部可靠保存
- 处理时间增加微乎其微
生产优化:
- 仅在需要时调用 session_start()
- 使用完会话后调用 session_write_close()
- 调整 spinLockWait(默认 200μs)
- 根据脚本时长设置 lockMaxWait
关键要点
- 标准 phpredis 在并发下无锁会丢失数据
- 自定义 SessionHandler 使用 SET NX + Lua 解锁解决问题
- 真实负载下锁开销极小
- 始终及时关闭会话,使用 session_write_close()
- 会话 TTL 来自 ini_get('gc_maxlifetime')
— Editorial Team
暂无评论。