Leistungen

Backend Engineering, Infrastruktur

Branche

B2B-Marktplatz

Jahr

2023

Vier Millionen Zeilen. Null Sekunden Downtime.

Ein B2B-Marktplatz brauchte eine neue nicht-nullable Spalte auf seiner größten Tabelle - 4 Millionen Zeilen Listing-Daten, die bei jedem Seitenaufruf abgefragt werden. Djangos Standard-Migrationsstrategie sperrt die gesamte Tabelle für die Dauer des Backfills. Bei einer 4M-Zeilen-Tabelle bedeutet das 30+ Sekunden blockierte Reads, blockierte Writes, Connection-Pool-Erschöpfung und HTTP 502s, die über die gesamte Anwendung kaskadieren. Die Spalte musste während des Spitzentraffics hinzugefügt werden, ohne dass es jemand bemerkt.

01.
DIE HERAUSFORDERUNG

Der 34-Sekunden-Blackout

Djangos AddField mit null=False und einem Standardwert führt ein einzelnes ALTER TABLE Statement aus. PostgreSQL erwirbt ein ACCESS EXCLUSIVE Lock auf die Tabelle - das restriktivste Lock-Level. Jede andere Transaktion, die die Tabelle berührt, reiht sich dahinter ein. Bei einer 4M-Zeilen-Tabelle mit durchschnittlicher Zeilenbreite dauert der Backfill 34 Sekunden. Während dieser 34 Sekunden gibt jede Listing-Seite, jede Suchanfrage, jede Admin-Panel-Ansicht ein 504 Gateway Timeout zurück. Der Connection-Pool füllt sich. Der Load Balancer beginnt 502er zurückzugeben. Nutzer sehen Fehlerseiten. Das Monitoring feuert. Und die Migration ist noch nicht einmal zur Hälfte fertig.

ALTER TABLE mit Default ist keine Schemaänderung. Es ist ein 34-Sekunden-Ausfall, der eine Migrationsnummer trägt.

02.
DIE LÖSUNG

Vier sichere Schritte statt einem gefährlichen

Die Lösung zerlegt die eine gefährliche Migration in vier Operationen, die nie länger als 10 Millisekunden ein Table Lock halten. Schritt 1: AddField mit null=True - sofort, kein Data Rewrite. Schritt 2: Eine Data Migration, die in Batches von 5.000 Zeilen mit iterator() und bulk_update() backfüllt, mit 100ms Sleep zwischen den Batches. Schritt 3: AlterField auf null=False setzen - nur Constraint, kein Data Rewrite. Schritt 4: Ein RunSQL zum Setzen des Column Defaults auf Datenbankebene. Traffic fließt ununterbrochen zwischen jedem Schritt.

Die Batch-Backfill Data Migration:

Python
def forwards(apps, schema_editor):
    Listing = apps.get_model('listings', 'Listing')
    batch_size = 5_000
    total = Listing.objects.filter(category_v2__isnull=True).count()

    for start in range(0, total, batch_size):
        batch = list(
            Listing.objects
            .filter(category_v2__isnull=True)
            .order_by('pk')
            .values_list('pk', flat=True)[:batch_size]
        )
        Listing.objects.filter(pk__in=batch).update(
            category_v2=F('category')  # copy from old column
        )
        time.sleep(0.1)  # yield I/O to production queries

Den Unterschied beobachten

Ein Seite-an-Seite-Vergleich. Der naive Ansatz sperrt die Tabelle 34 Sekunden lang, während Traffic auflauft. Der Zero-Downtime-Ansatz verarbeitet dieselben 4M Zeilen, ohne einen einzigen Request zu blockieren.

4.0M rows
NaiveALTER TABLE ... SET DEFAULT
0s34s
Zero-downtime4-step decomposition
0s42s
--
Downtime (naive)
--
Downtime (safe)
--
Blocked requests
--
Blocked requests

SeparateDatabaseAndState für ORM-Genauigkeit:

