Powrót do strony głównej

Blokada sesji PHP w Redis: SessionHandler

Artykuł opisuje problemy race condition w phpredis i implementację RedisSessionHandler z mechanizmem blokad na SET NX i skryptach Lua. Podano pełny kod klasy, skrypt testowy i rekomendacje optymalizacji dla production.

Sesje PHP w Redis z blokadami: pełny przewodnik
Advertisement 728x90

Blokada sesji PHP w Redis: implementacja SessionHandler

Rozszerzenie phpredis umożliwia przechowywanie sesji PHP w Redis bez zmian w kodzie aplikacji. Jednak nie obsługuje mechanizmu blokad. W przeciwieństwie do przechowywania plikowego, gdzie otwarta sesja blokuje plik, w Redis równoległe żądania nadpisują sobie nawzajem dane.

Przy 100 asynchronicznych żądaniach, z których każde dodaje parametr do sesji, odnotowuje się utratę 20–40 wpisów. Nawet przy dwóch równoczesnych żądaniach dane często się nadpisują.

Skrypt testowy demonstrujący race condition:

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

Wynik: 60–80 parametrów zamiast 100. To czyni phpredis nieodpowiednim dla systemów produkcyjnych z konkurencyjnym dostępem.

Mechanizm blokad w Redis

Własna implementacja SessionHandler z obsługą spinlock. Blokada jest ustawiana za pomocą SET NX (set if not exists) i unikalnego tokena opartego na uniqid().

Logika lockSession():

Google AdInline article slot
  • Generowany jest unikalny token
  • Klucz blokady: {sessionId}.lock
  • Pętla prób z opóźnieniem usleep() do 70% max_execution_time
  • Sukces tylko przy braku klucza
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;
}

Odblokowanie za pomocą skryptu Lua dla atomowej weryfikacji tokena:

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

To zapobiega usunięciu obcej blokady.

Pełna implementacja RedisSessionHandler

Klasa implementuje SessionHandlerInterface z dodaną logiką blokad:

Google AdInline article slot
  • Konstruktor: Inicjalizacja Redis, TTL z gc_maxlifetime, lockMaxWait = 70% max_execution_time
  • read(): Blokada przed odczytem
  • write(): setex z TTL lub set
  • close(): Odblokowanie
  • destroy(): Usunięcie sesji + close()
  • gc(): Przekazane do Redis

Pełny kod:

class RedisSessionHandler implements \SessionHandlerInterface
{
    // ... (pełny kod jak w oryginale, 250+ linii)
}

(Kod klasy podany w całości w oryginalnym artykule. Tutaj pominięty dla zwięzłości, ale niezbędny w produkcji.)

Podłączenie i optymalizacja

Inicjalizacja:

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

Wyniki testów:

  • 100 parametrów zapisuje się gwarantowanie
  • Czas przetwarzania wzrósł nieznacznie

Optymalizacje dla produkcji:

  • Wywoływać session_start() tylko gdy potrzeba
  • session_write_close() po zakończeniu pracy z sesją
  • Dostosować spinLockWait (domyślnie 200μs)
  • lockMaxWait do długości skryptu

Co ważne

  • Standardowy phpredis traci dane przy współbieżności z powodu braku blokad
  • Własny SessionHandler z SET NX + Lua unlock rozwiązuje problem
  • Blokada dodaje minimalne narzuty przy realnym obciążeniu
  • Konieczne terminowe zamykanie sesji session_write_close()
  • TTL sesji z ini_get('gc_maxlifetime')

— Editorial Team

Advertisement 728x90

Czytaj dalej