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:
<?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():
- 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:
- 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
Brak komentarzy.