Python
class Migration(migrations.Migration):
    operations = [
        # Step 3: add NOT NULL constraint only
        migrations.SeparateDatabaseAndState(
            state_operations=[
                migrations.AlterField(
                    model_name='listing',
                    name='category_v2',
                    field=models.CharField(max_length=64),
                ),
            ],
            database_operations=[
                migrations.RunSQL(
                    sql='ALTER TABLE listings_listing '
                        'ALTER COLUMN category_v2 '
                        'SET NOT NULL;',
                    reverse_sql='ALTER TABLE listings_listing '
                                'ALTER COLUMN category_v2 '
                                'DROP NOT NULL;',
                ),
            ],
        ),
    ]

Produktions-Absicherungen

Batch Sleep Interval

Ein 100ms Sleep zwischen Batches verhindert, dass die Migration die Datenbank-I/O monopolisiert. Ohne Sleep läuft der Backfill schneller, konkurriert aber mit Produktions-Queries um Disk-Bandbreite. Der Sleep verwandelt eine 28-Sekunden-Migration in eine 42-Sekunden-Migration, aber die p99 Query-Latenz bleibt durchgehend flach. Die zusätzlichen 14 Sekunden sind für Nutzer unsichtbar; die alternative 34-Sekunden-Sperre nicht.

SeparateDatabaseAndState

Djangos Migrations-Framework trackt sowohl das Datenbankschema als auch den internen Model-State des ORM. Wenn eine Migration auf mehrere Dateien aufgeteilt wird, kann der ORM-State von der Realität abdriften. SeparateDatabaseAndState erlaubt es, Django zu sagen: 'Die Datenbank hat diese Spalte bereits; aktualisiere nur deine Model-Definition.' Das verhindert, dass Django versucht, eine bereits existierende Spalte beim nächsten migrate-Lauf erneut hinzuzufügen.

Staging-Replik-Validierung

Jede zerlegte Migration läuft auf einer Staging-Datenbank, die aus einem Produktions-Snapshot wiederhergestellt wurde. pg_stat_activity überwacht Lock-Wartezeiten in Echtzeit. Wenn ein Schritt länger als 50ms ein ACCESS EXCLUSIVE Lock hält, wird die Migration vor dem Produktionseinsatz neu designed. Diese Validierung hat zwei Probleme gefunden: einen vergessenen Index-Rebuild, der die Tabelle gesperrt hätte, und eine CHECK Constraint, die PostgreSQL durch Scannen jeder Zeile validiert.

03.
DAS ERGEBNIS

Unsichtbare Infrastruktur-Änderungen

Die 4-Schritt-Migration verarbeitete 4 Millionen Zeilen in 42 Sekunden mit null Downtime. Die Request-Latenz blieb über den gesamten Zeitraum innerhalb der normalen p99-Grenzen. Keine Connection-Pool-Warnungen. Keine 502er. Keine Monitoring-Alerts. Das Produktteam wusste nicht, dass die Migration gelaufen war, bis sie das Changelog prüften. Das Pattern wurde seitdem auf 11 weitere Schemaänderungen an Tabellen von 500K bis 12M Zeilen angewendet, alle während Spitzenverkehrszeiten.

WICHTIGE KENNZAHLEN

0MMigrierte Zeilen
0sDowntime
0<1msp99-Auswirkung
WAS DER KUNDE SAGT

"Wir haben Migrationen früher um 3 Uhr morgens eingeplant und gehofft, dass nichts kaputtgeht. Jetzt laufen sie während der Geschäftszeiten und niemand bemerkt es. Diese Veränderung hat die Art, wie wir über Datenbankänderungen nachdenken, grundlegend verändert."

Lead Developer

B2B-Marktplatz · Engineering

FAQ

Warum nicht pt-online-schema-change oder ähnliche Tools?

Was wenn der Backfill mittendrin fehlschlägt?

Funktioniert das für alle Spaltentypen?

TECHNOLOGIE-STACK