Read/Write Split für Datenbank-Replikation in PHP umsetzen
AI generated
<?php
8.4
PHP · Datenbank · Skalierung
Read/Write Split für Datenbank-Replikation
Lesezugriffe verteilen, ohne Konsistenz zu opfern

Read/Write Split verteilt Schreibzugriffe auf einen einzigen Primary-Server und Lesezugriffe auf mehrere Replicas, um die Datenbankschicht horizontal zu skalieren. In PHP bedeutet das konkret: eine Connection-Routing-Schicht, die je nach Query-Typ die passende Verbindung wählt, und ein bewusster Umgang mit Replikationsverzögerung, damit Nutzer nach einem Schreibzugriff nicht plötzlich veraltete Daten sehen.

17 Min. Lesezeit Primary/Replica · Replikationslag · Failover PHP 8.4 · PDO · MySQL/PostgreSQL

1. Warum Read/Write Split überhaupt nötig wird

Read/Write Split wird relevant, sobald eine Anwendung mehr Leseanfragen produziert, als ein einzelner Datenbankserver zuverlässig bedienen kann, während Schreibzugriffe im Vergleich selten bleiben. Typische Verhältnisse in Webanwendungen liegen bei 80 bis 95 Prozent Lesezugriffen gegenüber wenigen Prozent Schreibzugriffen. Ohne Read/Write Split muss der einzige Datenbankserver sowohl die Schreiblast als auch die deutlich größere Leselast tragen, was ihn zum Flaschenhals der gesamten Anwendung macht.

Die Lösung ist Datenbank-Replikation: Ein Primary-Server nimmt alle Schreibzugriffe entgegen und repliziert jede Änderung asynchron an eine oder mehrere Replica-Server. Read/Write Split auf Anwendungsebene sorgt dafür, dass Leseanfragen gezielt an diese Replicas gehen, während Schreibzugriffe ausschließlich den Primary erreichen. Das entlastet den Primary erheblich und erlaubt horizontale Skalierung der Leselast durch einfaches Hinzufügen weiterer Replicas.

Wichtig ist die Erwartungshaltung: Read/Write Split löst ein Skalierungsproblem, kein Konsistenzproblem. Replikation ist bei den meisten Setups asynchron, das heißt, eine Replica kann für einen kurzen Moment veraltete Daten liefern. Wer dieses Verhalten nicht bewusst berücksichtigt, riskiert verwirrende Bugs, bei denen ein Nutzer eine gerade gespeicherte Änderung nicht sofort sieht.

2. Architektur: Primary, Replicas und die Routing-Schicht

Die Grundarchitektur für Read/Write Split besteht aus drei Komponenten: dem Primary-Server für alle Schreibzugriffe, einem oder mehreren Replica-Servern für Leseanfragen, und einer Routing-Schicht innerhalb der PHP-Anwendung, die entscheidet, welche Verbindung für eine gegebene Abfrage genutzt wird. Diese Routing-Schicht sollte zentral an einer Stelle implementiert werden, idealerweise innerhalb der Datenbankabstraktion, damit Anwendungscode niemals direkt zwischen Primary und Replica unterscheiden muss.

Ein häufiger Architekturfehler ist, die Entscheidung für Read/Write Split in jedem einzelnen Repository oder Controller manuell zu treffen. Das führt zu Inkonsistenzen, sobald ein Entwickler vergisst, eine kritische Leseanfrage explizit an den Primary zu leiten. Robuster ist ein zentraler Connection Manager, der basierend auf dem Query-Typ automatisch die richtige Verbindung wählt, sodass die Entscheidung an einer einzigen Stelle im Code getroffen und getestet wird.

3. Einen Connection Manager für Primary und Replicas bauen

Der Connection Manager hält jeweils eine PDO-Instanz für den Primary und für jede konfigurierte Replica. Er stellt zwei Methoden bereit, etwa getWriteConnection() für den Primary und getReadConnection() für eine der Replicas, wobei letztere bei mehreren Replicas zusätzlich eine Lastverteilungsstrategie anwenden kann. Diese Kapselung ist der Kern eines funktionierenden Read/Write Splits in PHP, weil sie die eigentliche Verbindungsauswahl vollständig vor der Geschäftslogik verbirgt.

