Alle Artikel
12. September 2026
9 mins

Dual-Write und Backfill: wo Umstellungen wirklich scheitern

Von Tim Rutte, Cloud & Software ArchitectThemaLegacy & Modernisierung

Ein Krug gießt in zwei Gläser zugleich; das rechte läuft mit blauer Flüssigkeit über.

Irgendwann in jeder Modernisierung müssen Daten umziehen. Eine Spalte bekommt einen neuen Typ, eine Tabelle wird geteilt, ein Dienst bekommt einen eigenen Speicher. Und fast immer gilt dabei die Bedingung, die den Unterschied zwischen einem Abend und einem Quartal ausmacht: Der Betrieb läuft weiter.

Das Verfahren dafür ist bekannt und heißt Expand and Contract: erst beides nebeneinander, dann umschalten, dann das Alte entfernen. Die Beschreibung ist in drei Sätzen erzählt, und trotzdem scheitern Umstellungen regelmäßig. Sie scheitern nicht am Verfahren, sondern an vier Stellen, über die die Kurzfassung hinweggeht.

Dieser Artikel geht die fünf Phasen durch, zeigt den Code für die heikelste davon und benennt die vier Stellen, an denen es in der Praxis reißt.

Die fünf Phasen

Zur Einordnung zuerst der ganze Ablauf. Jede Phase ist eine eigene Auslieferung, und zwischen zwei Phasen liegt immer ein Zustand, der dauerhaft laufen könnte.

  1. Erweitern. Das neue Ziel entsteht, leer. Nichts liest es, nichts schreibt es. Rückweg: die neue Struktur wieder entfernen.
  2. Doppelt schreiben. Jede Änderung geht in beide Ziele, gelesen wird weiter aus dem alten. Ab hier ist alles Neue an beiden Orten.
  3. Nachziehen. Der Bestand wird übertragen, in Portionen, außerhalb der Auslieferung.
  4. Umschalten. Gelesen wird aus dem neuen Ziel, geschrieben weiter in beide.
  5. Zusammenziehen. Das Schreiben ins alte Ziel entfällt, danach wird es entfernt.

Der wichtigste Satz zu dieser Liste steht nicht darin: Zwischen Phase 4 und 5 liegt eine Frist, und sie ist länger, als alle wollen. Sie muss den längsten Berichtszeitraum im Haus überdauern. Wer einmal im Quartal eine Auswertung fährt, merkt einen Fehler erst im Quartal darauf.

Phase 2: doppelt schreiben, ohne doppelte Fehler

Die naheliegende Umsetzung ist auch die falsche:

// Schlecht: was passiert, wenn die zweite Zeile fehlschlaegt?
$this->alterSpeicher->speichere($bestellung);
$this->neuerSpeicher->speichere($bestellung);

Schlägt die zweite Zeile fehl, ist die Bestellung im alten Ziel und nicht im neuen. Der Abgleich in Phase 3 findet das später, aber bis dahin ist die Zahl der Abweichungen unbekannt, und schlimmer: Niemand weiß, ob sie steigt.

Die Frage, die entscheidet, lautet: Darf ein Fehler im neuen Ziel den Vorgang scheitern lassen? Und die Antwort ist in Phase 2 immer nein. Das neue Ziel ist noch nicht die Wahrheit; ein Fehler dort darf keine Bestellung verhindern.

public function speichere(Bestellung $bestellung): void
{
    // Das alte Ziel ist die Wahrheit. Ein Fehler hier bricht den Vorgang ab.
    $this->alterSpeicher->speichere($bestellung);

    try {
        $this->neuerSpeicher->speichere($bestellung);
    } catch (Throwable $e) {
        // Bewusst nur protokolliert, nicht geworfen. In Phase 2 ist das
        // neue Ziel eine Kopie, kein Vertrag. Der Zaehler unten ist das
        // Abbruchkriterium: steigt er, wird die Umstellung angehalten.
        $this->zaehler->erhoehe('dualwrite.neu.fehler');
        $this->protokoll->warnung('Zweitschrift fehlgeschlagen', [
            'bestellung' => $bestellung->id(),
            'fehler'     => $e->getMessage(),
        ]);
    }
}

Zwei Dinge daran sind nicht Beiwerk.

