Zero Downtime Datenbankmigrationen in Symfony mit Doctrine
AI generated
SF
{ }
Symfony · Doctrine · Datenbank · DevOps
Zero Downtime Datenbankmigrationen
mit Doctrine, ohne Tabellensperre und ohne Ausfallzeit

Eine Migration, die spontan eine Spalte umbenennt oder eine Tabelle sperrt, kann eine laufende Symfony-Anwendung mitten in der Rush Hour lahmlegen. Zero Downtime Datenbankmigrationen mit Doctrine folgen stattdessen dem Expand-Contract-Muster: Schema und Anwendung wachsen in kleinen, immer rückwärtskompatiblen Schritten. Dieser Artikel zeigt jeden Schritt konkret, von der ersten neuen Spalte bis zum sicheren Entfernen der alten.

19 Min. Lesezeit Expand-Contract · Batch-Backfill · Dual-Write · Doctrine Migrations Symfony 7 · Doctrine ORM 3 · MySQL 8

1. Warum klassische Migrationen bei laufendem Betrieb gefährlich sind

Eine klassische Migration, die in einer einzigen Transaktion eine Spalte umbenennt, ihren Typ ändert oder eine Tabelle sperrt, geht implizit davon aus, dass die Anwendung während der Migration nicht erreichbar sein muss. In einem Setup mit Zero Downtime Datenbankmigration als Ziel ist diese Annahme nicht haltbar: Die Anwendung läuft weiter, bedient Nutzer, während das Schema sich verändert, und jede Inkompatibilität zwischen laufendem Code und neuem Schema führt zu Fehlern in Echtzeit.

Das Kernproblem ist zeitlicher Natur. Zwischen dem Moment, in dem eine Migration das Schema ändert, und dem Moment, in dem alle Anwendungsinstanzen auf den neuen Code umgestellt sind, vergeht immer eine gewisse Zeit, und während dieser Zeit müssen alte und neue Codeversion gleichzeitig mit demselben Schema arbeiten können. Eine Migration, die eine Spalte in einem einzigen, nicht rückwärtskompatiblen Schritt umbenennt, bricht garantiert die noch laufende alte Version, unabhängig davon, wie schnell das Deployment durchläuft.

Für echte Zero Downtime Datenbankmigration braucht es deshalb ein grundsätzlich anderes Vorgehen als das gewohnte Schreiben einer einzigen Migration pro Feature. Jede Schema-Änderung wird in mehrere, unabhängig voneinander deploybare Schritte zerlegt, von denen jeder einzelne für sich rückwärtskompatibel ist. Dieses Prinzip, bekannt als Expand-Contract-Muster, ist der rote Faden durch den gesamten restlichen Artikel.

2. Das Expand-Contract-Muster als Grundprinzip

Das Expand-Contract-Muster teilt jede Schema-Änderung in zwei klar getrennte Phasen. In der Expand-Phase wird ausschließlich hinzugefügt: neue Spalten, neue Tabellen, neue Indizes, niemals wird in dieser Phase etwas Bestehendes gelöscht oder umbenannt. Die alte Anwendungsversion funktioniert währenddessen unverändert weiter, weil sie die neuen Strukturen schlicht ignoriert. Erst in der späteren Contract-Phase, nachdem die alte Version endgültig aus dem Betrieb genommen wurde, werden nicht mehr benötigte alte Strukturen entfernt.

Für eine Zero Downtime Datenbankmigration mit Doctrine bedeutet das konkret: Eine geplante Spaltenumbenennung wird niemals als ALTER TABLE ... RENAME COLUMN in einem Schritt umgesetzt. Stattdessen entsteht zunächst eine neue Spalte mit dem gewünschten Namen, gefolgt von einem Backfill bestehender Daten, einer Übergangsphase mit paralleler Nutzung beider Spalten, und erst am Ende einer separaten Migration, die die alte Spalte entfernt. Vier bis fünf einzelne Deployments statt einem einzigen, aber jedes davon risikofrei für den laufenden Betrieb.

3. Neue Spalten sicher hinzufügen ohne Tabellensperre