Der Connection Manager sollte zudem eine Möglichkeit bieten, für einen bestimmten Codeblock erzwungenermaßen den Primary auch für Lesezugriffe zu verwenden. Das wird notwendig, wenn eine Leseanfrage direkt nach einem Schreibzugriff erfolgen muss und die Replikationsverzögerung nicht toleriert werden kann.


<?php

declare(strict_types=1);

final class DatabaseConnectionManager
{
    private PDO $writeConnection;
    /** @var array<int, PDO> */
    private array $readConnections = [];
    private bool $forcePrimaryForReads = false;

    /**
     * @param array<int, array{host: string, port: int}> $replicaConfigs
     */
    public function __construct(
        array $primaryConfig,
        array $replicaConfigs,
        private readonly string $user,
        private readonly string $password,
    ) {
        $this->writeConnection = $this->connect($primaryConfig);
        foreach ($replicaConfigs as $config) {
            $this->readConnections[] = $this->connect($config);
        }
    }

    public function getWriteConnection(): PDO
    {
        return $this->writeConnection;
    }

    public function getReadConnection(): PDO
    {
        if ($this->forcePrimaryForReads || $this->readConnections === []) {
            return $this->writeConnection;
        }

        // Simple round robin across replicas
        $index = array_rand($this->readConnections);
        return $this->readConnections[$index];
    }

    /** Runs a callback with all reads forced onto the primary connection. */
    public function withPrimaryReads(Closure $callback): mixed
    {
        $previous = $this->forcePrimaryForReads;
        $this->forcePrimaryForReads = true;
        try {
            return $callback();
        } finally {
            $this->forcePrimaryForReads = $previous;
        }
    }

    private function connect(array $config): PDO
    {
        $dsn = "mysql:host={$config['host']};port={$config['port']};charset=utf8mb4";
        return new PDO($dsn, $this->user, $this->password, [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        ]);
    }
}

4. Query-Klassifikation: Lesend oder schreibend erkennen

Damit der Connection Manager automatisch die richtige Verbindung wählt, muss irgendeine Schicht entscheiden, ob eine gegebene Abfrage lesend oder schreibend ist. Bei Verwendung eines Query Builders oder Repository Patterns ist diese Klassifikation trivial, weil dort bereits explizite Methoden wie select(), insert() oder update() existieren, die direkt die passende Verbindung anfordern können, ohne den SQL-String selbst analysieren zu müssen.

Wird stattdessen rohes SQL verwendet, bleibt nur eine textuelle Prüfung des SQL-Präfixes, etwa ob die Abfrage mit SELECT beginnt. Diese Methode ist fehleranfällig bei komplexeren Konstrukten wie SELECT ... FOR UPDATE, das trotz SELECT-Präfix eine Schreibsperre auf dem Primary setzt und daher zwingend an den Primary geleitet werden muss. Aus diesem Grund ist eine explizite Methode wie query(string $sql, bool $isWrite) deutlich robuster als reine Text-Heuristik.


<?php

declare(strict_types=1);

final class ReplicationAwareRepository
{
    public function __construct(private readonly DatabaseConnectionManager $connections)
    {
    }

    public function findById(int $id): ?array
    {
        // Explicit read — routed to a replica
        $statement = $this->connections->getReadConnection()
            ->prepare('SELECT * FROM orders WHERE id = ?');
        $statement->execute([$id]);

        $row = $statement->fetch(PDO::FETCH_ASSOC);
        return $row === false ? null : $row;
    }

    public function lockForUpdate(int $id): ?array
    {
        // SELECT ... FOR UPDATE must always go to the primary
        $statement = $this->connections->getWriteConnection()
            ->prepare('SELECT * FROM orders WHERE id = ? FOR UPDATE');
        $statement->execute([$id]);

        $row = $statement->fetch(PDO::FETCH_ASSOC);
        return $row === false ? null : $row;
    }

    public function updateStatus(int $id, string $status): void
    {
        // Explicit write — routed to the primary
        $statement = $this->connections->getWriteConnection()
            ->prepare('UPDATE orders SET status = ? WHERE id = ?');
        $statement->execute([$status, $id]);
    }
}

5. Replikationsverzögerung verstehen und messen

