Jedna kolumna, tydzień pracy, czyli migracja dużej tabeli w Django

Utworzono: maj 2018 Zaktualizowano: 1 października 2026

z pracy

Python, Django, PostgreSQL

Naiwnie prosta migracja na tabeli, której wierszy nikt już nie liczył, skończyła się tygodniem pracy i czterema migracjami zamiast jednej.

W tabeli ze zdarzeniami trzeba było poprawić drobiazg w danych i przy okazji dopisać jedno pole. Naiwnie prosta migracja, makemigrations, migrate i nara. Tylko że wierszy w tej tabeli było, jak powiedziałoby plemię, które liczy do trzech, wenga wenga, czyli po prostu dużo. Lokalnie migracja przechodziła w sekundę, a na produkcji trwało to i trwało, transakcja wisiała, blokowała tabelę, aż w końcu się wywalała i wszystko się cofało.

Co tu się dzieje

Django generuje jedną migrację, która dodaje kolumnę od razu z wartością domyślną i jako NOT NULL, a poprawka danych, dopisana jako RunPython, leci w tej samej transakcji. W PostgreSQL dodanie kolumny z wartością domyślną oznacza przepisanie całej tabeli, a przez ten czas nikt w niej nie czyta i nie pisze. Jak coś padnie po godzinie, cofanie trwa drugie tyle.

Czemu nie przeczekać

Można było wyłączyć system na noc, tylko że nie było pewności, że noc wystarczy, a cofnięcie w połowie to druga noc. Można było zbudować tabelę obok, przelać dane i podmienić, ale przy tej wielkości to też długie kopiowanie i pilnowanie zmian, które w międzyczasie przychodzą. Zostało rozłożenie tego na kawałki, z których żaden nie trzyma długo blokady.

Cztery migracje zamiast jednej

Pierwsza dodaje kolumnę jako null=True, bez wartości domyślnej, co w PostgreSQL jest tylko zmianą opisu tabeli i trwa chwilę. Druga poprawia i uzupełnia dane paczkami, każda paczka w osobnej transakcji, więc blokady są krótkie, a jak coś padnie, wystarczy puścić migrację jeszcze raz i zacznie tam, gdzie skończyła. Trzecia ustawia NOT NULL, kiedy wszystkie wiersze mają już wartość, to już tylko sprawdzenie tabeli, bez przepisywania. Czwarta zakłada indeks z CONCURRENTLY, czyli bez blokowania zapisu, przez RunSQL w migracji bez transakcji.

Najwięcej zależy od drugiej, bez transakcji i z paczkami:

python
from django.db import migrations

BATCH = 10000


def backfill(apps, schema_editor):
    Record = apps.get_model("data", "Record")
    last_id = 0
    while True:
        ids = list(
            Record.objects.filter(id__gt=last_id, status__isnull=True)
            .order_by("id")
            .values_list("id", flat=True)[:BATCH]
        )
        if not ids:
            break
        Record.objects.filter(id__in=ids).update(status="new")
        last_id = ids[-1]


class Migration(migrations.Migration):
    atomic = False  # każda paczka to osobna transakcja

    dependencies = [("data", "0042_record_status_nullable")]
    operations = [migrations.RunPython(backfill, migrations.RunPython.noop)]

Bez atomic = False Django owinęłoby całą pętlę jedną transakcją i wrócilibyśmy do punktu wyjścia. Zamiast jednego polecenia wyszedł tydzień pracy, razem z próbami na kopii bazy, ale żadna z migracji nie zatrzymała systemu.