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

Страницы:  1

Ответить
 

Professor Seleznov


Всем привет! Меня зовут Макс, я Lead Backend и автор YouTube-канала PyLounge
Это третья часть мини-серии о Django-миграциях. В первой части мы готовились к миграциям и разбирались с конфликтами, во второй чинили типичные подводные камни. Если их не читали, то рекомендую начать именно с них, а затем вернуться сюда.
В этом же материале поговорим о самом интересном: что происходит, когда pythonmanage.pymigrate запускается в 17:30 в пятницу на проде, под 3k RPS и таблицей в 200 миллионов строк. 
Расскажу какие блокировки в PostgreSQL берёт каждая операция Django, что внутри atomic = False, как пишется правильный паттерн expand - migrate - contract, зачем нужны AddIndexConcurrentlyAddConstraintNotValidSeparateDatabaseAndState и как обновлять данные на больших таблицах.
P.S. примеры намеренно упрощены, чтобы влезли в статью и не задушили. В реальной жизни всё ещё хуже - но шаги те же.
P.S.S. При подготовки этого материала ни одна продовая база данных не пострадала. 
pic
Почему migrate в проде это не "просто одна команда"
У миграции есть три стула слоя, каждый из которых потенциально может привести к падению прода:
  • Сгенерированный 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 операция.
  • Блокировки. PostgreSQL может блокировать таблицу так, что не пройдет даже SELECT. Очереди блокировок в PostgreSQL - это FIFO. То есть твоя миграция ждет долгую транзакцию пять минут, а за ней молча стоят ещё 200 запросов от пользователей. Никто не отвечает. Прод R.I.P.
  • 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.
  • ROW SHARE - SELECT FOR UPDATE/SHARE. Используется, когда запрос собирается блокировать строки; чуть строже обычного чтения.
  • ROW EXCLUSIVE - INSERTUPDATEDELETE. Это стандартная DML-нагрузка: изменения данных разрешены параллельно, пока нет «тяжёлого» DDL. То есть обычные DML-операции не мешают друг другу, но серьёзные изменения структуры таблицы могут остановить или заблокировать их (REINDEXALTER TABLE ... TYPEALTER TABLE ... ADD COLUMN и т.д.).
  • SHARE UPDATE EXCLUSIVE - VACUUM (без FULL), ANALYZECREATE INDEX CONCURRENTLYALTER TABLE VALIDATE CONSTRAINTREINDEX CONCURRENTLY. Нужна для риалтайм-операций обслуживания: таблицу можно продолжать читать и менять.
  • SHARE - CREATE INDEX (без CONCURRENTLY). Разрешает чтение, но блокирует INSERT/UPDATE/DELETE, потому что индекс строится в одном консистентном состоянии.
  • SHARE ROW EXCLUSIVE - CREATE TRIGGER, некоторые ALTER TABLE. Более жёсткий DDL-режим: PostgreSQL защищает структуру таблицы от параллельных изменений.
  • EXCLUSIVE - REFRESH MATERIALIZED VIEW CONCURRENTLY. Почти полная блокировка: читать можно, но любые изменения данных запрещены.
  • 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.py
from django.db import migrations
class 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),
),
Лечение - разделить на этапы:
  • AddField(null=True) - без default.
  • RunPython(backfill_uuid) чанками с atomic=False.
  • 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+ релизов:
  • Expand. Расширяем схему так, чтобы старый код продолжал работать (новые поля nullable, новые таблицы не используются, старые поля остаются).
  • Migrate. Переводим логику и данные на новую схему. Обычно - несколько подэтапов с код-релизами между ними.
  • 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.py
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 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, models
class 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_name
FROM pg_index
WHERE 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, ValidateConstraint
from django.db import migrations, models
# Файл 0045_add_rating_constraint_not_valid.py
class 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.py
class 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, models
class 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 при ошибке:
  • SQL, выполненные до места ошибки, остаются применёнными в БД (Django выполняет операции отдельными transaction scopes, поэтому часть операций может успеть примениться до места ошибки)
  • Запись в django_migrations не добавляется - Django не знает, что миграция применена частично.
  • При повторном запуске 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 migrations