Replikationsverzögerung, oft Replication Lag genannt, beschreibt die Zeitspanne zwischen einem Schreibzugriff auf dem Primary und dem Moment, in dem dieselbe Änderung auf einer Replica sichtbar wird. Diese Verzögerung liegt normalerweise im Millisekundenbereich, kann aber unter Last, bei Netzwerkproblemen oder bei besonders großen Transaktionen auf Sekunden oder sogar Minuten anwachsen. Für Read/Write Split ist das Verstehen dieser Verzögerung essenziell, weil sie direkt bestimmt, wie aktuell Daten sind, die von einer Replica gelesen werden.

In MySQL lässt sich der Replikationsstatus über SHOW REPLICA STATUS abfragen, das unter anderem den Wert Seconds_Behind_Source liefert. Eine PHP-Anwendung kann diesen Wert periodisch überwachen und eine Replica, deren Verzögerung einen Schwellwert überschreitet, temporär aus der Lastverteilung ausschließen. Diese aktive Überwachung verhindert, dass Nutzer auf eine bereits deutlich veraltete Replica geleitet werden.

6. Read-your-writes: Konsistenz nach eigenen Schreibzugriffen

Das häufigste sichtbare Problem bei Read/Write Split ist das sogenannte Read-your-writes-Problem: Ein Nutzer speichert eine Änderung, die auf dem Primary landet, wird direkt danach auf eine Seite weitergeleitet, die dieselben Daten von einer Replica liest, und sieht seine eigene Änderung nicht, weil die Replikation noch nicht nachgezogen hat. Aus Nutzersicht wirkt das wie ein Bug, selbst wenn das System technisch korrekt funktioniert.

Die robusteste Lösung ist, für eine kurze Zeitspanne nach einem Schreibzugriff gezielt Leseanfragen desselben Nutzers auf den Primary umzuleiten, etwa über die bereits gezeigte withPrimaryReads()-Methode. Alternativ lässt sich in einer Session ein Flag setzen, das für die Dauer des Requests oder für eine kurze Zeit alle Lesezugriffe erzwingt. Für Read/Write Split in zahlungsrelevanten oder sicherheitskritischen Abläufen ist dieser bewusste Umgang mit Konsistenz nicht optional, sondern Grundvoraussetzung.

7. Lastverteilung über mehrere Replicas hinweg

Sobald mehr als eine Replica im Einsatz ist, muss der Connection Manager eine Strategie zur Lastverteilung implementieren. Ein einfaches Round-Robin-Verfahren, das nacheinander jede verfügbare Replica auswählt, reicht für viele Anwendungen aus. Fortgeschrittenere Strategien berücksichtigen die aktuelle Last jeder Replica oder deren gemessene Replikationsverzögerung, um Anfragen bevorzugt an weniger belastete oder aktuellere Replicas zu leiten.

Für Read/Write Split in produktiven Umgebungen empfiehlt sich zusätzlich ein Health Check, der regelmäßig prüft, ob eine Replica überhaupt erreichbar ist, bevor sie in die Rotation aufgenommen wird. Eine nicht erreichbare Replica darf niemals Teil der zufälligen oder round-robin-basierten Auswahl sein, sonst führt jeder fehlgeschlagene Verbindungsversuch zu einem sichtbaren Fehler für den Nutzer.

8. Failover: Ausfall einer Replica oder des Primary abfangen

Fällt eine einzelne Replica aus, sollte der Connection Manager automatisch auf eine andere verfügbare Replica oder im Notfall auf den Primary zurückfallen, statt eine Exception nach außen durchzureichen. Dieses Verhalten lässt sich mit einem einfachen Retry-Mechanismus umsetzen, der beim Verbindungsfehler die nächste Replica in der Liste versucht, bevor er tatsächlich aufgibt.

Der Ausfall des Primary ist deutlich kritischer, weil dann keine Schreibzugriffe mehr möglich sind. In produktiven Read/Write Split-Setups übernimmt ein externer Orchestrator wie Orchestrator für MySQL oder Patroni für PostgreSQL die automatische Beförderung einer Replica zum neuen Primary. Die PHP-Anwendung sollte in diesem Fall so konfiguriert sein, dass sie den neuen Primary über einen stabilen DNS-Namen oder Proxy erreicht, statt fest verdrahtete IP-Adressen im Code zu pflegen.