Der erste Schritt jeder Zero Downtime Datenbankmigration nach dem Expand-Contract-Muster ist das Hinzufügen einer neuen Spalte. In modernen MySQL- und PostgreSQL-Versionen ist das Hinzufügen einer nullable Spalte ohne Standardwert meist eine reine Metadaten-Operation, die keine vollständige Tabellensperre erfordert und selbst bei großen Tabellen mit Millionen Zeilen in Millisekunden abgeschlossen ist. Wird dagegen ein Standardwert für eine bestehende Spalte gesetzt oder eine NOT-NULL-Bedingung sofort erzwungen, kann die Datenbank je nach Version und Speicher-Engine die gesamte Tabelle umkopieren, was bei großen Tabellen Minuten oder Stunden dauern und die Tabelle währenddessen blockieren kann.

Die sichere Doctrine-Migration für diesen Schritt fügt die neue Spalte deshalb konsequent als nullable ohne Default hinzu und verzichtet bewusst auf sofortige Constraints. NOT-NULL-Bedingungen und Fremdschlüssel werden erst in einer späteren Migration nachgezogen, nachdem der Backfill aus dem nächsten Abschnitt abgeschlossen ist und garantiert jede Zeile einen gültigen Wert trägt.


<?php
// migrations/Version20260730120000.php
// Expand step: add the new column, nullable, no default, no lock risk
declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260730120000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Expand: add nullable shipping_status column, no default, no lock';
    }

    public function up(Schema $schema): void
    {
        // Nullable, no default — metadata-only operation on most engines
        $this->addSql(
            'ALTER TABLE orders ADD shipping_status VARCHAR(32) DEFAULT NULL'
        );
    }

    public function down(Schema $schema): void
    {
        $this->addSql('ALTER TABLE orders DROP shipping_status');
    }
}

4. Daten-Backfill in Batches statt einer großen Transaktion

Nach dem Hinzufügen der neuen Spalte müssen bestehende Zeilen mit sinnvollen Werten gefüllt werden. Ein einzelnes UPDATE orders SET shipping_status = ... ohne Einschränkung sperrt bei Millionen Zeilen die Tabelle für die gesamte Laufzeit des Updates und ist damit das genaue Gegenteil von Zero Downtime Datenbankmigration. Der robuste Ansatz verarbeitet stattdessen kleine Batches, üblicherweise einige tausend Zeilen pro Durchlauf, mit kurzen Pausen zwischen den Batches, damit andere Anfragen an der Tabelle nicht ausgehungert werden.

Dieser Backfill läuft als eigenständiges Symfony-Console-Command, nicht als Teil der Migration selbst, weil Migrationen üblicherweise in einer einzigen, oft langen Transaktion laufen, während ein Batch-Backfill viele kurze, unabhängige Transaktionen braucht. Für Zero Downtime Datenbankmigration ist diese Trennung entscheidend: Die Migration ändert nur die Struktur, das Backfill-Command füllt die Daten, beide Schritte sind unabhängig voneinander wiederholbar und überwachbar.


<?php
// src/Command/BackfillShippingStatusCommand.php
declare(strict_types=1);

namespace App\Command;

use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'app:backfill:shipping-status')]
final class BackfillShippingStatusCommand extends Command
{
    private const int BATCH_SIZE = 2000;

    public function __construct(private readonly Connection $connection)
    {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $processed = 0;

        do {
            // Small, independent transactions — never one giant UPDATE
            $affected = $this->connection->executeStatement(
                'UPDATE orders SET shipping_status = \'unknown\'
                 WHERE shipping_status IS NULL
                 LIMIT :limit',
                ['limit' => self::BATCH_SIZE],
                ['limit' => \PDO::PARAM_INT]
            );

            $processed += $affected;
            $output->writeln(sprintf('Backfilled %d rows so far', $processed));

            usleep(200_000); // 200ms pause between batches to avoid saturation
        } while ($affected > 0);

        $output->writeln(sprintf('<info>Backfill complete: %d rows</info>', $processed));
        return Command::SUCCESS;
    }
}

