返回首页

在 Redis 中锁定 PHP 会话:SessionHandler

本文描述了 phpredis 中的竞态条件问题,以及基于 SET NX 和 Lua 脚本的 RedisSessionHandler 锁定机制实现。提供了完整类代码、测试脚本和生产优化建议。

带锁的 Redis 中的 PHP 会话:完整指南
Advertisement 728x90

PHP Redis 会话锁机制:自定义 SessionHandler 实现

phpredis 扩展允许你在不修改应用代码的情况下,将 PHP 会话存储到 Redis 中。但它缺少锁机制。与文件存储不同(打开会话文件时会锁定文件),Redis 允许多个并发请求相互覆盖数据。

如果有 100 个异步请求各自添加一个会话参数,你会丢失 20–40 个条目。即使两个同时请求,也常常导致数据丢失。

演示竞态条件的测试脚本:

Google AdInline article slot
<?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() 逻辑:

Google AdInline article slot
  • 生成唯一令牌
  • 锁键:{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,集成锁逻辑:

Google AdInline article slot
  • 构造函数: 初始化 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

Advertisement 728x90

继续阅读