Sechs Methoden zwischen PHP und Speicherort, an einem vollständigen Redis-Handler ohne Framework
Sobald mehr als ein php-fpm-Prozess läuft, wird die Standard-Session-Speicherung über Dateien im lokalen Dateisystem schnell zum Problem, spätestens beim horizontalen Skalieren über mehrere Server. SessionHandlerInterface bietet genau die sechs Methoden, um Session-Daten stattdessen in Redis, einer Datenbank oder einem beliebigen anderen Speicher abzulegen, komplett ohne Framework. Dieser Artikel zeigt die Implementierung im Detail, inklusive der Locking-Strategie, die parallele Requests derselben Session vor Datenverlust schützt.
Inhaltsverzeichnis
- 1. SessionHandlerInterface im Überblick und warum ein eigener Handler
- 2. open() und close(): Verbindungsaufbau und Abschluss
- 3. read(): Daten laden und die Locking-Semantik dahinter
- 4. write(), destroy() und gc(): Speichern, Löschen und Aufräumen
- 5. Race-Condition-Vermeidung bei parallelen Requests derselben Session
- 6. Praktisches Beispiel: Ein vollständiger Redis-Session-Handler
- 7. Registrierung mit session_set_save_handler
- 8. Vergleich mit SessionUpdateTimestampHandlerInterface
- 9. Fallstricke im produktiven Betrieb und sinnvolles Monitoring
- 10. Zusammenfassung
- 11. FAQ
1. SessionHandlerInterface im Überblick und warum ein eigener Handler
Der eingebaute PHP-Session-Mechanismus speichert Session-Daten standardmäßig als Dateien im durch session.save_path definierten Verzeichnis, was für einen einzelnen Server ausreicht, aber bei mehreren php-fpm-Instanzen hinter einem Load Balancer versagt, sobald zwei aufeinanderfolgende Requests desselben Nutzers auf unterschiedlichen Servern landen. SessionHandlerInterface, seit PHP 5.4 Teil der Sprache, definiert genau die sechs Methoden, die der interne Session-Mechanismus aufruft, unabhängig davon, wo die Daten am Ende tatsächlich landen.
Der entscheidende Vorteil gegenüber einer Framework-Lösung ist, dass sich dieser Mechanismus komplett ohne Symfony, Laravel oder ein anderes Framework nutzen lässt, denn er ist Teil der PHP-Standardbibliothek selbst. Man implementiert die sechs Methoden in einer eigenen Klasse, registriert sie mit session_set_save_handler, und PHP ruft ab diesem Zeitpunkt transparent diese Methoden statt der eingebauten Dateisystem-Logik auf, ohne dass sich am restlichen $_SESSION-Zugriff im Anwendungscode irgendetwas ändert.
2. open() und close(): Verbindungsaufbau und Abschluss
open wird von PHP genau einmal zu Beginn einer Session aufgerufen, bevor read das erste Mal ausgeführt wird, und erhält als Parameter den konfigurierten Save-Path sowie den Session-Namen, auch wenn ein Redis-basierter Handler beide in der Praxis meist ignoriert, da die Verbindungsdaten typischerweise bereits über den Konstruktor oder Dependency Injection bereitstehen. Der Rückgabewert muss ein Boolean sein, der signalisiert, ob die Initialisierung erfolgreich war.
close wird entsprechend am Ende der Session-Verarbeitung aufgerufen, meist implizit beim Skriptende oder explizit bei session_write_close, und sollte alle Ressourcen freigeben, die open reserviert hat. Bei einer bereits über Dependency Injection verwalteten Redis-Verbindung, die auch für andere Zwecke im Request genutzt wird, ist close in der Praxis oft ein reines No-Op, das lediglich true zurückgibt, weil das eigentliche Schließen der Verbindung an anderer Stelle im Lifecycle passiert.
3. read(): Daten laden und die Locking-Semantik dahinter
read erhält die Session-ID als Parameter und muss den zuvor gespeicherten, serialisierten Session-String zurückgeben, oder einen leeren String, wenn für diese ID noch keine Daten existieren. PHP deserialisiert diesen String intern selbst in das $_SESSION-Array, die Handler-Methode muss sich also nicht um Serialisierung im eigentlichen Sinn kümmern, sondern lediglich den rohen, bereits von PHP serialisierten String unverändert speichern und zurückgeben.
Der eingebaute Datei-Handler von PHP erwirbt bei read implizit einen exklusiven Dateisystem-Lock auf die Session-Datei, der erst bei write beziehungsweise close wieder freigegeben wird. Dieses Verhalten ist der Grund, warum zwei parallele Requests derselben Session mit dem Datei-Handler standardmäßig sequenziell statt parallel verarbeitet werden, denn der zweite Request wartet an dieser Stelle, bis der erste seinen Lock freigibt. Ein eigener Handler muss dieses Verhalten explizit nachbilden, wenn er dieselbe Konsistenzgarantie bieten will.
4. write(), destroy() und gc(): Speichern, Löschen und Aufräumen
write erhält die Session-ID sowie den von PHP bereits serialisierten Daten-String und muss diesen dauerhaft speichern, wobei der Rückgabewert wiederum ein Boolean für Erfolg oder Misserfolg ist. Wichtig ist, dass write auch dann aufgerufen wird, wenn sich der Inhalt von $_SESSION seit dem letzten read nicht verändert hat, PHP prüft das standardmäßig nicht selbst, sofern nicht session.lazy_write aktiviert und vom Handler entsprechend unterstützt wird.
destroy erhält ebenfalls die Session-ID und muss die zugehörigen Daten vollständig entfernen, etwa beim expliziten session_destroy-Aufruf infolge eines Logouts. gc schließlich erhält die konfigurierte Lebensdauer in Sekunden als Parameter und muss alle Sessions entfernen, die älter als dieser Wert sind, wobei der Rückgabewert seit PHP 7.1 nicht mehr ein Boolean, sondern die Anzahl der gelöschten Sessions als Integer oder false im Fehlerfall sein sollte.
5. Race-Condition-Vermeidung bei parallelen Requests derselben Session
Ohne eigenes Locking entsteht bei einem Redis-Handler ein klassisches Lost-Update-Problem: Zwei parallele Requests derselben Session, etwa ein normaler Seitenaufruf und ein per JavaScript ausgelöster Ajax-Call, lesen beide denselben Ausgangszustand, verändern jeweils unterschiedliche Teile von $_SESSION, und der zuletzt schreibende Request überschreibt dabei unbemerkt die Änderungen des anderen, weil write immer den kompletten serialisierten Zustand schreibt, nicht nur die geänderten Felder.
Die robuste Lösung ist ein pessimistisches Lock direkt in read, realisiert über den Redis-Befehl SET mit den Optionen NX (nur setzen, wenn der Schlüssel noch nicht existiert) und EX für eine automatische Ablaufzeit als Absicherung gegen hängengebliebene Locks. Der Handler versucht wiederholt mit kurzer Wartezeit, den Lock zu erwerben, bevor er die eigentlichen Session-Daten liest, und gibt ihn in write oder close wieder frei, sodass paralleles Schreiben derselben Session strikt serialisiert wird, ganz analog zum Dateisystem-Lock des eingebauten Handlers.
6. Praktisches Beispiel: Ein vollständiger Redis-Session-Handler
Das folgende Beispiel zeigt eine vollständige, framework-unabhängige Implementierung von SessionHandlerInterface, die Predis als Redis-Client nutzt und ein einfaches Spinlock-Pattern für die Lese-Sperre umsetzt. Jeder Session-Schlüssel erhält ein eigenes Redis-TTL, das bei jedem write erneuert wird, sodass abgelaufene Sessions automatisch durch Redis selbst entfernt werden und gc in diesem konkreten Fall nur noch als leere Formalität existiert.
Wichtig ist die konsequente Namensraum-Trennung über ein Präfix wie sess:, damit Session-Schlüssel nicht versehentlich mit anderen, im selben Redis genutzten Daten kollidieren, sowie eine sinnvolle Fehlerbehandlung, wenn Redis selbst nicht erreichbar ist, denn ein Session-Handler, der bei einem Redis-Ausfall eine ungefangene Exception wirft, reißt jeden einzelnen Request der gesamten Anwendung mit sich.
<?php
declare(strict_types=1);
use Predis\Client;
/**
* Framework-unabhängiger Session-Handler, der Session-Daten in Redis
* ablegt und paralleles Schreiben derselben Session per Lock serialisiert.
*/
final class RedisSessionHandler implements SessionHandlerInterface
{
private const string LOCK_PREFIX = 'sess_lock:';
private const string DATA_PREFIX = 'sess:';
private const int LOCK_TTL_SECONDS = 5;
private const int MAX_LOCK_WAIT_MICROSECONDS = 100_000;
public function __construct(
private readonly Client $redis,
private readonly int $sessionLifetimeSeconds = 1440,
) {
}
/**
* Wird von PHP einmalig zu Sessionbeginn aufgerufen. Die eigentliche
* Redis-Verbindung wird bereits per Konstruktor injiziert.
*
* @param string $path Konfigurierter Save-Path, hier ungenutzt
* @param string $name Konfigurierter Session-Name, hier ungenutzt
* @return bool Immer true, da keine zusätzliche Initialisierung nötig ist
*/
public function open(string $path, string $name): bool
{
return true;
}
/**
* Erwirbt ein Lock für die Session-ID und liest anschließend die
* gespeicherten, bereits von PHP serialisierten Session-Daten.
*
* @param string $id Die Session-ID
* @return string Der gespeicherte Datenstring oder ein leerer String
*/
public function read(string $id): string
{
$this->acquireLock($id);
return (string) $this->redis->get(self::DATA_PREFIX . $id);
}
/**
* Speichert die von PHP serialisierten Session-Daten mit einem
* erneuerten TTL und gibt danach das Lock wieder frei.
*
* @param string $id Die Session-ID
* @param string $data Der von PHP serialisierte Session-Inhalt
* @return bool True bei erfolgreichem Speichern
*/
public function write(string $id, string $data): bool
{
$this->redis->setex(self::DATA_PREFIX . $id, $this->sessionLifetimeSeconds, $data);
$this->releaseLock($id);
return true;
}
/**
* Entfernt die Session-Daten vollständig, z.B. beim Logout.
*
* @param string $id Die zu löschende Session-ID
* @return bool True bei erfolgreichem Löschen
*/
public function destroy(string $id): bool
{
$this->redis->del(self::DATA_PREFIX . $id);
$this->releaseLock($id);
return true;
}
/**
* Kein manuelles Aufräumen nötig, da jeder Schlüssel bereits ein
* eigenes Redis-TTL besitzt und automatisch verfällt.
*
* @param int $maxLifetime Konfigurierte Lebensdauer in Sekunden
* @return int Anzahl gelöschter Sessions, hier immer 0
*/
public function gc(int $maxLifetime): int
{
return 0;
}
/**
* Gibt ein eventuell noch offenes Lock frei und schließt die Session.
*
* @return bool Immer true
*/
public function close(): bool
{
return true;
}
/**
* Versucht wiederholt, ein exklusives Lock für die Session-ID zu
* erwerben, um parallele Schreibvorgänge zu serialisieren.
*
* @param string $id Die Session-ID
* @return void
*/
private function acquireLock(string $id): void
{
$lockKey = self::LOCK_PREFIX . $id;
while (!$this->redis->set($lockKey, '1', 'NX', 'EX', self::LOCK_TTL_SECONDS)) {
usleep(random_int(1_000, self::MAX_LOCK_WAIT_MICROSECONDS));
}
}
/**
* Gibt das Lock für die Session-ID wieder frei.
*
* @param string $id Die Session-ID
* @return void
*/
private function releaseLock(string $id): void
{
$this->redis->del(self::LOCK_PREFIX . $id);
}
}
7. Registrierung mit session_set_save_handler
Die Registrierung selbst geschieht mit session_set_save_handler, dem eine Instanz einer SessionHandlerInterface-Implementierung übergeben wird, gefolgt von register_shutdown_function mit session_write_close als Argument, damit die Session auch dann sauber geschrieben wird, wenn ein Objekt-Destruktor sie andernfalls zu spät oder in einem bereits inkonsistenten Zustand schließen würde.
Diese Registrierung muss vor dem eigentlichen session_start-Aufruf erfolgen, denn sobald PHP eine Session gestartet hat, greift bereits der zu diesem Zeitpunkt aktive Handler, und ein nachträglicher Wechsel würde stillschweigend ignoriert. In einem framework-freien Bootstrap-Skript gehören beide Aufrufe deshalb unmittelbar hintereinander an den Anfang, noch vor jeder Business-Logik, die $_SESSION liest oder schreibt.
<?php
declare(strict_types=1);
$redis = new Predis\Client(['host' => 'redis', 'port' => 6379]);
$handler = new RedisSessionHandler($redis, sessionLifetimeSeconds: 1440);
session_set_save_handler($handler, true);
register_shutdown_function('session_write_close');
session_start();
8. Vergleich mit SessionUpdateTimestampHandlerInterface
Neben SessionHandlerInterface existiert seit PHP 7.0 das optionale SessionUpdateTimestampHandlerInterface, das zwei zusätzliche Methoden definiert: validateId, die vor einem read prüft, ob eine vom Client übergebene Session-ID überhaupt gültig ist, und updateTimestamp, die bei einer clientseitig unveränderten Session lediglich den Zeitstempel aktualisiert, ohne den kompletten write-Vorgang zu durchlaufen.
Der praktische Nutzen liegt vor allem in updateTimestamp: Wird session.lazy_write genutzt, das seit PHP 7.0 standardmäßig aktiv ist, ruft PHP write nur dann auf, wenn sich der Inhalt tatsächlich geändert hat, und nutzt für den reinen Ablaufzeit-Refresh stattdessen updateTimestamp, was bei Redis deutlich günstiger als ein vollständiges write mit erneuter Serialisierung ist. Für den hier gezeigten Redis-Handler lohnt sich diese Erweiterung, weil setex bereits das TTL erneuert und updateTimestamp entsprechend nur denselben Befehl ohne den Datenteil ausführen müsste.
9. Fallstricke im produktiven Betrieb und sinnvolles Monitoring
Ein häufig übersehener Fallstrick ist ein zu kurzes Lock-TTL: Ist es kürzer als die tatsächliche Antwortzeit eines langsamen Requests, kann ein zweiter Request das vermeintlich abgelaufene Lock erwerben, während der erste Request noch schreibt, was die eigentlich angestrebte Serialisierung wieder aushebelt. Das Lock-TTL sollte daher deutlich über der erwarteten maximalen Requestdauer liegen, kombiniert mit einem sinnvollen Timeout in acquireLock, der nach mehreren Sekunden mit einer klaren Fehlermeldung statt einer Endlosschleife abbricht.
Für den produktiven Betrieb lohnt sich zusätzlich Monitoring auf die durchschnittliche Wartezeit in acquireLock, denn ein spürbarer Anstieg deutet meist auf zu viele parallele Requests derselben Session hin, etwa durch aggressives Polling im Frontend, das sich mit einer geeigneten Drosselung oder einer feingranularen Aufteilung der Session-Daten auf mehrere Redis-Schlüssel entschärfen lässt, statt die gesamte Session als einen einzigen, immer komplett neu geschriebenen Blob zu behandeln.
| Methode | Parameter | Rückgabewert | Redis-Umsetzung |
|---|---|---|---|
| open() | path, name | bool | No-Op, Verbindung besteht bereits |
| read() | id | string | Lock erwerben, dann GET auf sess:{id} |
| write() | id, data | bool | SETEX auf sess:{id}, danach Lock freigeben |
| destroy() | id | bool | DEL auf sess:{id} und das zugehörige Lock |
| gc() | maxLifetime | int | No-Op, da jeder Schlüssel ein eigenes TTL besitzt |
| close() | keine | bool | No-Op, Verbindung wird zentral verwaltet |
Mironsoft
PHP-Modernisierung, Code-Qualität und Legacy-Refactoring
Gewachsener PHP-Code, der niemand mehr gern anfasst?
Wir modernisieren PHP-Codebasen auf aktuelle Sprachstandards, führen statische Analyse und Coding Standards ein und refactorn Legacy-Code Schritt für Schritt, ohne den laufenden Betrieb zu gefährden.
Legacy-Refactoring
Gewachsenen PHP-Code strukturiert und risikoarm modernisieren.
Code-Qualität etablieren
PHPStan, Coding Standards und CI-Checks nachhaltig im Team verankern.
Versions-Upgrade
PHP-Major-Version-Upgrades sicher planen und ohne Ausfallzeit umsetzen.
10. Zusammenfassung
Eigene Session-Speicherung: Das Wichtigste auf einen Blick
Sechs Pflichtmethoden
open, close, read, write, destroy und gc bilden die vollständige Schnittstelle zwischen PHP und dem Speicherort.
write erhält Rohdaten
PHP serialisiert $_SESSION selbst, der Handler speichert und liefert nur den fertigen String.
Locking in read
Ein Redis-Lock mit NX und automatischer Ablaufzeit verhindert Lost-Update-Probleme bei parallelen Requests.
TTL statt gc
Bei Redis übernimmt ein pro Schlüssel gesetztes TTL die Aufgabe der klassischen Garbage Collection.