manage.py migrate в пятницу в 17:30 на проде с 3K RPS и таблицей 200М строк

от автора

Всем привет! Меня зовут Макс, я Lead Backend и автор YouTube-канала PyLounge

Это третья часть мини-серии о Django-миграциях. В первой части мы готовились к миграциям и разбирались с конфликтами, во второй чинили типичные подводные камни. Если их не читали, то рекомендую начать именно с них, а затем вернуться сюда.

В этом же материале поговорим о самом интересном: что происходит, когда python manage.py migrate запускается в 17:30 в пятницу на проде, под 3k RPS и таблицей в 200 миллионов строк. 

Расскажу какие блокировки в PostgreSQL берёт каждая операция Django, что внутри atomic = False, как пишется правильный паттерн expand — migrate — contract, зачем нужны AddIndexConcurrentlyAddConstraintNotValidSeparateDatabaseAndState и как обновлять данные на больших таблицах.

P.S. примеры намеренно упрощены, чтобы влезли в статью и не задушили. В реальной жизни всё ещё хуже — но шаги те же.

P.S.S. При подготовки этого материала ни одна продовая база данных не пострадала. 

Почему migrate в проде это не «просто одна команда»

У миграции есть три стула слоя, каждый из которых потенциально может привести к падению прода:

  1. Сгенерированный SQL. Иногда не такой, который ты ожидал. Например, AlterField(max_length=64)для CharField(max_length=32) — это ALTER TABLE ... ALTER COLUMN TYPE varchar(64), и, да, на PostgreSQL это будет очень быстро. 

А вот некоторые изменения типа действительно приводят к переписыванию всей таблицы (table rewrite), например, text -> integervarchar -> numericjson -> jsonb и другие небинарно-совместимые преобразования.

При этом varchar(n) -> text в PostgreSQL rewrite не требует — это binary-compatible изменение и обычно выполняется как metadata-only операция.

  1. Блокировки. PostgreSQL может блокировать таблицу так, что не пройдет даже SELECT. Очереди блокировок в PostgreSQL — это FIFO. То есть твоя миграция ждет долгую транзакцию пять минут, а за ней молча стоят ещё 200 запросов от пользователей. Никто не отвечает. Прод R.I.P.

  2. Python-код в RunPython. Он запускается прямо в транзакции миграции (если atomic = True, а это значение по умолчанию) и держит её открытой всё время выполнения. Developer.objects.all().update(...) на 50 миллионов строк — R.I.P.

Из практики (все персонажи и числа выдуманы, я актер, это все постановка):

  • Кейс 1. Славик добавил поле is_archived =models.BooleanField(default=False) 
    в таблицу с 80 000 000 строк на PostgreSQL 13. Миграция отработала за 14 минут. Всё это время таблица была недоступна на запись. Прод лежал, весь автобус плакал. 

  • Кейс 2. Владислав добавил models.Index(fields=['created_at']) в Meta модели Order.  CREATE INDEX без CONCURRENTLYвзял SHARE на таблице — все вставки заказов встали в очередь на десять минут. 

  • Кейс 3. Васян написал data-миграцию для бэкфилла на 20M строк через Order.objects.filter(...).update(...). Миграция была атомарной по умолчанию. Один большой UPDATE сгенерил гигантский WAL, реплики залагали — R.I.P согласованность данных.

Все три случая лечатся одинаково — понимать, что именно делает каждая твоя миграция на уровне PostgreSQL.

Минимально необходимая теория блокировок в PostgreSQL

PostgreSQL имеет 8 уровней табличных блокировок. Запомнить все не обязательно — достаточно понять «лестницу»: чем выше уровень, тем больше других операций он блокирует. Уровни в иерархии (от слабого к сильному):

1. ACCESS SHARE — берет SELECT. Самая слабая блокировка: обычное чтение почти никому не мешает и конфликтует только с ACCESS EXCLUSIVE.

  1. ROW SHARE — SELECT FOR UPDATE/SHARE. Используется, когда запрос собирается блокировать строки; чуть строже обычного чтения.

  2. ROW EXCLUSIVE — INSERTUPDATEDELETE. Это стандартная DML-нагрузка: изменения данных разрешены параллельно, пока нет «тяжёлого» DDL. То есть обычные DML-операции не мешают друг другу, но серьёзные изменения структуры таблицы могут остановить или заблокировать их (REINDEXALTER TABLE ... TYPEALTER TABLE ... ADD COLUMN и т.д.).

  3. SHARE UPDATE EXCLUSIVE — VACUUM (без FULL), ANALYZECREATE INDEX CONCURRENTLYALTER TABLE VALIDATE CONSTRAINTREINDEX CONCURRENTLY. Нужна для риалтайм-операций обслуживания: таблицу можно продолжать читать и менять.

  4. SHARE — CREATE INDEX (без CONCURRENTLY). Разрешает чтение, но блокирует INSERT/UPDATE/DELETE, потому что индекс строится в одном консистентном состоянии.

  5. SHARE ROW EXCLUSIVE — CREATE TRIGGER, некоторые ALTER TABLE. Более жёсткий DDL-режим: PostgreSQL защищает структуру таблицы от параллельных изменений.

  6. EXCLUSIVE — REFRESH MATERIALIZED VIEW CONCURRENTLY. Почти полная блокировка: читать можно, но любые изменения данных запрещены.

  7. ACCESS EXCLUSIVE — DROP TABLETRUNCATE, большинство ALTER TABLEREINDEX. Самая сильная блокировка: останавливает вообще всё, включая обычный SELECT.

Для миграций нас в основном волнует разница между CONCURRENTLY-вариантами (SHARE UPDATE EXCLUSIVE — совместимо с DML) и обычными DDL (SHARE / ACCESS EXCLUSIVE — НЕ совместимо с DML).

Великий и ужасный — ACCESS EXCLUSIVE