5. Dual-Write-Phase: Anwendung schreibt in alte und neue Struktur

Während des Übergangs müssen sowohl die alte als auch die neue Version der Anwendung parallel laufen können, was bedeutet, dass Schreibvorgänge in beide Strukturen gleichzeitig erfolgen müssen. Für Zero Downtime Datenbankmigration ist diese Dual-Write-Phase der heikelste Teil, weil ein Fehler hier zu stillschweigend inkonsistenten Daten führen kann, die erst viel später auffallen.

In Symfony lässt sich Dual-Write elegant über einen Doctrine Event Listener auf prePersist und preUpdate umsetzen, der bei jedem Schreibvorgang automatisch beide Felder synchron hält, ohne dass der eigentliche Anwendungscode etwas davon merkt. Sobald alle Anwendungsinstanzen auf die neue Codeversion aktualisiert sind, die durchgängig die neue Spalte nutzt, kann der Dual-Write-Listener in einem weiteren, risikofreien Deployment entfernt werden.


<?php
// src/EventListener/OrderDualWriteListener.php
declare(strict_types=1);

namespace App\EventListener;

use App\Entity\Order;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Events;
use Doctrine\Persistence\Event\LifecycleEventArgs;

#[AsDoctrineListener(event: Events::prePersist)]
#[AsDoctrineListener(event: Events::preUpdate)]
final class OrderDualWriteListener
{
    /**
     * Keeps the legacy status column and the new shipping_status
     * column in sync during the transition window. Remove this
     * listener only after every application instance reads
     * exclusively from shipping_status.
     */
    public function prePersist(LifecycleEventArgs $args): void
    {
        $this->syncColumns($args);
    }

    public function preUpdate(LifecycleEventArgs $args): void
    {
        $this->syncColumns($args);
    }

    private function syncColumns(LifecycleEventArgs $args): void
    {
        $entity = $args->getObject();
        if (!$entity instanceof Order) {
            return;
        }

        // Mirror the legacy field into the new column on every write
        $entity->setShippingStatus($entity->getLegacyStatus());
    }
}

6. Lesend umschalten und Konsistenz prüfen

Bevor die alte Spalte entfernt werden darf, muss zweifelsfrei feststehen, dass keine einzige Anwendungsinstanz mehr lesend auf sie zugreift. Für Zero Downtime Datenbankmigration empfiehlt sich dafür ein zweistufiges Vorgehen: Zunächst wird der Lesepfad im Code auf die neue Spalte umgestellt, während der Dual-Write weiterhin beide Spalten befüllt. Erst nachdem diese Codeversion vollständig ausgerollt ist, folgt eine Konsistenzprüfung, die stichprobenartig oder vollständig vergleicht, ob alte und neue Spalte tatsächlich übereinstimmen.

Diese Konsistenzprüfung läuft am besten als eigenständiges Batch-Command, ähnlich dem Backfill-Command, und protokolliert jede gefundene Abweichung statt sie automatisch zu korrigieren. Abweichungen deuten meist auf einen Fehler in der Dual-Write-Logik hin, der behoben werden muss, bevor die Contract-Phase überhaupt beginnen darf. Ein Zero Downtime Datenbankmigration-Prozess, der diese Prüfung überspringt, riskiert stillen Datenverlust beim finalen Entfernen der alten Spalte.

7. Alte Spalten sicher entfernen, erst nach vollständiger Migration

Erst wenn feststeht, dass keine Codeversion mehr die alte Spalte liest oder schreibt und die Konsistenzprüfung ohne Abweichungen durchgelaufen ist, darf die Contract-Phase beginnen. Diese letzte Migration entfernt die alte Spalte und alle zugehörigen Indizes, typischerweise ebenfalls eine schnelle, überwiegend metadatenbasierte Operation, sofern keine Fremdschlüssel mit kaskadierenden Effekten involviert sind.