def 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 = end
class Migration(migrations.Migration):
atomic = False
dependencies = [('developers', '0050_add_status_field')]
operations = [
migrations.RunPython(
backfill_status,
reverse_code=migrations.RunPython.noop,
elidable=True,
),
]
Несколько критичных моментов в этом коде:
  • apps.get_model(...), а не прямой импорт модели. Мы это обсуждали во второй статье. Импортированная модель - это "текущая" версия из models.py, в которой могут быть поля, ещё не существующие в БД. apps.get_model возвращает "историческую" модель - ровно такую, какой она была на момент этой миграции.
  • using(db_alias). На случай multi-db schema_editor.connection.alias отдаст нужное имя БД. Если забыть - update()пойдёт в default-базу.
  • reverse_code=migrations.RunPython.noop. Хорошо бы написать обратную функцию, но в случае бэкфилла это часто бессмысленно (исходного состояния "без статуса" больше не существует логически). noop говорит Django: "можешь откатить, никаких действий не нужно".
  • elidable=True. Бэкфилл - разовая операция. При squashmigrations Django удалит её из объединённой миграции.
  • atomic = False на уровне Migration. Без этого каждый .update() всё равно был бы внутри общей транзакции миграции - то есть мы бы не получили никакого выигрыша.
Когда лучше команда, а не миграция
Для очень больших бэкфиллов (>10M строк, часы выполнения) data-миграция - плохой выбор:
  • Миграция блокирует выкатку. Если бэкфилл идёт 6 часов, релизы стоят.
  • Миграцию нельзя поставить на паузу, перезапустить, мониторить отдельно.
  • Если разработчик пропустит её локально (migrate идёт слишком долго)
Альтернатива - BaseCommand:
# developers/management/commands/backfill_developer_status.py
from django.core.management.base import BaseCommand
from django.db import transaction
from developers.models import Developer
class 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 AddConstraintNotValid
from django.db import migrations, models
class 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 ValidateConstraint
from django.db import migrations, models
class 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 Migrator
def 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:
  • pythonmanage.pymakemigrations --check --dry-run - есть ли несгенерированные миграции в коде? Если разработчик поменял модели, но не запустил makemigrations, миграции в проде не будет.
  • pythonmanage.pymigrate --plan - что именно поедет.
  • Тесты на data-миграции (django-test-migrations).
  • (Опционально, для крупных проектов) - pythonmanage.pysqlmigrate <app> <migration> в артефакт CI: ревьюверам удобно сразу увидеть SQL без поднятия локального окружения.
Чек-лист перед накатом миграции на прод
Бумажка над монитором. Перед каждой production-миграцией:
  • Запустил sqlmigrate, прочитал SQL глазами. Не смог понять глазами - прогнал нейронкой.
  • Понимаю, какую блокировку возьмёт PostgreSQL для каждой операции.
  • Если ACCESS EXCLUSIVE на большой таблице - миграция разделена на безопасные шаги (NOT VALID + VALIDATE, CONCURRENTLY, expand/contract).
  • Долгие операции в отдельной миграции с atomic = False.
  • Если есть сомнения - подумать над lock_timeout и statement_timeout
  • Изменения обратно-совместимы: старый код приложения корректно работает с новой схемой.
  • Есть план отката: если миграция сломала прод, что мы делаем? (Откатить миграцию? Откатить код? И то и другое?)
  • Есть бэкап актуальной БД, или это slave с актуальным lag.
  • На staging миграция прогналась против дампа prod-данных или их объёма.
  • Время накатывания - не пятница вечер. Не "вот сейчас релиз, и сразу миграция в пиковый трафик".
Если хоть один пункт не закрыт - лучше переложить накат или помолиться.
Экстра
Несколько подводных камней, которые не вписались в основное повествование, но регулярно стреляют:
  • 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: Пакеты: Статьи на тему: Предыдущие части серии: -Источник
 
Loading...
Error