Vom Request-Response-Modell zum langlebigen Prozess
PHP wurde für ein Modell entworfen, in dem jede Anfrage einen neuen Prozess oder zumindest einen frischen Ausführungskontext startet, das passt nicht zu WebSockets, die eine dauerhaft offene Verbindung erwarten. Ratchet löst dieses Problem über eine Event-Loop auf Basis von ReactPHP. Wir bauen einen Echtzeit-Benachrichtigungsserver und zeigen konkret, wo die Skalierungsgrenzen eines PHP-WebSocket-Servers in Produktion liegen.
Inhaltsverzeichnis
- 1. Warum klassisches PHP für WebSockets ungeeignet ist
- 2. Ratchets Event-Loop-Modell
- 3. MessageComponentInterface im Detail
- 4. Einen minimalen Server aufbauen
- 5. Praxisbeispiel: Echtzeit-Benachrichtigungen
- 6. Broadcasting und gezielte Zustellung
- 7. Integration mit einer bestehenden PHP-Anwendung
- 8. Skalierungsgrenzen von PHP-WebSocket-Servern in der Produktion
- 9. Deployment und Betrieb in der Praxis
- 10. Zusammenfassung
- 11. FAQ
1. Warum klassisches PHP für WebSockets ungeeignet ist
Ein klassisches PHP-Setup unter PHP-FPM oder Apache mit mod_php beendet den Ausführungskontext eines Skripts, sobald die Response an den Client gesendet wurde. Für HTTP-Anfragen ist das genau richtig, jede Anfrage ist unabhängig, der Speicher wird danach vollständig freigegeben, und der nächste Request startet wieder bei null. Genau dieses Modell verhindert aber eine WebSocket-Verbindung, die über Minuten oder Stunden offen bleiben muss, damit der Server jederzeit von sich aus Daten an den Client senden kann.
WebSockets brauchen einen Prozess, der dauerhaft läuft, mehrere gleichzeitige Verbindungen im Speicher hält und aktiv auf Ereignisse reagiert, statt nach einer einzelnen Antwort zu terminieren. PHP-FPM ist für dieses Muster architektonisch nicht vorgesehen, weshalb ein WebSocket-Server in PHP als eigenständiger, langlebiger Prozess außerhalb des klassischen Webserver-Request-Zyklus laufen muss, typischerweise über die PHP-CLI gestartet.
2. Ratchets Event-Loop-Modell
Ratchet baut auf ReactPHP auf und nutzt dessen Event-Loop, um mit einem einzigen PHP-Prozess trotzdem tausende gleichzeitige Verbindungen zu verwalten. Statt für jede Verbindung einen eigenen Thread oder Prozess zu starten, registriert die Event-Loop nicht-blockierende Socket-Operationen und ruft registrierte Callbacks auf, sobald tatsächlich Daten verfügbar sind, ähnlich dem Modell von Node.js.
Diese Architektur bedeutet konkret, dass der eigene Code niemals aktiv auf eine Verbindung wartet, sondern stattdessen auf Methodenaufrufe reagiert, die Ratchet zum passenden Zeitpunkt selbst auslöst. Blockierende Operationen wie eine synchrone Datenbankabfrage mit langer Laufzeit sind in diesem Modell besonders gefährlich, weil sie die gesamte Event-Loop und damit alle anderen offenen Verbindungen für die Dauer der Blockade einfrieren.
3. MessageComponentInterface im Detail
Der Einstiegspunkt für eigene WebSocket-Logik in Ratchet ist MessageComponentInterface mit vier Methoden: onOpen() wird bei einer neuen Verbindung aufgerufen, onMessage() bei einer eingehenden Nachricht, onClose() beim Verbindungsabbau und onError() bei einem Fehler auf der Verbindung. Jede Methode erhält ein ConnectionInterface-Objekt, das die konkrete Verbindung repräsentiert und über send() das Senden von Daten erlaubt.
Diese vier Methoden bilden den vollständigen Lebenszyklus einer WebSocket-Verbindung ab. Anders als bei einer HTTP-Anfrage, die einen einzigen Request-Response-Zyklus durchläuft, kann eine WebSocket-Verbindung über ihre gesamte Lebensdauer beliebig viele onMessage()-Aufrufe in beide Richtungen erzeugen, weshalb der eigene Zustand pro Verbindung, etwa ein zugeordneter Benutzer, in einer eigenen Datenstruktur gehalten werden muss.
<?php
declare(strict_types=1);
namespace Ratchet;
interface MessageComponentInterface
{
public function onOpen(ConnectionInterface $conn): void;
public function onMessage(ConnectionInterface $from, $msg): void;
public function onClose(ConnectionInterface $conn): void;
public function onError(ConnectionInterface $conn, \Exception $e): void;
}
4. Einen minimalen Server aufbauen
Ein minimaler Ratchet-Server besteht aus einer eigenen Klasse, die MessageComponentInterface implementiert, verpackt in einen WsServer für das WebSocket-Protokoll und einen HttpServer für das initiale HTTP-Upgrade, das jede WebSocket-Verbindung technisch einleitet. Diese Schichten werden über IoServer::factory() zusammengeführt und an einen konkreten Port gebunden.
Der Server-Prozess läuft anschließend dauerhaft über run(), bis er explizit beendet wird. Anders als ein klassisches PHP-Skript endet dieser Prozess also nicht nach einer einzelnen Aktion, sondern verarbeitet in einer Endlosschleife der Event-Loop kontinuierlich neue Verbindungen und Nachrichten, weshalb Supervisor oder ein vergleichbares Prozessüberwachungstool für den produktiven Betrieb zwingend notwendig ist.
<?php
declare(strict_types=1);
use Ratchet\Http\HttpServer;
use Ratchet\Server\IoServer;
use Ratchet\WebSocket\WsServer;
require __DIR__ . '/vendor/autoload.php';
$server = IoServer::factory(
new HttpServer(
new WsServer(new NotificationServer()),
),
8080,
);
$server->run();
5. Praxisbeispiel: Echtzeit-Benachrichtigungen
Eine Benachrichtigungslogik verwaltet eine SplObjectStorage, in der jede offene ConnectionInterface-Instanz als Schlüssel gespeichert wird. In onOpen() wird die neue Verbindung dieser Struktur hinzugefügt, in onClose() wieder entfernt, wodurch der Server jederzeit genau weiß, welche Clients aktuell verbunden sind, ohne eine externe Datenbank für diesen Zustand zu benötigen.
Eine eingehende Nachricht in onMessage() wird typischerweise als JSON dekodiert, um den Nachrichtentyp und die Zielgruppe zu bestimmen, etwa eine Benachrichtigung für einen einzelnen Benutzer oder einen Broadcast an alle Verbindungen. Wichtig ist, jede eingehende Nachricht defensiv zu validieren, da ein WebSocket-Endpunkt aus Sicherheitssicht wie jeder andere öffentlich erreichbare Endpunkt ungefiltert Eingaben von außen erhält.
<?php
declare(strict_types=1);
namespace App\WebSocket;
use Ratchet\ConnectionInterface;
use Ratchet\MessageComponentInterface;
final class NotificationServer implements MessageComponentInterface
{
private \SplObjectStorage $connections;
public function __construct()
{
$this->connections = new \SplObjectStorage();
}
public function onOpen(ConnectionInterface $conn): void
{
$this->connections->attach($conn);
}
public function onMessage(ConnectionInterface $from, $msg): void
{
$payload = json_decode((string) $msg, true);
if (!is_array($payload) || !isset($payload['type'])) {
$from->send(json_encode(['error' => 'invalid_payload']));
return;
}
$this->broadcast($payload, $from);
}
public function onClose(ConnectionInterface $conn): void
{
$this->connections->detach($conn);
}
public function onError(ConnectionInterface $conn, \Exception $e): void
{
$conn->close();
}
private function broadcast(array $payload, ConnectionInterface $sender): void
{
foreach ($this->connections as $connection) {
if ($connection !== $sender) {
$connection->send(json_encode($payload));
}
}
}
}
6. Broadcasting und gezielte Zustellung
Das Beispiel oben zeigt einen einfachen Broadcast an alle Verbindungen außer dem Absender, für viele Anwendungen genügt aber gezielte Zustellung an einzelne Nutzer. Dafür ergänzt man die SplObjectStorage um zusätzliche Metadaten je Verbindung, etwa eine Benutzer-ID, die beim Verbindungsaufbau über einen Authentifizierungs-Token aus der HTTP-Upgrade-Anfrage ermittelt wird.
Statt über alle Verbindungen zu iterieren, filtert man dann gezielt nach der passenden Benutzer-ID, bevor eine Nachricht gesendet wird. Bei sehr vielen gleichzeitigen Verbindungen lohnt sich zusätzlich eine Map von Benutzer-ID zu Verbindung, um die Zustellung von linearer Suche auf einen direkten Zugriff zu beschleunigen, was bei tausenden Verbindungen einen spürbaren Unterschied macht.
7. Integration mit einer bestehenden PHP-Anwendung
Der eigentliche Auslöser einer Benachrichtigung, etwa eine neue Bestellung, entsteht meist in der klassischen PHP-FPM-Anwendung, nicht im WebSocket-Prozess selbst. Da beide Prozesse getrennt laufen und keinen gemeinsamen Speicher teilen, braucht es eine Bridge, üblicherweise über Redis Pub/Sub: Die FPM-Anwendung veröffentlicht ein Ereignis auf einem Redis-Kanal, der WebSocket-Server abonniert diesen Kanal über die ReactPHP-Redis-Integration und leitet eingehende Nachrichten an die passenden Verbindungen weiter.
Diese Trennung ist kein Nachteil, sondern architektonisch sinnvoll: Die FPM-Anwendung bleibt für das klassische Request-Response-Geschäft zuständig, der WebSocket-Prozess kümmert sich ausschließlich um offene Verbindungen und Echtzeit-Zustellung, beide kommunizieren ausschließlich über den entkoppelten Redis-Kanal miteinander.
8. Skalierungsgrenzen von PHP-WebSocket-Servern in der Produktion
Ein einzelner Ratchet-Prozess läuft single-threaded innerhalb der ReactPHP-Event-Loop, echte parallele Verarbeitung auf mehreren CPU-Kernen ist damit ohne zusätzliche Werkzeuge nicht möglich. Für mehr Kapazität startet man mehrere Worker-Prozesse auf unterschiedlichen Ports, was allerdings sofort das Problem aufwirft, dass Verbindungen zufällig auf verschiedene Worker verteilt werden und ein Broadcast dann nicht mehr alle Clients erreicht, ohne die Redis-Pub/Sub-Bridge auch zwischen den Workern selbst zu nutzen.
Für Lastverteilung über mehrere Worker hinweg braucht ein WebSocket-Setup zudem sticky Sessions auf Ebene des Load Balancers, da eine bestehende Verbindung während ihrer gesamten Lebensdauer beim selben Worker bleiben muss. Bei sehr hohen Verbindungszahlen, etwa mehreren zehntausend gleichzeitigen Clients, stoßen PHP-basierte Lösungen zunehmend an praktische Grenzen, und Alternativen wie ein dedizierter Node.js-Prozess, ein gehosteter Dienst wie Pusher oder eine spezialisierte Lösung wie Mercure werden oft die wartungsärmere Wahl.
9. Deployment und Betrieb in der Praxis
Weil der WebSocket-Server als langlebiger CLI-Prozess läuft, braucht er im Gegensatz zu PHP-FPM eine explizite Prozessüberwachung. Supervisor eignet sich dafür gut, da es einen abgestürzten Prozess automatisch neu startet und Logs zentral sammelt, ohne dass man ein eigenes Watchdog-Skript schreiben muss.
Für Zero-Downtime-Deployments ist ein einfacher Prozess-Restart problematisch, weil dabei alle aktuell offenen Verbindungen hart getrennt werden. In der Praxis bewährt sich ein Rolling-Restart mit mehreren Worker-Prozessen, bei dem jeweils nur ein Teil der Worker gleichzeitig neu gestartet wird, während der Load Balancer neue Verbindungen währenddessen ausschließlich an die bereits aktualisierten Worker weiterleitet.
| Ansatz | Parallelität | Betriebsaufwand | Eignung |
|---|---|---|---|
| Einzelner Ratchet-Prozess | Single-threaded, Event-Loop | Gering | Prototypen, kleine Nutzerzahlen |
| Mehrere Ratchet-Worker plus Redis Pub/Sub | Mehrere Prozesse, koordiniert über Redis | Mittel | Mittelgroße Anwendungen mit klarer Kapazitätsplanung |
| Dedizierter Node.js-WebSocket-Dienst | Ereignisbasiert, ausgereiftes Ökosystem | Mittel bis hoch, zusätzlicher Stack | Wenn PHP-Grenzen erreicht sind |
| Gehosteter Dienst wie Pusher | Vom Anbieter verwaltet | Gering, aber laufende Kosten | Wenn Eigenbetrieb vermieden werden soll |
| Mercure | SSE-basiert, HTTP/2-nativ | Gering bis mittel | Server-zu-Client-Push ohne volle WebSocket-Komplexität |
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
WebSocket-Server mit Ratchet: Das Wichtigste auf einen Blick
Anderes Modell
WebSockets brauchen einen langlebigen Prozess statt des klassischen PHP-Request-Response-Zyklus.
Event-Loop
Ratchet nutzt ReactPHP, um tausende Verbindungen mit einem einzigen Prozess nicht-blockierend zu verwalten.
Vier Methoden
MessageComponentInterface bildet den gesamten Verbindungslebenszyklus über onOpen, onMessage, onClose und onError ab.
Grenzen kennen
Ab hoher Verbindungszahl werden mehrere Worker, Redis-Bridging und sticky Sessions notwendig, teils lohnt sich der Wechsel zu Node.js.