9. Read/Write Split im Vergleich zu anderen Skalierungsansätzen

Read/Write Split ist eine von mehreren Strategien zur Datenbank-Skalierung und lässt sich gut mit anderen Ansätzen wie Caching oder Sharding kombinieren. Die Entscheidung, wann welcher Ansatz sinnvoll ist, hängt vom konkreten Lastprofil der Anwendung ab.

Ansatz Löst Komplexität Grenzen
Read/Write Split Hohe Leselast Mittel Schreiblast bleibt auf einem Server
Caching (Redis) Wiederholte identische Abfragen Gering bis mittel Cache-Invalidierung ist schwierig
Sharding Hohe Schreiblast, große Datenmengen Hoch Cross-Shard-Queries komplex
Vertikale Skalierung Kurzfristiger Engpass Gering Physikalische Obergrenze pro Server

In der Praxis kombinieren viele Anwendungen Read/Write Split mit einer Caching-Schicht: Häufig gelesene, selten geänderte Daten kommen aus Redis, seltener gelesene oder personalisierte Daten aus den Replicas, und ausschließlich Schreibzugriffe erreichen den Primary. Sharding wird meist erst relevant, wenn selbst die Schreiblast auf dem Primary zum Engpass wird, was deutlich seltener der Fall ist als reine Leselastprobleme.

10. Zusammenfassung

Read/Write Split verteilt Schreibzugriffe konsequent auf einen Primary-Server und Lesezugriffe auf Replicas, um die Datenbankschicht horizontal für Leselast zu skalieren. Der Kern der Implementierung in PHP ist ein zentraler Connection Manager, der Query-Typen erkennt, Verbindungen entsprechend routet und dabei Replikationsverzögerung sowie das Read-your-writes-Problem bewusst behandelt.

Lastverteilung über mehrere Replicas, Health Checks und ein durchdachtes Failover-Konzept runden ein produktionstaugliches Read/Write Split ab. Wichtig bleibt die klare Abgrenzung zu anderen Skalierungsansätzen: Read/Write Split löst Leselastprobleme, während Sharding für Schreiblastprobleme und Caching für wiederholte, identische Abfragen die passenderen Werkzeuge sind.

Read/Write Split für Datenbank-Replikation — Das Wichtigste auf einen Blick

Connection Manager

Zentrale Stelle, die Verbindungen zu Primary und Replicas verwaltet und routet.

Replikationsverzögerung

Aktiv überwachen und Replicas mit zu hoher Verzögerung aus der Rotation nehmen.

Read-your-writes

Nach Schreibzugriffen gezielt den Primary für Lesezugriffe erzwingen.

Failover

Health Checks und automatischer Fallback bei Ausfall einer Replica.

11. FAQ: Read/Write Split in PHP

1Was bedeutet Read/Write Split?
Schreibzugriffe zum Primary, Lesezugriffe verteilt auf Replicas, um die Leselast horizontal zu skalieren.
2Ist das dasselbe wie Sharding?
Nein, Read/Write Split verteilt nur Leselast über vollständige Kopien, Sharding teilt die Daten selbst auf.
3Wie erkennt man lesend vs. schreibend?
Am besten über explizite Methoden im Query Builder, nicht über fehleranfällige Textanalyse.
4Was ist Replikationsverzögerung?
Die Verzögerung zwischen Schreiben auf dem Primary und Sichtbarkeit auf einer Replica.
5Was ist das Read-your-writes-Problem?
Eigene Änderungen scheinen zu fehlen, weil eine Replica sie noch nicht repliziert hat.
6Wie löst man es?
Leseanfragen kurz nach Schreibzugriffen gezielt auf den Primary umleiten.
7Wie verteilt man Last über Replicas?
Round-Robin reicht oft, fortgeschrittene Strategien berücksichtigen Last und Lag.
8Was passiert bei Ausfall einer Replica?
Automatischer Fallback auf eine andere Replica oder den Primary.
9Was passiert bei Ausfall des Primary?
Schreiben ist blockiert, ein Orchestrator befördert automatisch eine Replica zum neuen Primary.
10Wann lohnt es sich gegenüber Caching?
Bei individuellen, nicht cachebaren Abfragen. Für identische, wiederholte Abfragen ist Caching effizienter.