|
Professor Seleznov
|
Всем привет! Меня зовут Макс, я Lead Backend и автор YouTube-канала PyLounge. Это третья часть мини-серии о Django-миграциях. В первой части мы готовились к миграциям и разбирались с конфликтами, во второй чинили типичные подводные камни. Если их не читали, то рекомендую начать именно с них, а затем вернуться сюда. В этом же материале поговорим о самом интересном: что происходит, когда pythonmanage.pymigrate запускается в 17:30 в пятницу на проде, под 3k RPS и таблицей в 200 миллионов строк. Расскажу какие блокировки в PostgreSQL берёт каждая операция Django, что внутри atomic = False, как пишется правильный паттерн expand - migrate - contract, зачем нужны AddIndexConcurrently, AddConstraintNotValid, SeparateDatabaseAndState и как обновлять данные на больших таблицах. P.S. примеры намеренно упрощены, чтобы влезли в статью и не задушили. В реальной жизни всё ещё хуже - но шаги те же. P.S.S. При подготовки этого материала ни одна продовая база данных не пострадала.
 Почему migrate в проде это не "просто одна команда" У миграции есть три стула слоя, каждый из которых потенциально может привести к падению прода:
- Сгенерированный SQL. Иногда не такой, который ты ожидал. Например, AlterField(max_length=64)для CharField(max_length=32) - это ALTER TABLE ... ALTER COLUMN TYPE varchar(64), и, да, на PostgreSQL это будет очень быстро.
А вот некоторые изменения типа действительно приводят к переписыванию всей таблицы (table rewrite), например, text -> integer, varchar -> numeric, json -> 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 - INSERT, UPDATE, DELETE. Это стандартная DML-нагрузка: изменения данных разрешены параллельно, пока нет «тяжёлого» DDL. То есть обычные DML-операции не мешают друг другу, но серьёзные изменения структуры таблицы могут остановить или заблокировать их (REINDEX, ALTER TABLE ... TYPE, ALTER TABLE ... ADD COLUMN и т.д.).
- SHARE UPDATE EXCLUSIVE - VACUUM (без FULL), ANALYZE, CREATE INDEX CONCURRENTLY, ALTER TABLE VALIDATE CONSTRAINT, REINDEX 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 TABLE, TRUNCATE, большинство ALTER TABLE, REINDEX. Самая сильная блокировка: останавливает вообще всё, включая обычный 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 TABLE, TRUNCATE, CLUSTER, VACUUM 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 deploy) RenameField может быть вполне допустим. 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_at, created_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-операции (AddField, AddIndex) не умеют генерить 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 с CASCADE. ON 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 - стартовая точка, не финальная. На больших таблицах её надо править: SeparateDatabaseAndState, AddIndexConcurrently, AddConstraintNotValid, atomic = False.
- Инструменты-минимум: sqlmigrate + тесты data-миграций.
- Не забываем про существование lock_timeout + statement_timeout
Миграции, которые катятся под нагрузкой - это инженерная задача, а не одна команда. Чем раньше команда осознает это, тем меньше инцидентов будет. Буду рад фидбеку и кейсам из вашей практики в комментариях. А также замечаниям и исправлениям неточностей, который, вероятно, я мог допустить  Полезные ссылки Документация PostgreSQL:
Пакеты:
Статьи на тему:
Предыдущие части серии:
-Источник
|