Volver al inicio

Bloqueo de sesiones PHP en Redis: SessionHandler

El artículo describe problemas de condición de carrera en phpredis e implementación de RedisSessionHandler con mecanismo de bloqueo en SET NX y scripts Lua. Código completo de la clase, script de prueba y recomendaciones de optimización de producción.

Sesiones PHP en Redis con bloqueos: guía completa
Advertisement 728x90

# 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:

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

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():

Google AdInline article slot
  • 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:

Google AdInline article slot
  • 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

Advertisement 728x90

Leer después