Эта блокировка берется:

  • ALTER TABLE ... ADD COLUMN (даже если мгновенно).

  • ALTER TABLE ... DROP COLUMN.

  • ALTER TABLE ... ALTER COLUMN TYPE (даже без REWRITE).

  • ALTER TABLE ... ADD CONSTRAINT (без NOT VALID).

  • CREATE INDEX (без CONCURRENTLY) — берёт SHARE, что не полный ACCESS EXCLUSIVE, но всё равно блокирует write.

  • DROP INDEX (без CONCURRENTLY).

  • ALTER TABLE ... RENAME.

  • DROP TABLETRUNCATECLUSTERVACUUM FULL.

ACCESS EXCLUSIVE мгновенен, если операция не требует физического переписывания таблицы или сканирования всех строк. Например, ADD COLUMN без default — это просто изменение метаданных в pg_attribute, миллисекунды. Но даже мгновенная ACCESS EXCLUSIVE может уронить прод из-за очереди блокировок.

Очередь блокировок и почему она опасна

PostgreSQL старается не допускать ситуации, когда более сильные блокировки ждут бесконечно долго. Поэтому если ALTER TABLE уже ждёт ACCESS EXCLUSIVE, новые запросы, которые формально совместимы с текущими lock’ами, могут начать вставать в очередь за ним.

На практике это выглядит так — одна ожидающая DDL-операция начинает тормозить весь поток запросов к таблице.

Сценарий:

T0: Аналитик запустил SELECT pg_dump таблицы users → берёт ACCESS SHARE на 5 минут.T1: Запускается миграция ALTER TABLE users ADD COLUMN foo    -> пытается взять ACCESS EXCLUSIVE -> ждёт.T2: Пришёл API-запрос: SELECT * FROM users WHERE id = 42    -> пытается взять ACCESS SHARE -> совместим с тем, что у аналитика,       НО несовместим с тем, что ЖДЁТ миграция -> встаёт в очередь.T3: Ещё 200 запросов -> все в очереди.T4: Аналитик закончил.T5: Миграция отработала за 5 мс.T6: Очередь рассасывается.

Между T1 и T5 прошло 5 минут полной недоступности сервиса. Миграция при этом фактически отработала за 5 мс.

Лечение — lock_timeout. Это настройка PostgreSQL, которая говорит «если не могу взять блокировку за N секунд — упади». Лучше упасть и попробовать снова через минуту, чем стоять и блокировать прод:

# 0042_safe_alter.pyfrom django.db import migrationsclass Migration(migrations.Migration):    atomic = False    dependencies = [...]    operations = [        migrations.RunSQL(            sql="SET lock_timeout = '3s'; SET statement_timeout = '5min';",            reverse_sql=migrations.RunSQL.noop,        ),        # ... основные операции    ]

lock_timeout действует на текущую сессию, поэтому строка обязательно должна быть внутри той же транзакции/сессии, что и опасный ALTER. 

Конкретные значения timeout’ов сильно зависят от вашей текущей нагрузки.

Для высоконагруженных систем 3 с. может быть слишком агрессивным значением и приводить к постоянным рестартам.

Timeout’ы стоит подбирать исходя из:

  • средней длительности транзакций;

  • профиля нагрузки;

  • maintenance window;

  • replication lag;

  • количества параллельных записей.

statement_timeout — соседний предохранитель — «если сам SQL-стейтмент выполняется дольше N — упади».

В PostgreSQL 17 появился ещё один уровень — transaction_timeout. Он ограничивает время всей транзакции, не отдельного оператора.

