Alle Beiträge

Laravel Read Replicas: warum Nutzer nach dem Speichern alte Daten sehen

Laravel · 4 Min. Lesezeit ·

Wenn deine Laravel-App Schreibzugriffe an einen Datenbankserver und Lesezugriffe an Replicas schickt, kann dir ein Bug begegnen, der lokal nie auftaucht: Jemand ändert den Titel eines Beitrags, speichert, landet wieder auf der Seite und sieht den alten Titel. Nichts ist fehlgeschlagen. Der Lesezugriff ging an eine Replica, die die Änderung noch nicht kopiert hatte. Laravel 13.38, erschienen im Oktober 2026, bringt genau dafür zwei Werkzeuge.

Warum sticky dich nicht rettet

Eine Read/Write-Verbindung in config/database.php hat die Option sticky. Ist sie an, gehen nach einem Schreibzugriff die folgenden Lesezugriffe an den Schreibserver. Der Haken ist die Reichweite: Das gilt nur bis zum Ende dieses Requests. Ein normales Formular ist ein PUT, das auf ein GET umleitet, also zwei Requests. Der Redirect beginnt mit einer frischen Verbindung, und die Seite nach dem Speichern liest wieder von einer Replica.

PUT: speichern GET: anzeigen Redirect Schreibserver Replica (hinterher) kopiert später der alte Titel ist zurück
Zwei Requests, zwei Verbindungen. Sticky hat nur den ersten abgedeckt.

Die Lösung für Nutzer: PreventStaleDatabaseReads

Die neue Middleware merkt sich den Schreibzugriff über Requests hinweg, indem sie einen Zeitstempel in die Session legt. Nachdem ein Request auf die Standardverbindung geschrieben hat, gehen die Lesezugriffe dieses Nutzers zwei Sekunden lang an den Schreibserver, danach wieder an die Replicas. Häng sie in bootstrap/app.php an die web-Gruppe:

use Illuminate\Foundation\Http\Middleware\PreventStaleDatabaseReads;

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        PreventStaleDatabaseReads::class,
    ]);
})

Oder probier sie zuerst auf ein paar Routen aus, mit längerem Fenster, falls deine Replicas das brauchen:

Route::middleware(PreventStaleDatabaseReads::for(15))->group(function () {
    // routes where a user edits and then views
});

Leg das Fenster nach deinen eigenen Lag-Metriken fest, nicht nach Gefühl. Ein Fenster, das kürzer ist als der echte Lag, lässt die alten Daten durch. Drei Grenzen solltest du kennen: Die Middleware braucht eine Session, sie verfolgt nur die Standardverbindung, und sie schützt nur den Nutzer, der geschrieben hat. Wer sonst dieselbe Seite lädt, kann weiter die ältere Kopie bekommen.

Schreiben Lesen: Schreibserver Lesen: Replicas Fenster: standardmäßig 2 s nach dem Fenster die Session eines Nutzers
Das Fenster hängt an der Session und übersteht damit den Redirect, an dem sticky scheitert.

Die Lösung im Code: DB::usingWritePdo()

Mancher Code braucht frische Daten, egal wer geschrieben hat, etwa ein Job, der direkt nach einem Webhook-Update einer Zeile läuft. onWriteConnection() gibt es schon, es gilt aber nur für die Query, die es startet. Lädst du aus dem Ergebnis eine Relation, geht diese Query an eine Replica. Die neue Methode schickt jeden Lesezugriff im Callback an den Schreibserver und stellt danach die vorherige Einstellung wieder her, auch wenn der Callback eine Exception wirft:

$drafts = DB::usingWritePdo(function () use ($email) {
    $user = User::query()->where('email', $email)->first();

    return $user?->posts()->whereNull('published_at')->get();
});

Code, der ohnehin in DB::transaction() läuft, braucht das nicht: Queries in einer Transaktion nutzen sowieso den Schreibserver.

Queue-Worker haben das umgekehrte Problem

Im Web-Request endet sticky mit der Antwort. Ein queue:work-Prozess behält dieselbe Verbindung über alle Jobs, also kann ein einziger schreibender Job die Lesezugriffe aller folgenden Jobs bis zum Neustart des Workers auf den Schreibserver lenken. Laravel 13.36 hat dafür einen optionalen Reset eingeführt: Worker::$flushStickyConnections = true in der boot()-Methode eines Service Providers. Er ist standardmäßig aus, prüf also vor dem Einschalten die Jobs, die direkt nach dem Schreiben eines anderen Jobs lesen. Mit dem database-Queue-Treiber gib der Jobs-Tabelle außerdem eine eigene Verbindung über DB_QUEUE_CONNECTION, sonst setzt das Reservieren eines Jobs das Flag gleich wieder.

Testen, ohne dich selbst zu täuschen

Jeder Eintrag im Query-Log hat den Schlüssel readWriteType mit read oder write. Ein Test kann also speichern, die Seite abrufen und prüfen, dass die erste Query ein write war. Nimm für diesen Test DatabaseMigrations oder DatabaseTruncation. RefreshDatabase packt jeden Test in eine Transaktion, jede Query in einer Transaktion nutzt den Schreibserver, und die Prüfung wäre auch ohne Middleware grün.

Sticky schützt einen Request. Schreiben, Redirect und Lesen sind zwei, deshalb brauchen veraltete Daten nach dem Speichern die Session-basierte Middleware, und Code, der frisch lesen muss, braucht usingWritePdo().

Nächster Schritt: Wenn du Replicas betreibst, such deinen schlimmsten Replikations-Lag heraus, setz PreventStaleDatabaseReads auf die Routen, auf denen Nutzer bearbeiten und dann ansehen, stell das Fenster über diesen Lag und schreib den readWriteType-Test ohne RefreshDatabase. Beide Features stammen von Jack Bayliss, in #62078 und #62099. Quelle: Laravel News, Laravel Read Replicas: Prevent Stale Reads After a Write.

Notizen der Leser

  1. Noch keine Notizen. Die erste ist immer die mutigste.
Baust du etwas Ähnliches? Ich kann helfen. Schreib mir auf WhatsApp