Ein wichtiger Sicherheitsschritt vor dieser finalen Migration: Der Dual-Write-Listener aus Abschnitt fünf muss vollständig entfernt sein, bevor die Spalte verschwindet, sonst würde die Anwendung versuchen, in eine nicht mehr existierende Spalte zu schreiben. Die Reihenfolge lautet deshalb immer: Erst Code-Deployment ohne Dual-Write, dann Verifikation im Betrieb, dann erst die Contract-Migration. Diese strikte Reihenfolge ist der Kern dessen, was Zero Downtime Datenbankmigration von einer riskanten Ad-hoc-Schema-Änderung unterscheidet.


<?php
// migrations/Version20260815090000.php
// Contract step: remove the legacy column, only after dual-write
// has been fully removed from the application code
declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260815090000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Contract: drop legacy status column after full cutover to shipping_status';
    }

    public function preUp(Schema $schema): void
    {
        // Safety guard: refuse to run if any row still lacks the new column
        $count = $this->connection->fetchOne(
            'SELECT COUNT(*) FROM orders WHERE shipping_status IS NULL'
        );

        if ((int) $count > 0) {
            throw new \RuntimeException(
                sprintf('Refusing to drop legacy column: %d rows not backfilled yet', $count)
            );
        }
    }

    public function up(Schema $schema): void
    {
        $this->addSql('ALTER TABLE orders DROP status');
    }
}

8. Doctrine Migrations praktisch: Hooks und Locking

Doctrine Migrations bietet mit preUp, postUp, preDown und postDown Hooks, die für Zero Downtime Datenbankmigration essenzielle Sicherheitsprüfungen ermöglichen, wie im vorigen Abschnitt gezeigt. Ebenso wichtig ist, dass Migrationen niemals von mehreren Deployment-Pipelines gleichzeitig ausgeführt werden, was bei parallelen Deployments verschiedener Services auf dieselbe Datenbank zu Race Conditions führen kann. Doctrine Migrations bringt dafür einen eingebauten Locking-Mechanismus mit, der über eine Metadaten-Tabelle sicherstellt, dass immer nur ein Migrationslauf gleichzeitig aktiv ist.

Für produktive Zero Downtime Datenbankmigration empfiehlt sich außerdem, jede Migration einzeln mit einem expliziten Statement-Timeout zu versehen. Eine Migration, die unerwartet lange läuft, etwa weil eine andere Transaktion eine Sperre hält, sollte nach einer definierten Zeit abbrechen und Alarm schlagen, statt unbegrenzt zu blockieren und im schlimmsten Fall den gesamten Deployment-Prozess zum Stillstand zu bringen.


#!/usr/bin/env bash
# run-migration.sh — apply a single Doctrine migration with a hard
# statement timeout so a stuck migration cannot block the deployment
set -euo pipefail

MIGRATION_VERSION="${1:?Usage: run-migration.sh <migration-version>}"

php bin/console doctrine:migrations:execute \
  --up "${MIGRATION_VERSION}" \
  --no-interaction \
  --query-time-limit=30

echo "[OK] Migration ${MIGRATION_VERSION} applied within timeout"

9. Migrationsstrategien im direkten Vergleich

Die folgende Tabelle stellt drei verbreitete Ansätze für Schema-Änderungen bei laufendem Betrieb gegenüber.

Strategie Sperrzeit Komplexität Kompatibilität während Rollout
Große Transaktion, ein Schritt hoch, ganze Tabelle niedrig keine, alte Version bricht sofort
Online Schema Change Tool niedrig, kurze finale Sperre mittel, externes Tool nötig gut für reine Strukturänderungen
Expand-Contract-Muster minimal, meist metadatenbasiert hoch, mehrere Deployments vollständig während des gesamten Rollouts

Online Schema Change Tools wie gh-ost oder pt-online-schema-change lösen das Sperrproblem auf Datenbankebene sehr gut, beantworten aber nicht die Frage der Anwendungskompatibilität während des Rollouts. Für echte Zero Downtime Datenbankmigration in Symfony-Anwendungen ist deshalb das Expand-Contract-Muster meist unverzichtbar, während Online Schema Change Tools als ergänzende Technik für die eigentliche Strukturänderung auf sehr großen Tabellen wertvoll bleiben.

Mironsoft

Symfony DevOps, Doctrine-Migrationen und Datenbank-Betrieb ohne Ausfallzeit

