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:
Artikel C ist verloren!
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:
- Nutze
snc/redis-bundlemit aktiviertem Locking für den Session Handler - Verschiebe volatile Daten aus Sessions (Warenkörbe, Einstellungen → Datenbank)
- Mache API-Endpoints stateless wo möglich
- 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.
