Redis Session Locking Fallstricke in Symfony: Warum Nutzer zufällig ausgeloggt werden

Oder: Die Race Condition, die jedes Team beim Skalieren auf Redis erwischt

“Nutzer werden zufällig ausgeloggt.”

Der Bugreport kam an einem Freitagnachmittag. Unser Monitoring zeigte normalen Traffic, keine Spitzen, keine Fehler. Trotzdem verloren Nutzer ihre Sessions. Manche verloren Flash-Messages. Andere bekamen “Invalid CSRF token”-Fehler beim Absenden von Formularen.

Wir nutzten Redis für Sessions. Es schien die offensichtliche Wahl für unser Multi-Server-Setup. Was wir nicht wussten: Redis Sessions haben ein fundamentales Problem, vor dem Symfonys Dokumentation warnt—aber nicht laut genug.


Das Problem: Redis hat kein Locking

Permalink to "Das Problem: Redis hat kein Locking"

PHPs Standard-dateibasierte Sessions nutzen File Locking. Wenn ein Request eine Session liest, warten andere Requests. Das verhindert Race Conditions.

Redis hat keinen solchen Mechanismus. Symfonys RedisSessionHandler liest und schreibt ohne Locking.

Aus der Symfony-Dokumentation:

Redis hat kein Session Locking. Es kann zu Race Conditions kommen, wenn du auf Sessions zugreifst. Zum Beispiel “Invalid CSRF token”-Fehler.

“Kann zu Race Conditions kommen” ist untertrieben. Wenn deine App parallele Requests macht, die die Session berühren, wirst du dieses Problem haben.


Wie Race Conditions Sessions zerstören

Permalink to "Wie Race Conditions Sessions zerstören"

Das passiert bei parallelen AJAX-Requests:

Beide Requests lesen den gleichen Session-Zustand. Beide modifizieren ihn unabhängig. Der letzte Schreibvorgang gewinnt, und die erste Änderung geht verloren.

Symptome in der Praxis

Permalink to "Symptome in der Praxis"
Symptom Ursache
Zufällige Logouts Auth-Token von parallelem Request überschrieben
Verlorene Flash-Messages Flash von einem Request konsumiert, vom anderen ohne sie neu gespeichert
CSRF-Token-Fehler Token von einem Request regeneriert, alter Token von anderem gesendet
Warenkorb-Artikel verschwinden Warenkorb-Zustand von parallelem Request überschrieben
Inkonsistente Nutzer-Einstellungen Einstellungen von parallelen Seitenaufrufen überschrieben

Warum AJAX-lastige Apps am härtesten betroffen sind

Permalink to "Warum AJAX-lastige Apps am härtesten betroffen sind"

Moderne SPAs und AJAX-lastige Anwendungen machen mehrere parallele Requests:

// Dashboard lädt - 5 parallele Requests
Promise.all([
  fetch("/api/notifications"),
  fetch("/api/messages"),
  fetch("/api/stats"),
  fetch("/api/recent-activity"),
  fetch("/api/user-profile"),
]);

Jeder Request liest die Session am Anfang und schreibt sie am Ende. Mit File Sessions würden sie sich einreihen. Mit Redis lesen alle den gleichen Zustand und überschreiben sich gegenseitig.


Das Problem diagnostizieren

Permalink to "Das Problem diagnostizieren"

1. Auf parallele Requests prüfen

Permalink to "1. Auf parallele Requests prüfen"

Öffne den Network-Tab deines Browsers. Schau nach parallelen Requests zur gleichen Anwendung. Wenn du mehrere Requests siehst, die gleichzeitig starten, bist du gefährdet.

2. Session-Schreibvorgänge loggen

Permalink to "2. Session-Schreibvorgänge loggen"

Füge temporär Logging hinzu, um Session-Überschreibungen zu sehen:

// src/EventSubscriber/SessionDebugSubscriber.php
namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Psr\Log\LoggerInterface;

class SessionDebugSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private LoggerInterface $logger
    ) {}

    public static function getSubscribedEvents(): array
    {
        return [KernelEvents::RESPONSE => 'onResponse'];
    }

    public function onResponse(ResponseEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $request = $event->getRequest();
        $session = $request->getSession();

        $this->logger->debug('Session write', [
            'request_id' => $request->headers->get('X-Request-ID', uniqid()),
            'uri' => $request->getRequestUri(),
            'session_id' => $session->getId(),
            'session_data_keys' => array_keys($session->all()),
        ]);
    }
}

Achte auf überlappende Zeitstempel mit der gleichen Session-ID—das sind deine Race Conditions.

3. Mit Artillery reproduzieren

Permalink to "3. Mit Artillery reproduzieren"
# artillery-session-test.yml
config:
  target: "https://deine-app.de"
  phases:
    - duration: 10
      arrivalRate: 10
  cookies:
    sessionid: "test-session-123"

scenarios:
  - name: "Paralleler Session-Zugriff"
    flow:
      - get:
          url: "/api/endpoint-1"
      - get:
          url: "/api/endpoint-2"
      - get:
          url: "/api/endpoint-3"

Führe es aus und prüfe auf verlorene Daten oder CSRF-Fehler.


Lösungen

Permalink to "Lösungen"

