# Bloqueo de sesiones PHP en Redis: Implementación personalizada de SessionHandler
La extensión phpredis permite guardar sesiones PHP en Redis sin modificar el código de tu aplicación. Pero le falta el bloqueo. A diferencia del almacenamiento en archivos, donde una sesión abierta bloquea el archivo, Redis permite que solicitudes concurrentes sobrescriban los datos mutuamente.
Con 100 peticiones asíncronas que cada una añade un parámetro a la sesión, pierdes entre 20-40 entradas. Incluso dos solicitudes simultáneas suelen corromper los datos.
Script de prueba para demostrar la condición de carrera:
<?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;
}
Resultado: 60-80 parámetros en lugar de 100. Esto hace que phpredis no sea apto para sistemas de producción con acceso concurrente.
Mecanismo de bloqueo en Redis
SessionHandler personalizado con soporte para spinlock. Los bloqueos usan SET NX (establecer si no existe) y un token único generado con uniqid().
Lógica de lockSession():
- Generar token único
- Clave de bloqueo:
{sessionId}.lock - Bucle de reintentos con usleep() hasta el 70% de max_execution_time
- Éxito solo si la clave no existe
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;
}
Desbloqueo mediante script Lua para verificación atómica del token:
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;
}
Esto evita eliminar el bloqueo de otra sesión.
Implementación completa de RedisSessionHandler
La clase implementa SessionHandlerInterface con lógica de bloqueo:
- Constructor: Inicializa Redis, TTL desde gc_maxlifetime, lockMaxWait = 70% max_execution_time
- read(): Bloquea antes de leer
- write(): setex con TTL o set
- close(): Desbloquea
- destroy(): Elimina sesión + close()
- gc(): Delega a Redis
Código completo:
class RedisSessionHandler implements \SessionHandlerInterface
{
// ... (código completo listo para producción ~250+ líneas)
}
(Código completo de la clase disponible en el artículo original. Esencial para uso en producción.)
Configuración y optimización
Inicialización:
$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();
Resultados de pruebas:
- Los 100 parámetros se guardan de forma fiable
- Incremento mínimo en tiempo de procesamiento
Optimizaciones para producción:
- Llamar session_start() solo cuando sea necesario
- Usar session_write_close() tras terminar con la sesión
- Ajustar spinLockWait (por defecto 200μs)
- Configurar lockMaxWait según duración del script
Conclusiones clave
- phpredis estándar pierde datos con concurrencia sin bloqueos
- SessionHandler personalizado con SET NX + desbloqueo Lua lo soluciona
- El bloqueo añade overhead mínimo bajo carga real
- Siempre cerrar sesiones a tiempo con session_write_close()
- TTL de sesión desde ini_get('gc_maxlifetime')
— Editorial Team
Aún no hay comentarios.