Zero Downtime Datenbankmigrationen für euren Symfony-Betrieb?

Wir bauen Expand-Contract-Migrationen für Symfony und Doctrine: Batch-Backfill, Dual-Write-Phasen, Konsistenzprüfungen und ein sicheres Entfernen alter Spalten ohne Ausfallzeit.

Migrations-Audit

Bestehende Migrationen auf riskante Sperr- und Rewrite-Operationen prüfen

Expand-Contract-Umbau

Kritische Schema-Änderungen auf sichere, mehrstufige Migrationen umstellen

Backfill-Automatisierung

Batch-Commands mit Monitoring und Konsistenzprüfung implementieren

10. Zusammenfassung

Zero Downtime Datenbankmigration mit Doctrine verlangt ein grundsätzliches Umdenken weg von der einzelnen, alles verändernden Migration hin zum Expand-Contract-Muster. Neue Spalten werden nullable ohne Default hinzugefügt, bestehende Daten in kleinen Batches statt einer großen Transaktion nachgezogen, und eine Dual-Write-Phase hält alte und neue Struktur synchron, während beide Codeversionen parallel im Betrieb sind.

Erst nach vollständiger Verifikation, dass keine Instanz mehr die alte Struktur nutzt, folgt die Contract-Phase mit dem sicheren Entfernen alter Spalten, abgesichert durch explizite Guards in preUp-Hooks. Diese mehrstufige Vorgehensweise braucht mehr Deployments als eine klassische Migration, garantiert dafür aber echte Zero Downtime Datenbankmigration ohne Tabellensperren, ohne Inkompatibilitäten zwischen Codeversionen und ohne das Risiko stiller Datenverluste.

Zero Downtime Datenbankmigrationen mit Doctrine — Das Wichtigste auf einen Blick

Expand-Contract-Muster

Erst hinzufügen, nie sofort umbenennen oder löschen. Entfernen erst nach vollständigem Rollout.

Batch-Backfill

Kleine, unabhängige Transaktionen statt eines einzigen großen UPDATE über Millionen Zeilen.

Dual-Write-Phase

Doctrine Event Listener hält alte und neue Spalte synchron, bis der vollständige Cutover erfolgt ist.

Sichere Contract-Phase

preUp-Guards verhindern das Entfernen alter Spalten, solange nicht alle Zeilen migriert sind.

11. FAQ: Zero Downtime Datenbankmigrationen mit Doctrine

1Was ist das Expand-Contract-Muster?
Schema-Änderungen in additive Expand-Phase und spätere Contract-Phase mit Entfernen alter Strukturen aufteilen.
2Warum nullable ohne Default?
Reine Metadaten-Operation ohne Tabellensperre. Ein Default-Wert oder NOT-NULL kann ein Umkopieren auslösen.
3Warum Backfill nicht in der Migration?
Migrationen laufen in einer langen Transaktion, Backfill braucht viele kurze. Ein Console-Command trennt beides sauber.
4Was ist eine Dual-Write-Phase?
Jeder Schreibvorgang füllt alte und neue Struktur gleichzeitig, meist über einen Event Listener.
5Wann alte Spalte entfernen?
Erst nach vollständiger Verifikation und einem technischen Guard, der ohne saubere Migration verweigert.
6Parallele Migrationsläufe verhindern?
Eingebauter Locking-Mechanismus von Doctrine Migrations über eine Metadaten-Tabelle.
7Wozu ein Statement-Timeout?
Verhindert, dass eine hängende Migration den gesamten Deployment-Prozess unbegrenzt blockiert.
8Online Schema Change Tools als Alternative?
Lösen nur das Sperrproblem, nicht die Anwendungskompatibilität. Expand-Contract bleibt zusätzlich nötig.
9Wie viele Deployments für eine Umbenennung?
Typischerweise vier bis fünf Schritte, jeder für sich risikofrei.
10Was bei Abweichungen in der Konsistenzprüfung?
Protokollieren statt automatisch korrigieren. Meist ein Hinweis auf einen Fehler in der Dual-Write-Logik.