Lösung 1: Sessions früh schließen

Permalink to "Lösung 1: Sessions früh schließen"

Wenn ein Request nur Session-Daten liest, schließe die Session sofort:

#[Route('/api/notifications')]
public function notifications(Request $request): JsonResponse
{
    $session = $request->getSession();
    $userId = $session->get('user_id');

    // Session schließen - keine Schreibvorgänge mehr möglich, aber kein Lock gehalten
    $session->save();

    // Jetzt die langsame Arbeit machen
    $notifications = $this->notificationService->getForUser($userId);

    return $this->json($notifications);
}

Das reduziert das Zeitfenster für Race Conditions, eliminiert sie aber nicht.

Lösung 2: Redis Handler mit Locking nutzen

Permalink to "Lösung 2: Redis Handler mit Locking nutzen"

Das snc/redis-bundle bietet einen Session Handler mit Locking:

composer require snc/redis-bundle
# config/packages/snc_redis.yaml
snc_redis:
  clients:
    default:
      type: predis
      alias: default
      dsn: "%env(REDIS_URL)%"
  session:
    client: default
    locking: true # Locking aktivieren!
    spin_lock_wait: 150000 # Mikrosekunden zwischen Lock-Versuchen
    lock_max_wait: 10 # Sekunden bis zum Aufgeben
# config/packages/framework.yaml
framework:
  session:
    handler_id: snc_redis.session.handler

Der Handler nutzt Redis SETNX, um einen Spinlock zu implementieren. Requests warten auf den Lock, bevor sie auf die Session zugreifen.

Trade-off: Locking erhöht die Latenz. Parallele Requests müssen jetzt warten, statt gleichzeitig zu laufen.

Lösung 3: Session-Nutzung minimieren

Permalink to "Lösung 3: Session-Nutzung minimieren"

Die beste Lösung ist oft architektonisch: speicher keine Daten in Sessions, die sich häufig ändern.

Vorher (problematisch):

// Jede Warenkorb-Änderung berührt die Session
$session->set('cart', $cart->toArray());

Nachher (besser):

// Warenkorb in Datenbank gespeichert, nur Warenkorb-ID in Session
$session->set('cart_id', $cart->getId());
$this->cartRepository->save($cart);

Schieb Daten, die sich oft ändern, in die Datenbank. In der Session bleiben nur IDs.

Lösung 4: Stateless, wo es geht

Permalink to "Lösung 4: Stateless, wo es geht"

Für API-Endpoints: nimm JWT oder token-basierte Auth, die keine Sessions braucht:

# config/packages/security.yaml
security:
  firewalls:
    api:
      pattern: ^/api/
      stateless: true
      jwt: ~
    main:
      pattern: ^/
      # Sessions für Web-UI behalten

API-Endpoints werden immun gegen Session Race Conditions.


Die Nuklear-Option: Datenbank Sessions

Permalink to "Die Nuklear-Option: Datenbank Sessions"

Wenn Locking kritisch ist und du langsamere Performance akzeptieren kannst, lösen Datenbank Sessions das Problem:

framework:
  session:
    handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler
services:
    Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
        arguments:
            - '%env(DATABASE_URL)%'
            - lock_mode: !php/const Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler::LOCK_ADVISORY

Datenbank Sessions mit LOCK_ADVISORY haben richtiges Locking—ohne die Komplexität, es selbst in Redis zu bauen.


Vergleich der Ansätze

Permalink to "Vergleich der Ansätze"
Ansatz Komplexität Performance Löst Problem?
Sessions früh schließen Niedrig Beste Teilweise
snc/redis-bundle Locking Mittel Gut Ja
Session-Nutzung minimieren Mittel Beste Ja (architektonisch)
Stateless APIs Mittel Beste Ja (für APIs)
Datenbank Sessions Niedrig Langsamer Ja

Meine Empfehlung

Permalink to "Meine Empfehlung"

Für die meisten Anwendungen:

  1. Nutze snc/redis-bundle mit aktiviertem Locking für den Session Handler
  2. Verschiebe volatile Daten aus Sessions (Warenkörbe, Einstellungen → Datenbank)
  3. Mache API-Endpoints stateless wo möglich
  4. Schließe Sessions früh in read-only Endpoints

Der Performance-Hit durch Locking ist meist akzeptabel. Die Alternative—zufälliger Datenverlust—ist es nicht.


Erkennen, ob du betroffen bist

Permalink to "Erkennen, ob du betroffen bist"

Schnell-Checkliste:

  • [ ] Nutzt Redis oder Memcached für Sessions?
  • [ ] Anwendung macht parallele AJAX-Requests?
  • [ ] Nutzer melden zufällige Logouts oder verlorene Daten?
  • [ ] CSRF-Token-Fehler erscheinen sporadisch?
  • [ ] Flash-Messages verschwinden manchmal?

Wenn du mehrere Punkte angekreuzt hast, hast du wahrscheinlich dieses Problem.


Redis ist schnell und skaliert gut—aber das fehlende Session Locking erzeugt Race Conditions, die Daten zerstören. Die Lösung: Session Handler mit Locking nutzen, Session-Nutzung minimieren, oder Datenbank Sessions für kritische Anwendungen.