Der Zähler ist das Abbruchkriterium. Ohne ihn ist „wir schreiben doppelt" eine Behauptung. Mit ihm ist es eine Messung, und eine steigende Fehlerzahl hält die Umstellung an, bevor Phase 4 ansteht.

Die Kennung des Vorgangs steht im Protokoll. Damit ist jede Abweichung hinterher einzeln nachvollziehbar. Ein Protokolleintrag ohne Kennung erzeugt nur das Gefühl, informiert zu sein.

Und die Transaktionsgrenze: Wo beide Ziele in derselben Datenbank liegen, gehört die Zweitschrift nicht in dieselbe Transaktion. Sonst rollt ein Fehler im neuen Ziel den ganzen Vorgang zurück, und genau das sollte die Ausnahmebehandlung ja verhindern.

Warum die alte Struktur überhaupt so aussieht, wie sie aussieht, steht in Das eigentliche Legacy ist Ihr Datenbankschema.

Phase 3: der Backfill

Der Bestand wird in Portionen übertragen. Drei Eigenschaften braucht dieser Lauf, und alle drei fehlen in der ersten Fassung, die jemand schreibt.

Er ist unterbrechbar und fortsetzbar. Ein Lauf über acht Stunden wird unterbrochen, und zwar durch ein Deployment, einen Neustart oder jemanden, der die Verbindung kappt. Der Fortschritt steht deshalb nicht im Arbeitsspeicher, sondern in der Datenbank.

Er ist wiederholbar. Derselbe Datensatz zweimal übertragen darf nichts kaputt machen. Praktisch heißt das: einfügen oder aktualisieren, nie blind einfügen.

Er drosselt sich selbst. Ein Lauf, der die Datenbank auslastet, macht die Anwendung langsam, und dann wird er abgeschaltet und nie wieder eingeschaltet.

public function uebertrageStapel(int $groesse = 500): int
{
    $ab = (int) $this->fortschritt->hole('backfill.bestellung', 0);

    // Nach ID, nicht mit OFFSET: bei OFFSET wandert das Fenster, sobald
    // parallel geschrieben wird, und einzelne Saetze werden uebersprungen.
    $saetze = $this->db->fetchAll(
        'SELECT * FROM bestellung WHERE id > ? ORDER BY id LIMIT ?',
        [$ab, $groesse]
    );
    if ($saetze === []) {
        return 0;
    }

    foreach ($saetze as $satz) {
        // Einfuegen oder aktualisieren: in Phase 2 schreibt die Anwendung
        // parallel schon in das neue Ziel. Ein blindes Einfuegen wuerde
        // an genau diesen Saetzen scheitern.
        $this->neuerSpeicher->speichereRoh($satz);
        $ab = max($ab, (int) $satz['id']);
    }

    $this->fortschritt->setze('backfill.bestellung', $ab);

    // Drosseln. Der Backfill hat keine Eile, die Anwendung schon.
    usleep(100_000);

    return count($saetze);
}

Zur Portionsgröße: 500 ist ein Anfangswert, kein Gesetz. Die richtige Größe ist die, bei der die Antwortzeit der Anwendung unverändert bleibt, und sie wird gemessen, nicht geraten.

Der Abgleich: nicht „fertig", sondern „gleich"

Ein durchgelaufener Backfill heißt nicht, dass beide Ziele dasselbe enthalten. Er heißt, dass ein Programm einmal über alle Datensätze gelaufen ist. Der Unterschied ist der Grund für die Phase, die am häufigsten weggelassen wird.

Der Abgleich läuft in drei Stufen, von billig nach teuer.

Anzahl. Zwei Abfragen, sofort. Findet fehlende Datensätze, nicht falsche Werte.

Prüfsumme je Bereich. Über Bereiche von Kennungen eine Summe bilden und vergleichen. Findet falsche Werte, ohne alle Datensätze zu vergleichen, und zeigt, in welchem Bereich der Fehler liegt.

-- Je 10.000 IDs eine Pruefsumme. Unterschiede zeigen den Bereich,
-- in dem verglichen werden muss.
SELECT FLOOR(id / 10000) AS bereich,
       COUNT(*)          AS anzahl,
       SUM(CRC32(CONCAT_WS('|', id, status, betrag_cent, kunde_id))) AS summe
FROM bestellung
GROUP BY bereich ORDER BY bereich;

