Zurück zur Startseite
Leistungen

Backend Engineering, Infrastruktur

Branche

B2B-Marktplatz

Jahr

2023

Eine Schemaänderung auf vier Millionen Zeilen, mitten im Peak-Traffic

Jede Schemaänderung bei diesem B2B-Marktplatz folgte demselben Ritual: für 3 Uhr morgens einplanen, eine Person aus dem Team wach halten, hoffen, dass nichts bricht. Dann brauchte das Produktteam eine neue Pflichtspalte auf der Listings-Tabelle. Vier Millionen Zeilen, bei jedem Seitenaufruf gelesen, und keine ruhige Stunde mehr in der Traffic-Kurve.

DIE HERAUSFORDERUNG

Der 34-Sekunden-Blackout

Djangos AddField mit null=False und Default wird zu einem einzigen ALTER TABLE. PostgreSQL hält dafür ein ACCESS EXCLUSIVE Lock auf der Tabelle, für die gesamte Dauer des Backfills, und bei vier Millionen Zeilen dauert der 34 Sekunden. In diesen 34 Sekunden stellt sich jede Listing-Seite, jede Suche und jede Admin-Ansicht hinter dem Lock an, bis sie mit 504 abbricht. Der Connection Pool läuft voll, der Load Balancer antwortet mit 502, das Monitoring feuert, und die Migration ist noch nicht einmal zur Hälfte durch. Ein Fenster um 3 Uhr hätte diesen Ausfall nur verschoben, nicht beseitigt. Die Spalte musste im Peak-Traffic landen, ohne dass es jemand merkt.

DIE LÖSUNG

Vier Migrationen statt einer

Aus der einen gefährlichen Migration wurden vier Operationen, von denen keine länger als zehn Millisekunden ein Table Lock hält. AddField mit null=True legt die Spalte an, ohne eine Zeile umzuschreiben. Eine Data Migration füllt die Werte in Batches von 5.000 Zeilen nach, mit 100 ms Pause zwischen den Batches. AlterField setzt danach null=False als reine Constraint-Änderung, ein abschließendes RunSQL setzt den Default in der Datenbank. Die naheliegende Alternative waren Online-Schema-Tools wie gh-ost oder pt-online-schema-change: Sie kopieren die Tabelle in eine Shadow Table und tauschen sie aus. Das löst das Lock-Problem, bringt aber Berechtigungen, Monitoring und Rollback-Prozeduren in ein PostgreSQL-Setup, das nichts davon hatte, und nimmt die Änderung aus Djangos Migrationshistorie heraus. Vier normale Migrationsdateien halten die Historie intakt. Der Preis: mehr Laufzeit und eine Migration, die dem ORM erklären muss, was die Datenbank schon weiß.

Der Backfill in Batches, als 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

Batch-Größe gegen Lock-Dauer

Beide Spuren migrieren dieselben vier Millionen Zeilen. Wechsle die Batch-Größe und beobachte die untere Spur: die Dauer des Row Locks, die Requests, die sich dahinter anstellen, und bei 500.000 Zeilen die Connection-Pool-Warnungen. Bei 5.000 sind die Locks zu kurz, um sie zu zeichnen.

Batch-GrößeZeilen pro Batch
Bereit
Eine MigrationAddField(null=False, default=...)
Tabelle gesperrt · 34 s
0 s34 s
Vier MigrationenAddField · backfill · AlterField · RunSQL
  1. AddField(null=True)
    < 10 ms
  2. Backfill in Batches
    Row Lock < 50 ms
  3. AlterField(null=False)
    < 10 ms
  4. RunSQL SET DEFAULT
    < 10 ms
0 sBatch 800 von 800: 4.000.000 von 4.000.000 Zeilen nachgefüllt42 s

800 Row Locks von je unter 50 ms: zu kurz, um sie in diesem Maßstab zu zeichnen.

Vergleich
Ein ALTER TABLEVier Schritte
Downtime34 s0 s
Blockierte Requests4000
Längster Lock34 s< 50 ms
Batches1 Statement800

400 Requests hingen hinter einem Table Lock von 34 Sekunden. Vier Schritte mit 5.000 Zeilen pro Batch: 0 blockiert, längster Lock < 50 ms.

Django verwaltet das Datenbankschema und seinen eigenen Model-State getrennt. Schritt drei setzt die Constraint per SQL und sagt dem ORM in derselben Migration Bescheid, damit der nächste migrate-Lauf nicht versucht, eine bereits vorhandene Spalte anzulegen:

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;',
                ),
            ],
        ),
    ]
DAS ERGEBNIS

Migrationen laufen jetzt zu Geschäftszeiten

Der Backfill lief im Peak-Traffic, und die Request-Latenz blieb die ganze Zeit in ihrem normalen p99-Band. Keine Pool-Warnungen, keine 502er, keine Alerts. Das Produktteam erfuhr aus dem Changelog, dass die Migration gelaufen war. Seitdem hat dieselbe Zerlegung in vier Schritte elf weitere Schemaänderungen getragen, auf Tabellen zwischen 500.000 und zwölf Millionen Zeilen, alle zu Geschäftszeiten. Das 3-Uhr-Fenster wurde nicht mehr gebucht.

WICHTIGE KENNZAHLEN

4MZeilen nachgefüllt, ein Lauf
0sDowntime während des Laufs
11Weitere Schemaänderungen, gleiches Muster
KUNDENSTIMME

Früher haben wir Migrationen für 3 Uhr morgens eingeplant und jemanden wach gehalten, der die Dashboards beobachtet. Seit dieser einen gehen sie im normalen Deploy zu Geschäftszeiten raus. Außerhalb des Engineering-Teams hat niemand eine einzige bemerkt.

Lead Developer

B2B-Marktplatz, Engineering

FÜR DEIN PROJEKT

Wann es passt
Jede PostgreSQL-Tabelle, bei der ein Lock von mehr als ein paar hundert Millisekunden für Nutzer sichtbar wird: neue NOT-NULL-Spalten, Defaults, Constraints. Dieselben vier Schritte haben hier elf weitere Änderungen getragen, auf Tabellen von 500.000 bis zwölf Millionen Zeilen.
Was du prüfen solltest
Stelle eine Staging-Datenbank aus einem Produktions-Snapshot wieder her und beobachte pg_stat_activity, während die Migration läuft. Alles, was ACCESS EXCLUSIVE länger als 50 ms hält, wird umgebaut. Index-Rebuilds und CHECK Constraints sind die üblichen Überraschungen; beide scannen die ganze Tabelle.
Was es braucht
Vier Migrationsdateien statt einer, eine Staging-Replik mit produktionsnahen Daten und mehr Laufzeit pro Durchgang. Kein neues Tooling, keine zusätzlichen Berechtigungen, kein Wartungsfenster.

FAQ

TECHNOLOGIE-STACK

Django
Python
PostgreSQL

Seit mittlerweile 10 Jahren arbeiten wir immer wieder gerne mit Daniel zusammen. Wir schätzen dabei sehr seine schnellen Reaktionszeiten rund um die Uhr und sein Allroundwissen. Egal ob Serverkonfigurationen oder Programmierungen, er hat immer die passende Lösung.

Manuel Kasbarian - Geschäftsführer, SophistiX

Tritt in die Fußstapfen von Manuel und erwecke deine Vision zum Leben.

Schreibe mirSchreibe mir