migrations.RunSQL(    sql=(        "SET lock_timeout = '3s'; "        "SET statement_timeout = '5min'; "        "SET transaction_timeout = '10min';"  # PG 17+    ),    reverse_sql=migrations.RunSQL.noop,),

sqlmigrate — твой лучший друг перед migrate

Перед каждым накатом миграции на прод запускаем:

python manage.py sqlmigrate developers 0042

И читаем глазами. Команда показывает SQL, который Django сгенерирует, не применяя его. Это первая и обязательная проверка. По нему ты сразу видишь:

  • сколько операторов будет выполнено;

  • есть ли ALTER TABLE ... ALTER COLUMN TYPE (потенциально REWRITE);

  • есть ли CREATE INDEX без CONCURRENTLY;

  • есть ли ADD CONSTRAINT без NOT VALID;

  • завернет ли Django все в BEGIN ... COMMIT (если миграция атомарная).

Пример «опасного» вывода:

BEGIN;---- Alter field rating on developer--ALTER TABLE "developers_developer" ALTER COLUMN "rating" TYPE numeric(10, 2)    USING "rating"::numeric(10, 2);COMMIT;

ALTER COLUMN ... TYPE ... USING ... — это REWRITE на всю таблицу под ACCESS EXCLUSIVE. На таблице в 50М строк это часы простоя.

Пример «безопасного»:

BEGIN;---- Add field nickname to developer--ALTER TABLE "developers_developer" ADD COLUMN "nickname" varchar(64) NULL;COMMIT;

ACCESS EXCLUSIVE, но мгновенный (только метаданные). Безопасно, если есть lock_timeout.

Каталог операций х безопасность

Шпаргалка, на которую можно +-ориентироваться. 

Операция Django

SQL

Блокировка

Время

Безопасна?

CreateModel

CREATE TABLE

мгновенно

DeleteModel

DROP TABLE

ACCESS EXCLUSIVE

мгновенно

⚠️ ломает старый код

AddField (nullable, без default)

ADD COLUMN NULL

ACCESS EXCLUSIVE

мгновенно

AddField (NOT NULL + constant default)

ADD COLUMN NOT NULL DEFAULT 'x'

ACCESS EXCLUSIVE

почти быстро

⚠️, но могут быть нюансы с большими таблицами

AddField (NOT NULL + volatile default: uuid4, now())

ADD COLUMN + UPDATE строк

ACCESS EXCLUSIVE

долго

AddField (FK)

ADD COLUMN + ADD CONSTRAINT FK

ACCESS EXCLUSIVE + полный скан

долго

⚠️

RemoveField

DROP COLUMN

ACCESS EXCLUSIVE

мгновенно

⚠️ ломает старый код

AlterField: расширение max_length для varchar

ALTER COLUMN TYPE

ACCESS EXCLUSIVE, без REWRITE

мгновенно

AlterField: сужение / смена типа

ALTER COLUMN TYPE с REWRITE

ACCESS EXCLUSIVE

очень долго

AlterField: смена null=True → null=False

ALTER COLUMN SET NOT NULL

ACCESS EXCLUSIVE + полный скан (PG 12+ обходится при CHECK)

долго

AlterField: смена default=

ALTER COLUMN SET DEFAULT

ACCESS EXCLUSIVE

мгновенно

AddIndex

CREATE INDEX

SHARE (блокирует write)

от секунд до часов

❌ → AddIndexConcurrently

RemoveIndex

DROP INDEX

lock на index + связанные table locks

может быть медленно

⚠️ → RemoveIndexConcurrently

AddConstraint (CheckConstraint)

ADD CONSTRAINT CHECK

ACCESS EXCLUSIVE + полный скан

долго

❌ → NOT VALID + VALIDATE

AddConstraint (UniqueConstraint)

ADD CONSTRAINT UNIQUE

SHARE на время создания индекса

долго

❌ → SeparateDatabaseAndState

RenameField

ALTER TABLE RENAME COLUMN

ACCESS EXCLUSIVE

мгновенно

⚠️ старый код упадёт

RenameModel

ALTER TABLE RENAME

ACCESS EXCLUSIVE

мгновенно

⚠️ старый код упадёт

RunPython (UPDATE без батчей)

UPDATE ...

ROW EXCLUSIVE на куче строк

очень долго

❌ → батчи

P.S. Не является строгой спецификаций, это больше шпаргалка для общего понимая. Что можно держать в голове. 

Разберём ключевые ячейки подробнее.

AddField + DEFAULT на PostgreSQL 14+

# PG 14+ (и даже 11+), БЕЗОПАСНО:migrations.AddField(    model_name='developer',    name='is_archived',    field=models.BooleanField(default=False),),

Эта миграция мгновенна на любой таблице. ACCESS EXCLUSIVE берётся, но удерживается миллисекунды.

Но! Это работает только для константных default. Если default — это callable, оптимизация PG не применяется и таблица переписывается:

# ОПАСНО на любом PG:migrations.AddField(    model_name='developer',    name='external_id',    field=models.UUIDField(default=uuid.uuid4),),

Лечение — разделить на этапы:

  1. AddField(null=True) — без default.

  2. RunPython(backfill_uuid) чанками с atomic=False.

  3. AlterField(null=False) — через AddConstraintNotValid + ValidateConstraint (см. ниже).

AddField + FK на большой таблице

ADD CONSTRAINT FOREIGN KEY валидирует существующие строки и может долго сканировать таблицу.

Основная проблема здесь — не столько тип блокировки, сколько длительность validation scan на больших таблицах. Во время валидации PostgreSQL берёт несколько lock’ов на referencing/referenced tables, а сама операция может идти очень долго на десятках миллионов строк. На таблице в 100M строк это может занять часы.

Решение — NOT VALID + VALIDATE CONSTRAINT. Django не имеет встроенной операции для FK с NOT VALID, поэтому делаем руками через RunSQL + SeparateDatabaseAndState:

class Migration(migrations.Migration):    atomic = False    dependencies = [...]    operations = [        # 1. Колонка nullable, мгновенно.        migrations.AddField(            model_name='order',            name='customer',            field=models.ForeignKey(                'customers.Customer', null=True,                on_delete=models.PROTECT, db_constraint=False,            ),        ),        # 2. Добавляем FK как NOT VALID - мгновенно (берёт ACCESS EXCLUSIVE,        #    но не сканирует таблицу).        migrations.RunSQL(            sql=(                'ALTER TABLE "orders_order" '                'ADD CONSTRAINT "orders_order_customer_fk" '                'FOREIGN KEY ("customer_id") '                'REFERENCES "customers_customer" ("id") NOT VALID;'            ),            reverse_sql=(                'ALTER TABLE "orders_order" DROP CONSTRAINT "orders_order_customer_fk";'            ),        ),        # 3. Валидируем существующие строки - SHARE UPDATE EXCLUSIVE,        #    совместимо с DML, может идти долго, но прод работает.        migrations.RunSQL(            sql='ALTER TABLE "orders_order" VALIDATE CONSTRAINT "orders_order_customer_fk";',            reverse_sql=migrations.RunSQL.noop,        ),    ]

VALIDATE CONSTRAINT обычно совместим с обычным DML и значительно безопаснее прямого ADD CONSTRAINT.

Но на очень горячих таблицах validation всё равно может создавать заметную IO-нагрузку и влиять на latency.

db_constraint=False в ForeignKey. Это говорит Django — в БД constraint не создавай, я его сделаю руками.

AlterConstraint — подарок от Django 5.2

Это маленькая, но очень важная для прода фича. Раньше любое изменение метаданных constraint — например, добавление violation_error_message для красивого сообщения юзеру при нарушении уникальности — приводило к миграции вида «DROP CONSTRAINT + ADD CONSTRAINT». На большой таблице это DROP INDEX + CREATE INDEX = боль.

Начиная с Django 5.2:

# Было в модели:class Meta:    constraints = [        models.UniqueConstraint(fields=['email'], name='user_email_uniq'),    ]# Стало:class Meta:    constraints = [        models.UniqueConstraint(            fields=['email'], name='user_email_uniq',            violation_error_message='Email уже занят',        ),    ]

В Django 5.1 и ранее makemigrations сгенерил бы RemoveConstraint + AddConstraint с реальным DROP/CREATE в БД. В Django 5.2 — AlterConstraint (no-op для БД, обновление только in-memory state):

# Django 5.2 makemigrations:operations = [    migrations.AlterConstraint(        model_name='user',        name='user_email_uniq',        constraint=models.UniqueConstraint(            fields=['email'], name='user_email_uniq',            violation_error_message='Email уже занят',        ),    ),]

Никакого ALTER TABLE. Просто Django запоминает новые метаданные. Минус одна потенциально долгая миграция — это здорово.

Главный паттерн: Expand — Migrate — Contract

Принцип 1: Старый код должен работать с новой схемой. 
Принцип 2: Новый код должен работать со старой схемой. 
Принцип 3: Между ними — отдельные шаги по миграции данных.

Это значит, что почти любое «опасное» изменение схемы — это не одна миграция и не один деплой. Это последовательность из 3+ релизов:

  1. Expand. Расширяем схему так, чтобы старый код продолжал работать (новые поля nullable, новые таблицы не используются, старые поля остаются).

  2. Migrate. Переводим логику и данные на новую схему. Обычно — несколько подэтапов с код-релизами между ними.

  3. Contract. Удаляем старое.

Между этими этапами — обязательно деплои с проверкой, что всё работает. Никогда не пытайся уместить переименование поля в один PR.

Сквозной пример: переименование Developer.title в Developer.name

Это та же модель, что в первых статьях. Допустим, мы решили, что title — плохое имя для имени разработчика, нужно переименовать в name. Сделать это в лоб через RenameField значит:

  • На уровне PG: ALTER TABLE RENAME COLUMN — мгновенно, ACCESS EXCLUSIVE.

  • На уровне приложения: между моментом, когда миграция применилась, и моментом, когда задеплоился новый код, старые инстансы приложения ходят в БД с запросом SELECT title FROM developers_developer и получают ошибку column "title" does not exist.

В rolling deploy это особенно весело: пока новые поды поднимаются, старые продолжают отдавать 500-ки.

Правильный путь через 3 релиза:

Релиз 1 (Expand): добавляем name, оставляем title.

# developers/models.pyclass Developer(models.Model):    title = models.CharField(max_length=64)           # старое поле    name = models.CharField(max_length=64, null=True) # новое поле    @property    def display_name(self):        return self.name or self.title

Миграция 1 — schema:

class Migration(migrations.Migration):    dependencies = [('developers', '0041_previous')]    operations = [        migrations.AddField(            model_name='developer',            name='name',            field=models.CharField(max_length=64, null=True),        ),    ]

Миграция 2 — data (отдельной миграцией, не в одном файле!):

def copy_title_to_name(apps, schema_editor):    Developer = apps.get_model('developers', 'Developer')    db_alias = schema_editor.connection.alias    from django.db.models import F    BATCH_SIZE = 5000    last_pk = 0    while True:        # Забираем очередной блок первичных ключей, строго pk > last_pk        chunk = list(            Developer.objects.using(db_alias)            .filter(name__isnull=True, pk__gt=last_pk)            .order_by('pk')            .values_list('pk', flat=True)[:BATCH_SIZE]        )        if not chunk:            break        # Обновляем ровно этот блок        (            Developer.objects.using(db_alias)            .filter(pk__in=chunk)            .update(name=F('title'))        )        # Двигаем курсор        last_pk = chunk[-1]class Migration(migrations.Migration):    atomic = False  # обязательно — батчи коммитятся независимо    dependencies = [('developers', '0042_add_name_field')]    operations = [        migrations.RunPython(            copy_title_to_name,            reverse_code=migrations.RunPython.noop,            elidable=True,  # при squash удалится — это разовая операция        ),    ]

Не собирай PK всей таблицы в Python-список (list(qs.values_list(...))) на десятках миллионов строк легко приводит к огромному потреблению памяти.

Для больших таблиц безопаснее keyset pagination (pk > last_pk) или cursor-based batching.

order_by('pk') + pk__gt=last_pk позволяет стабильно и предсказуемо проходить таблицу небольшими чанками без материализации всего набора строк в памяти Python-процесса.

Для реально огромной таблицы даже пример выше необходимо будет оптимизировать

В коде приложения продолжаем читать title, но при создании/обновлении пишем в оба поля:

def update_developer(developer: Developer, new_title: str) -> None:    developer.title = new_title    developer.name = new_title    developer.save(update_fields=['title', 'name'])

Это позволит старым инстансам читать title, а новым — name. Бэкфилл закроет существующие строки.

Релиз 2 (Migrate): переключаем чтение на name.

После того как релиз 1 деплоится и бэкфилл проходит — в новой версии кода:

class Developer(models.Model):    title = models.CharField(max_length=64)    name = models.CharField(max_length=64, null=True)    @property    def display_name(self):        return self.name  # больше не fallback на title

Пишем в оба поля (на случай отката), читаем только из name.

Релиз 3 (Contract): удаляем title.

В коде убираем title совсем. Делаем name обязательным.

class Developer(models.Model):    name = models.CharField(max_length=64)  # теперь NOT NULL

Миграция:

operations = [    # На этот момент 100% строк имеют name, проверяем CHECK NOT VALID + VALIDATE.    AddConstraintNotValid(...),    ValidateConstraint(...),    migrations.AlterField(        model_name='developer',        name='name',        field=models.CharField(max_length=64),  # null=False    ),    migrations.RemoveField(        model_name='developer',        name='title',    ),]

Да, три релиза вместо одного. Зато 0 минут даунтайма.

Антипаттерн: давайте просто RenameField

Иногда соблазнительно:

operations = [    migrations.RenameField(        model_name='developer',        old_name='title',        new_name='name',    ),]

makemigrations даже спросит: «It looked like you renamed title to name. Is that correct?» — yes. 

Для rolling deploy и distributed environments такой подход опасен без обратной совместимости. В controlled deployment сценариях (blue-green deployRenameField может быть вполне допустим.

PostgreSQL-специфичные операции Django

В django.contrib.postgres.operations лежит небольшой, но критически важный набор операций, который автогенерация Django никогда не предложит сама. Их нужно вставлять руками.

AddIndexConcurrently и RemoveIndexConcurrently

CREATE INDEX без CONCURRENTLY берёт SHARE на таблице — это значит, что INSERT/UPDATE/DELETE встают в очередь. На таблице, в которую активно пишут, такой AddIndex = гарантированный инцидент.

CONCURRENTLY обходит блокировку, читая таблицу в несколько проходов. Цена: индекс создаётся в 2-3 раза дольше и не может выполняться внутри транзакции.

from django.contrib.postgres.operations import (    AddIndexConcurrently, RemoveIndexConcurrently,)from django.db import migrations, modelsclass Migration(migrations.Migration):    atomic = False  # обязательно — CONCURRENTLY вне транзакции    dependencies = [('developers', '0044_some_migration')]    operations = [        AddIndexConcurrently(            model_name='developer',            index=models.Index(                fields=['rating'],                name='developer_rating_idx',            ),        ),    ]

Подвох: если CREATE INDEX CONCURRENTLY упадёт посередине (например, по lock_timeout или из-за дубликата в unique-индексе), индекс останется в БД со статусом INVALID. Он виден в \d table и pg_indexes, но не используется планировщиком. Django об этом ничего не знает.

Лечение — перед повторным накатом проверить и удалить:

-- Найти INVALID-индексы:SELECT indexrelid::regclass AS index_name, indrelid::regclass AS table_nameFROM pg_indexWHERE indisvalid = false;-- Удалить:DROP INDEX CONCURRENTLY IF EXISTS developer_rating_idx;

И после этого перезапустить миграцию.

AddConstraintNotValid + ValidateConstraint

Появились в Django 4.0 для PostgreSQL. Только для CheckConstraint (для FK — руками через RunSQL, как мы делали выше).

Обычный ADD CONSTRAINT ... CHECK блокирует таблицу под ACCESS EXCLUSIVE и сканирует все строки. На 50M строк это надолго.

NOT VALID говорит: не сканируй существующие, проверяй только новые INSERT/UPDATE. Берёт ACCESS EXCLUSIVE, но мгновенно. Потом отдельной операцией валидируем существующие — это идёт под SHARE UPDATE EXCLUSIVE (совместимо с DML).

from django.contrib.postgres.operations import AddConstraintNotValid, ValidateConstraintfrom django.db import migrations, models# Файл 0045_add_rating_constraint_not_valid.pyclass Migration(migrations.Migration):    dependencies = [('developers', '0044_previous')]    operations = [        AddConstraintNotValid(            model_name='developer',            constraint=models.CheckConstraint(                condition=models.Q(rating__gte=0),  # ВАЖНО                name='developer_rating_non_negative',            ),        ),    ]# ОТДЕЛЬНЫЙ файл 0046_validate_rating_constraint.pyclass Migration(migrations.Migration):    atomic = False  # VALIDATE может быть долгим    dependencies = [('developers', '0045_add_rating_constraint_not_valid')]    operations = [        ValidateConstraint(            model_name='developer',            name='developer_rating_non_negative',        ),    ]

Важно про CheckConstraint: в Django 5.1+ параметр стал называться condition вместо check (старое имя deprecated, в 6.0 ещё работает с warning, но в будущем удалят). 

Важно про две миграции: если положить в одну, то транзакция вокруг них (atomic = True по умолчанию) сведёт всю оптимизацию на нет.

UniqueConstraint CONCURRENTLY: лайфхак через SeparateDatabaseAndState

Django не имеет AddConstraintConcurrently. Если нужно навесить UNIQUE на большую таблицу, обычный AddConstraint(UniqueConstraint(...)) создаст индекс под SHARE — а это write-блокировка.

Обход: создать UNIQUE INDEX CONCURRENTLY руками, а Django сказать считай, что constraint у меня есть через SeparateDatabaseAndState:

from django.db import migrations, modelsclass Migration(migrations.Migration):    atomic = False    dependencies = [('developers', '0046_previous')]    operations = [        migrations.SeparateDatabaseAndState(            database_operations=[                migrations.RunSQL(                    sql=(                        'CREATE UNIQUE INDEX CONCURRENTLY "developer_inn_uniq" '                        'ON "developers_developer" ("inn");'                    ),                    reverse_sql=(                        'DROP INDEX CONCURRENTLY IF EXISTS "developer_inn_uniq";'                    ),                ),            ],            state_operations=[                migrations.AddConstraint(                    model_name='developer',                    constraint=models.UniqueConstraint(                        fields=['inn'],                        name='developer_inn_uniq',                    ),                ),            ],        ),    ]

database_operations идут в БД (создаём индекс CONCURRENTLY), state_operations обновляют in-memory представление Django (autodetector думает, что constraint существует и не предлагает создать его снова).

PostgreSQL умеет использовать unique-индекс как backing для constraint поэтому такой подход корректен и с точки зрения семантики.

SeparateDatabaseAndState

SDAS — главный инструмент тонкой работы, когда автогенерация Django делает правильно по семантике, но не подходит по перформансу. Ещё несколько кейсов, где он нужен.

Кейс 1: переход с index_together на Meta.indexes

index_together deprecated в Django 4.2 и удалён в 5.1. Просто перенести в indexes нельзя — Django сгенерирует миграцию, которая пересоздаст индекс: DROP + CREATE. На большой таблице будет плохо.

Хитрость: оставить индекс в БД, но сказать Django, что мы «переименовали» его в state:

# Найти текущее имя индекса в БД:# \d+ developers_developer в psql# Или: SELECT indexname FROM pg_indexes WHERE tablename='developers_developer';# Допустим, было developers_dev_a_b_idx.# В Meta модели:class Meta:    indexes = [        models.Index(fields=['a', 'b'], name='developers_dev_a_b_idx'),    ]# Миграция:operations = [    migrations.SeparateDatabaseAndState(        database_operations=[],  # в БД ничего не делаем        state_operations=[            migrations.AlterIndexTogether(                name='developer',                index_together=set(),            ),            migrations.AddIndex(                model_name='developer',                index=models.Index(                    fields=['a', 'b'],                    name='developers_dev_a_b_idx',                ),            ),        ],    ),]

Главное — указать то же имя, что у физического индекса в БД. Тогда Django считает, что всё в порядке, а реально индекс не трогался.

Кейс 2: переименование колонки через db_column

Альтернатива expand/contract для маленьких таблиц или внутренних рефакторингов: переименовать только в коде Python, оставив физическое имя колонки.

class Developer(models.Model):    # В коде — name, в БД — title    name = models.CharField(max_length=64, db_column='title')

Миграция:

operations = [    migrations.SeparateDatabaseAndState(        database_operations=[],        state_operations=[            migrations.RenameField(                model_name='developer',                old_name='title',                new_name='name',            ),            migrations.AlterField(                model_name='developer',                name='name',                field=models.CharField(max_length=64, db_column='title'),            ),        ],    ),]

Кейс 3: превращение M2M в явную through-модель

Когда нужно к ManyToManyField добавить дополнительные поля (например, created_atcreated_by), приходится переходить на through. Django по умолчанию предложит удалить промежуточную таблицу и создать новую — данные потеряются.

Через SeparateDatabaseAndState мы оставляем таблицу в БД, но говорим Django — вот теперь это твоя through-модель:

# В models.py — определяем through:class Project(models.Model):    developers = models.ManyToManyField(        'developers.Developer',        through='ProjectDeveloper',    )class ProjectDeveloper(models.Model):    project = models.ForeignKey('Project', on_delete=models.CASCADE)    developer = models.ForeignKey(        'developers.Developer', on_delete=models.CASCADE,    )    class Meta:        db_table = 'projects_project_developers'  # имя авто-таблицы M2M

SeparateDatabaseAndState — очень мощный, но опасный инструмент.

Он позволяет «разводить»:

  • реальное состояние БД;

  • migration state Django.

При неаккуратном использовании это приводит к state drift, странным auto-generated migrations и трудноотлавливаемым проблемам в графе миграций.

Чем больше SDAS в проекте тем важнее дисциплина вокруг ревью миграций.

atomic = False

По умолчанию каждая миграция Django оборачивается в транзакцию (BEGIN ... COMMIT). Это безопасно для большинства операций: упала миграция — БД откатилась к исходному состоянию.

Но иногда атомарность не нужна и даже вредит:

  • Операции с CONCURRENTLY вообще запрещены внутри транзакции.

  • Долгий бэкфилл данных в одной транзакции = ROW EXCLUSIVE на куче строк надолго + раздутый WAL.

  • AddConstraintNotValid + VALIDATE хотим выполнить независимо, чтобы VALIDATE мог идти на проде без блокировок.

В таких случаях ставим:

class Migration(migrations.Migration):    atomic = False    # ...

Что происходит при сбое в non-atomic миграции

Тонкий момент. В обычной (atomic) миграции при ошибке Django делает ROLLBACK — БД возвращается в исходное состояние, и запись в django_migrations не добавляется. Можно безопасно перезапустить.

В non-atomic при ошибке:

  1. SQL, выполненные до места ошибки, остаются применёнными в БД (Django выполняет операции отдельными transaction scopes, поэтому часть операций может успеть примениться до места ошибки)

  2. Запись в django_migrations не добавляется — Django не знает, что миграция применена частично.

  3. При повторном запуске Django начнёт миграцию с самого начала и упадёт на первой же операции с column already exists / index already exists.

Как лечить:

Вариант 1: писать идемпотентные SQL руками. Это в первую очередь касается RunSQL:

migrations.RunSQL(    sql='ALTER TABLE foo ADD COLUMN IF NOT EXISTS bar integer;',    reverse_sql='ALTER TABLE foo DROP COLUMN IF EXISTS bar;',),

К сожалению, Django-операции (AddFieldAddIndex) не умеют генерить IF NOT EXISTS — для них этот трюк не работает.

Вариант 2: разбивать миграцию на максимально мелкие шаги. Если упадёт — упадёт на конкретном шаге, и руками легче понять, что сделалось, а что нет.

Вариант 3: ручная очистка перед перезапуском. Зашёл в psql, посмотрел \d table, удалил лишнее, повторил миграцию.

Вариант 4: --fake после ручного применения. Если ты руками докатил все, что нужно, скажи Django «считай, что миграция применена»:

python manage.py migrate developers 0045 --fake

(--fake мы разбирали во второй статье)

Batch updates: data-миграции на больших таблицах

Один UPDATE на 50M строк это:

  • ROW EXCLUSIVE на куче строк надолго;

  • гигабайт WAL — реплики залагают;

  • если в atomic = True = одна гигантская транзакция, увеличение размера shared buffers, autovacuum не может работать;

  • невозможно прервать без отката.

PostgreSQL 17 содержит ряд заметных улучшений вокруг vacuum/WAL и обработке конкурентной нагрузки, но конкретный выигрыш сильно зависит от этой самой нагрузки и конфигурации системы. То есть это не значит, что можно теперь не думать про батчи — это значит, что последствия твоих ошибок проще пережить. Но писать всё равно надо нормально.

Лечение — батчи с atomic = False:

from django.db import migrationsdef backfill_status(apps, schema_editor):    Developer = apps.get_model('developers', 'Developer')    db_alias = schema_editor.connection.alias    BATCH_SIZE = 10_000    qs = (        Developer.objects.using(db_alias)        .filter(status__isnull=True)    )    last_pk = 0    while True:        batch_qs = (            qs.filter(pk__gt=last_pk)               .order_by('pk')        )        # берём только границу диапазона        batch = list(            batch_qs.values_list('pk', flat=True)[:BATCH_SIZE]        )        if not batch:            break        start = batch[0]        end = batch[-1]        Developer.objects.using(db_alias).filter(            pk__gte=start,            pk__lte=end,            status__isnull=True        ).update(status='active')        last_pk = endclass Migration(migrations.Migration):    atomic = False    dependencies = [('developers', '0050_add_status_field')]    operations = [        migrations.RunPython(            backfill_status,            reverse_code=migrations.RunPython.noop,            elidable=True,        ),    ]

Несколько критичных моментов в этом коде:

  1. apps.get_model(...), а не прямой импорт модели. Мы это обсуждали во второй статье. Импортированная модель — это «текущая» версия из models.py, в которой могут быть поля, ещё не существующие в БД. apps.get_model возвращает «историческую» модель — ровно такую, какой она была на момент этой миграции.

  2. using(db_alias). На случай multi-db schema_editor.connection.alias отдаст нужное имя БД. Если забыть — update()пойдёт в default-базу.

  3. reverse_code=migrations.RunPython.noop. Хорошо бы написать обратную функцию, но в случае бэкфилла это часто бессмысленно (исходного состояния «без статуса» больше не существует логически). noop говорит Django: «можешь откатить, никаких действий не нужно».

  4. elidable=True. Бэкфилл — разовая операция. При squashmigrations Django удалит её из объединённой миграции.

  5. atomic = False на уровне Migration. Без этого каждый .update() всё равно был бы внутри общей транзакции миграции — то есть мы бы не получили никакого выигрыша.

Когда лучше команда, а не миграция

Для очень больших бэкфиллов (>10M строк, часы выполнения) data-миграция — плохой выбор:

  • Миграция блокирует выкатку. Если бэкфилл идёт 6 часов, релизы стоят.

  • Миграцию нельзя поставить на паузу, перезапустить, мониторить отдельно.

  • Если разработчик пропустит её локально (migrate идёт слишком долго)

Альтернатива — BaseCommand:

# developers/management/commands/backfill_developer_status.pyfrom django.core.management.base import BaseCommandfrom django.db import transactionfrom developers.models import Developerclass Command(BaseCommand):    help = 'Backfill Developer.status'    def add_arguments(self, parser):        parser.add_argument('--batch-size', type=int, default=5000)        parser.add_argument('--sleep', type=float, default=0.0)    def handle(self, *args, batch_size, sleep, **options):        import time        qs = Developer.objects.filter(status__isnull=True)        total = qs.count()        self.stdout.write(f'To backfill: {total}')        done = 0        while True:            pks = list(                qs.order_by('pk').values_list('pk', flat=True)[:batch_size]            )            if not pks:                break            with transaction.atomic():                Developer.objects.filter(pk__in=pks).update(status='active')            done += len(pks)            self.stdout.write(f'Done: {done}/{total}')            if sleep:                time.sleep(sleep)

Запускаем:

python manage.py backfill_developer_status --batch-size 2000 --sleep 0.5

Плюсы команды:

  • запускаешь руками, когда нагрузка ниже;

  • можешь прервать Ctrl+C и продолжить позже (она идемпотентна — берёт только строки с status IS NULL);

  • параметры (batch_size, sleep) на лету;

  • легко мониторить — отдельный процесс, отдельный лог.

Минус: нужно не забыть запустить руками.

NOT NULL без даунтайма: классический сценарий

Во второй статье мы разбирали, как пройти ошибку «You are trying to add a non-nullable field» при makemigrations. Сейчас тот же сценарий в production-разрезе.

Задача: у нас есть поле Developer.status, которое сейчас nullable, нужно сделать обязательным.

Наивный путь:

operations = [    migrations.AlterField(        model_name='developer',        name='status',        field=models.CharField(max_length=16),  # null=False    ),]

Что Django сгенерит:

ALTER TABLE "developers_developer" ALTER COLUMN "status" SET NOT NULL;

Эта операция берёт ACCESS EXCLUSIVE и сканирует всю таблицу для проверки, что нет NULL. На 50M строк = минуты простоя.

Безопасный путь — 4 шага:

Шаг 1: миграция — добавить CHECK (status IS NOT NULL) NOT VALID.

from django.contrib.postgres.operations import AddConstraintNotValidfrom django.db import migrations, modelsclass Migration(migrations.Migration):    dependencies = [('developers', '0050_previous')]    operations = [        AddConstraintNotValid(            model_name='developer',            constraint=models.CheckConstraint(                condition=models.Q(status__isnull=False),                name='developer_status_not_null',            ),        ),    ]

После этой миграции новые строки уже не могут вставить NULL в status. Существующие — пока могут быть NULL, мы их не трогаем.

Шаг 2: код приложения пишет в status для всех новых записей.

Деплоим релиз. Теперь поток новых записей чист.

Шаг 3: data-миграция — бэкфиллим существующие строки.

def backfill_status(apps, schema_editor):    # Как реализовано ранееclass Migration(migrations.Migration):    atomic = False    dependencies = [('developers', '0051_check_status_not_null')]    operations = [        migrations.RunPython(            backfill_status,            reverse_code=migrations.RunPython.noop,            elidable=True,        ),    ]

Шаг 4: миграция — VALIDATE CONSTRAINT + AlterField.

from django.contrib.postgres.operations import ValidateConstraintfrom django.db import migrations, modelsclass Migration(migrations.Migration):    atomic = False  # VALIDATE может быть долгим    dependencies = [('developers', '0052_backfill_status')]    operations = [        # 1. Валидируем CHECK — SHARE UPDATE EXCLUSIVE, совместимо с DML.        ValidateConstraint(            model_name='developer',            name='developer_status_not_null',        ),        # 2. Меняем колонку на NOT NULL.        # На PG 12+ при наличии валидного CHECK NOT NULL операция мгновенна:        # PG использует существующий CHECK как доказательство.        migrations.AlterField(            model_name='developer',            name='status',            field=models.CharField(max_length=16),        ),        # 3. CHECK больше не нужен (его роль теперь у NOT NULL constraint).        migrations.RemoveConstraint(            model_name='developer',            name='developer_status_not_null',        ),    ]

Django по умолчанию не сгенерит такую последовательность сам. makemigrations для смены null=True → null=Falseсоздаст обычный AlterField. Шаги 1, 3, 4 нужно писать руками; шаг 2 — обычная code-only data-миграция.

Тестирование миграций

Data-миграции — это код. Код должен иметь тесты. Без тестов миграция, отработавшая на 12 строках на dev, может рухнуть на 100M строк на проде.

Пакет django-test-migrations от wemake-services даёт удобный API:

from django_test_migrations.migrator import Migratordef test_backfill_status_fills_existing_rows(transactional_db):    migrator = Migrator(database='default')    # 1. Применяем миграции до состояния "ДО" нашей data-миграции.    old_state = migrator.apply_initial_migration(        ('developers', '0050_add_status_field'),    )    Developer = old_state.apps.get_model('developers', 'Developer')    # 2. Создаём данные — как они выглядели до бэкфилла.    Developer.objects.create(title='Alice', status=None)    Developer.objects.create(title='Bob', status=None)    Developer.objects.create(title='Charlie', status='admin')    # 3. Применяем нашу data-миграцию.    new_state = migrator.apply_tested_migration(        ('developers', '0052_backfill_status'),    )    Developer = new_state.apps.get_model('developers', 'Developer')    # 4. Проверяем результат.    assert Developer.objects.filter(status='unknown').count() == 2    assert Developer.objects.filter(status='admin').count() == 1    assert Developer.objects.filter(status__isnull=True).count() == 0    migrator.reset()

Что полезного:

  • Тест действительно прогоняет миграцию против БД, а не моки.

  • Может тестировать reverse: применил A — создал данные — откатился до B — проверил, что данные адекватны.

  • Интегрируется с pytest-django (фикстура transactional_db).

CI-минимум для миграций (чек-лист)

В каждом PR:

  1. python manage.py makemigrations --check --dry-run — есть ли несгенерированные миграции в коде? Если разработчик поменял модели, но не запустил makemigrations, миграции в проде не будет.

  2. python manage.py migrate --plan — что именно поедет.

  3. Тесты на data-миграции (django-test-migrations).

  4. (Опционально, для крупных проектов) — python manage.py sqlmigrate <app> <migration> в артефакт CI: ревьюверам удобно сразу увидеть SQL без поднятия локального окружения.

Чек-лист перед накатом миграции на прод

Бумажка над монитором. Перед каждой production-миграцией:

  1. Запустил sqlmigrate, прочитал SQL глазами. Не смог понять глазами — прогнал нейронкой.

  2. Понимаю, какую блокировку возьмёт PostgreSQL для каждой операции.

  3. Если ACCESS EXCLUSIVE на большой таблице — миграция разделена на безопасные шаги (NOT VALID + VALIDATE, CONCURRENTLY, expand/contract).

  4. Долгие операции в отдельной миграции с atomic = False.

  5. Если есть сомнения — подумать над lock_timeout и statement_timeout

  6. Изменения обратно-совместимы: старый код приложения корректно работает с новой схемой.

  7. Есть план отката: если миграция сломала прод, что мы делаем? (Откатить миграцию? Откатить код? И то и другое?)

  8. Есть бэкап актуальной БД, или это slave с актуальным lag.

  9. На staging миграция прогналась против дампа prod-данных или их объёма.

  10. Время накатывания — не пятница вечер. Не «вот сейчас релиз, и сразу миграция в пиковый трафик».

Если хоть один пункт не закрыт — лучше переложить накат или помолиться.

Экстра

Несколько подводных камней, которые не вписались в основное повествование, но регулярно стреляют:

  • FK с CASCADEON DELETE CASCADE сам по себе не блокирует. Но DELETE родительской строки берёт ROW EXCLUSIVE на дочерних — на больших таблицах это медленно. 

  • Изменение choices в Django обычно приводит к AlterField миграции, даже если схема БД фактически не меняется. На PostgreSQL лишние ALTER TABLE могут брать сильные блокировки, поэтому для часто меняющихся choices лучше использовать callable choices (Django 5.0+) либо state-only миграции через SeparateDatabaseAndState.

  • max_length для varchar. Расширение мгновенно. Сужение — REWRITE. 

  • AlterField для default. Изменение default=... в Python-коде модели не приводит к изменению default в БД — Django применяет default при INSERT на уровне Python. Но makemigrations всё равно сгенерит AlterField. Это no-op для БД, но лишний ALTER TABLE (мгновенный, но ACCESS EXCLUSIVE).

  • Кейс с migrate --fake после non-atomic краха. Если ты руками докатил часть SQL, помни: --fake фиксирует запись в django_migrations, но не проверяет, действительно ли применены все операции. Ответственность за консистентность — на тебе. Лучше написать checklist в комментарии PR: «применил руками: ALTER TABLE X, CREATE INDEX Y; запускаю migrate —fake».

  • Reverse data-миграций. По умолчанию ставим reverse_code=migrations.RunPython.noop — на reverse ничего не происходит. Это нормально для бэкфиллов: смысла откатывать данные нет. Для обратимых преобразований (например, миграции enum) — пишим явный reverse. Не оставляй RunPython без второго аргумента: Django выдаст ошибку при попытке откатить.

Заключение

Главные правила, которые стоит унести с собой:

  • Перед migrate — sqlmigrate. Всегда. На любой миграции в любой ветке. Это бесплатно и спасает от 90% инцидентов.

  • Малые шаги, отдельные миграции. В идеале одна миграция — одна логическая операция. NOT NULL — это четыре миграции, а не одна. Да, миграций станет много, но их можно будет сквошнуть.

  • Expand — Migrate — Contract. Любое значимое изменение схемы — это минимум три релиза. Старый и новый код должны уметь работать с одной и той же БД.

  • Автогенерация Django — стартовая точка, не финальная. На больших таблицах её надо править: SeparateDatabaseAndStateAddIndexConcurrentlyAddConstraintNotValidatomic = False.

  • Инструменты-минимумsqlmigrate + тесты data-миграций.

  • Не забываем про существование  lock_timeout + statement_timeout

Миграции, которые катятся под нагрузкой — это инженерная задача, а не одна команда. Чем раньше команда осознает это, тем меньше инцидентов будет.

Буду рад фидбеку и кейсам из вашей практики в комментариях. А также замечаниям и исправлениям неточностей, который, вероятно, я мог допустить 🙂

Полезные ссылки

Документация PostgreSQL:

Пакеты:

Статьи на тему:

Предыдущие части серии:

ссылка на оригинал статьи https://habr.com/ru/articles/1035830/