Feldweiser Vergleich im auffälligen Bereich. Erst hier wird es teuer, und dank Stufe zwei betrifft es einen Bruchteil der Daten.

Das Ergebnis dieser drei Stufen ist die Bedingung für Phase 4. Nicht „der Backfill ist durch", sondern „beide Ziele sind über alle Bereiche gleich, und die Fehlerzahl aus Phase 2 ist null".

Vier Stellen, an denen es wirklich reißt

Das Verfahren ist solide. Was in der Praxis schiefgeht, sind fast immer dieselben vier Dinge, und keines davon steht in der Kurzfassung.

Erstens: Schreibpfade, die niemand kennt. Die Anwendung schreibt doppelt. Der nächtliche Importlauf nicht. Das Wartungsskript nicht. Die Schnittstelle für den Partner nicht. Jeder dieser Pfade erzeugt Datensätze, die es im neuen Ziel nicht gibt, und der Abgleich findet sie, ohne zu sagen, woher sie kommen. Die Suche danach gehört vor Phase 2, nicht danach: eine Woche Abfrageprotokoll, gefiltert auf schreibende Zugriffe auf die betroffenen Tabellen, liefert die vollständige Liste.

Zweitens: Löschungen. Doppelt schreiben denkt jeder mit. Doppelt löschen vergisst fast jeder. Das Ergebnis ist ein neues Ziel, das Datensätze enthält, die im alten längst weg sind, und bei Phase 4 erscheinen sie wieder. Dasselbe gilt für Statusänderungen, die fachlich einer Löschung entsprechen.

Drittens: der Rückweg nach Phase 4. Nach dem Umschalten schreibt die Anwendung weiter in beide Ziele, also ist der Rückweg technisch da. Er ist es nicht mehr, wenn das neue Ziel Daten erzeugt, die im alten Modell nicht abbildbar sind, etwa eine feinere Aufteilung. Dann ist Phase 4 eine Einbahnstraße, und das gehört vorher gesagt statt nachher gemerkt.

Viertens: die Zeit zwischen Lesen und Schreiben. Ein Datensatz wird vom Backfill gelesen, in derselben Sekunde ändert die Anwendung ihn, dann schreibt der Backfill den alten Stand ins neue Ziel. Das ist selten und es passiert. Dagegen hilft, dass der Backfill nur schreibt, wenn der Datensatz im Ziel älter ist als die Quelle, und dass der Abgleich am Ende noch einmal läuft.

Derselbe Gedanke auf der Betriebsseite: Ein Rückweg besteht nur, wenn beide Fassungen gleichzeitig arbeiten können. Blue/Green und Canary ohne Kubernetes.

Der Rückweg und wann er endet

Bis Phase 5 ist der Rückweg ein Schalter: Der Lesepfad geht wieder auf das alte Ziel. Das ist der Grund, warum Phase 4 und 5 getrennt sind und warum die Frist dazwischen so wichtig ist.

Was in dieser Zeit beobachtet wird, sind nicht nur Fehler. Es sind drei Dinge, und das zweite fällt am ehesten unter den Tisch.

  • Fehler und Abweichungen, also die Zähler aus Phase 2 und ein täglich laufender Abgleich.
  • Antwortzeiten. Ein neues Ziel ist nicht automatisch schneller. Wenn das 95. Perzentil des Lesepfads nach dem Umschalten steigt, ist das ein Befund, auch wenn alle Daten stimmen.
  • Die leisen Verbraucher. Berichte, Exporte, die Buchhaltung. Die melden sich nicht am ersten Tag, sondern am Monatsersten, und genau deshalb ist die Frist länger als zwei Wochen.

Phase 5 ist dann die Aufräumarbeit, und sie wird tatsächlich gemacht, nicht als Ticket geführt. Solange beide Ziele geschrieben werden, trägt das System die doppelte Komplexität, und der Zustand „wir haben migriert, schreiben aber sicherheitshalber noch beides" ist schlechter als beide Endzustände.

Das Muster dahinter ist dasselbe wie bei jeder schrittweisen Modernisierung: kleine Schritte, jeder einzeln produktiv, jeder einzeln zurückdrehbar. Wie ich das insgesamt schneide, steht auf einer eigenen Seite.

Dieser Artikel gehört zu einer Reihe über Systeme, die es schon gibt. Der Rückblick ordnet alle Artikel der Reihe nach Anlass.