Нулевой простой при миграциях схемы в Django: как это устроено внутри
ALTER TABLE на таблице с десятью миллионами строк в PostgreSQL блокирует запись на секунды, а иногда — на минуты. Разбираем, почему стандартный manage.py migrate кладёт продакшен и как менять схему без даунтайма, не выходя за пределы Django ORM.

Почему обычный migrate создаёт проблему
Django генерирует миграции на основе diff между текущим состоянием моделей и предыдущим. Результат — файл с набором операций: AddField, AlterField, RemoveField, RenameField. Каждая из них транслируется в DDL-запрос к базе. В PostgreSQL (рассматриваем его, как наиболее частый бэкенд для Django в проде) многие DDL-операции берут ACCESS EXCLUSIVE блокировку на таблицу. Это самая «жадная» блокировка: она несовместима ни с SELECT, ни с INSERT, ни с другими DDL. Пока блокировка удерживается, любой запрос к таблице встаёт в очередь.
На таблице с тысячей строк вы этого не заметите — операция занимает миллисекунды. На таблице с десятью миллионами строк ALTER TABLE ... ADD COLUMN ... DEFAULT ... в PostgreSQL до версии 11 переписывал все строки: минуты блокировки, рост WAL, рост нагрузки на диск. С PostgreSQL 11+ ситуация лучше — ADD COLUMN с неволатильным DEFAULT не переписывает строки, но многие другие операции по-прежнему берут тяжёлые блокировки.
Какие операции опасны, а какие нет
Не каждый ALTER TABLE одинаково вреден. Степень опасности зависит от типа блокировки, от необходимости перезаписи строк и от длительности удержания блокировки.
| Операция Django | DDL в PostgreSQL | Блокировка | Перезапись строк (PG 16) | Риск |
|---|---|---|---|---|
AddField (nullable, без default) | ADD COLUMN ... NULL | ACCESS EXCLUSIVE, но мгновенная | Нет | Низкий |
AddField (non-null, с default) | ADD COLUMN ... DEFAULT ... NOT NULL | ACCESS EXCLUSIVE, мгновенная (PG 11+) | Нет (неволатильный default) | Низкий* |
AlterField (смена типа, напр. varchar → text) | ALTER COLUMN ... TYPE text | ACCESS EXCLUSIVE | Зависит от преобразования | Средний–высокий |
AddIndex / AddConstraint | CREATE INDEX | SHARE (блокирует запись) | — | Высокий на больших таблицах |
RemoveField | DROP COLUMN | ACCESS EXCLUSIVE, мгновенная | Нет | Низкий для DDL, но код может упасть |
RenameField | ALTER COLUMN ... RENAME | ACCESS EXCLUSIVE, мгновенная | Нет | Низкий для DDL, критический для кода |
* Волатильная функция в default (например, now() в некоторых контекстах) всё ещё вызывает перезапись строк. Django по умолчанию не ставит DEFAULT на уровне базы — он подставляет значение на уровне Python. Это значит, что миграция сначала добавит nullable-колонку, потом выставит default через UPDATE, потом поставит NOT NULL. Каждый шаг — отдельная блокировка, а UPDATE всех строк — долгая операция сама по себе.
Главный подводный камень: очередь блокировок
Даже если ALTER TABLE выполняется мгновенно, он должен получить ACCESS EXCLUSIVE блокировку. Если в момент запроса блокировки открыта длинная транзакция (аналитический SELECT, зависшая транзакция, незакрытый курсор в Django ORM), ALTER TABLE встаёт в очередь. Все последующие запросы — включая короткие SELECT — выстраиваются за ним. Одна забытая транзакция превращает мгновенную миграцию в минутный простой.
Блокировка в PostgreSQL — очередь с приоритетами. ACCESS EXCLUSIVE в очереди — пробка, в которой встаёт весь трафик, пока впереди не уедет последняя «длинная» транзакция.
Стратегия: разделяй DDL и данные
Ключевой принцип zero-downtime миграции — никогда не делать в одной транзакции и изменение схемы, и массовое изменение данных. Django по умолчанию оборачивает каждую миграцию в транзакцию (для PostgreSQL). Это удобно для отката, но губительно для больших таблиц: транзакция держит блокировку, пока не завершится весь файл миграции.
Шаг 1. Добавить колонку как nullable
Добавление nullable-колонки без default — самая лёгкая операция. Блокировка берётся и отпускается за миллисекунды. Приложение продолжает работать, просто не знает о новой колонке (и это нормально, если старый код не обращается к ней напрямую).
Шаг 2. Развернуть код, который пишет в новую колонку
Новая версия приложения пишет данные и в старую, и в новую колонку. Чтение — из старой. Это «переходная» версия, она должна быть совместима и со старой, и с новой схемой.
Шаг 3. Backfill данных
Массовое заполнение новой колонки значениями из старой. Выполняется батчами, вне транзакции миграции. Django позволяет это через RunPython с atomic=False на уровне миграции или через management-команду.
# Пример backfill батчами через RunPython (Django 4.2+/5.x)
# Файл миграции: 0042_backfill_new_status.py
from django.db import migrations
def backfill_status(apps, schema_editor):
Order = apps.get_model("orders", "Order")
batch_size = 5000
total_updated = 0
while True:
# Берём пачку строк, где новая колонка ещё пуста
ids = list(
Order.objects.filter(status_v2__isnull=True)
.values_list("pk", flat=True)[:batch_size]
)
if not ids:
break
# UPDATE батчем; каждый UPDATE — отдельная транзакция
Order.objects.filter(pk__in=ids).update(status_v2=models.F("status"))
total_updated += len(ids)
class Migration(migrations.Migration):
atomic = False # критически важно: без этого весь backfill — одна транзакция
dependencies = [
("orders", "0041_add_status_v2_nullable"),
]
operations = [
migrations.RunPython(backfill_status, migrations.RunPython.noop),
]
Обратите внимание на atomic = False в классе миграции. Без него Django обернёт всё в одну транзакцию, и вы получите ту же длинную блокировку, от которой пытаетесь уйти.
Шаг 4. Переключить чтение на новую колонку
Ещё один деплой. Теперь приложение читает из новой колонки. Запись — по-прежнему в обе (на случай отката).
Шаг 5. Поставить NOT NULL и убрать старую колонку
Финальная миграция: ALTER COLUMN SET NOT NULL, удаление старой колонки. В PostgreSQL 12+ SET NOT NULL с существующим CHECK-constraint валидируется без full table scan, если constraint уже VALID. Django не создаёт такой constraint автоматически — придётся добавить AddConstraint с NOT VALID, затем отдельно VALIDATE CONSTRAINT (через RunSQL), и только потом AlterField на NOT NULL.
Индексы: CONCURRENTLY или страдание
Создание индекса через CREATE INDEX берёт SHARE-блокировку — запись в таблицу заблокирована на всё время построения. На таблице с миллионами строк это минуты.
PostgreSQL поддерживает CREATE INDEX CONCURRENTLY, который не блокирует запись. Django умеет генерировать такой запрос через AddIndex с параметром concurrently=True (доступен через AddIndexConcurrently из django.contrib.postgres.operations). Но есть нюанс: concurrent-операции нельзя выполнять внутри транзакции. Миграция, содержащая AddIndexConcurrently, должна иметь atomic = False.
# Миграция с конкурентным индексом (Django 4.2+/5.x, PostgreSQL)
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
class Migration(migrations.Migration):
atomic = False
dependencies = [
("orders", "0043_backfill_done"),
]
operations = [
AddIndexConcurrently(
model_name="order",
index=models.Index(fields=["status_v2"], name="idx_order_status_v2"),
),
]
Если CREATE INDEX CONCURRENTLY падает (например, из-за дубликата ключа в unique-индексе), индекс остаётся в состоянии INVALID. PostgreSQL его не удалит автоматически. Проверяйте после миграции:
SELECT indexrelid::regclass, indisvalid
FROM pg_index
WHERE NOT indisvalid;
Инструменты, которые ловят ошибки до прода
Разбивать миграции вручную — утомительно и подвержено человеческим ошибкам. Существуют линтеры, которые анализируют сгенерированные миграции и предупреждают об опасных операциях.
- django-pg-zero-downtime-migrations — подменяет бэкенд миграций, добавляя
lock_timeoutиstatement_timeoutк DDL-запросам. Если блокировка не получена за N миллисекунд, операция падает, а не висит бесконечно. - django-migration-linter — статический анализ файлов миграций. Предупреждает о
NOT NULLбез default, об обычных (не concurrent) индексах на больших таблицах, оRunPythonбезatomic=False. - squawkhq/squawk — линтер SQL, не привязанный к Django. Проверяет сырой DDL; удобно запускать в CI после
sqlmigrate.
Ни один из этих инструментов не решает проблему целиком. Они ловят симптомы — опасные DDL-паттерны. Стратегию «разделяй DDL и данные» нужно закладывать в процесс разработки.
Где подход ломается
Zero-downtime миграции — не бесплатный обед. Вот цена:
- Количество деплоев. Переименование одной колонки превращается в четыре-пять миграций и три деплоя. Для команды из двух человек с еженедельными релизами это может быть неприемлемо.
- Код в переходном состоянии. Между шагами 2 и 5 в кодовой базе живут две версии логики. Если забыть убрать старую — она останется навсегда.
- Backfill на репликах. Батчевый
UPDATEгенерирует WAL. На синхронной репликации это дополнительная нагрузка. На асинхронной — рост лага. - Не PostgreSQL. MySQL (InnoDB) ведёт себя иначе:
ALTER TABLEсALGORITHM=INPLACEне блокирует чтение и запись для многих операций, ноCOPY-алгоритм блокирует. Django не различает эти алгоритмы — нужно писатьRunSQLвручную или использовать pt-online-schema-change / gh-ost. - SQLite. В тестах Django часто использует SQLite, где DDL-семантика совершенно другая. Миграция, безопасная на PostgreSQL, может вести себя иначе на SQLite. Это не проблема прода, но причина ложного спокойствия в CI.
Сноска: DDL (Data Definition Language) — подмножество SQL, отвечающее за изменение структуры базы: CREATE, ALTER, DROP. В отличие от DML (SELECT, INSERT, UPDATE), DDL-операции часто требуют эксклюзивных блокировок.
Что делать дальше
Начните с аудита текущих миграций. Запустите python manage.py sqlmigrate app_name migration_name для каждой непримёненой миграции и посмотрите, какой DDL уходит в базу. Подключите django-migration-linter в CI — он отловит самые грубые промахи без усилий. Установите lock_timeout на уровне базы или сессии для пользователя миграций — пусть лучше миграция упадёт, чем повесит весь трафик. И заведите правило: любая миграция на таблице больше миллиона строк проходит ревью отдельно от кода.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: PostgreSQL Documentation: ALTER TABLE — Locking and Performance Notes, Django Documentation: Migration Operations (AddIndexConcurrently, RunPython, atomic)
Частые вопросы
Обязательно ли разбивать каждую миграцию на несколько шагов?
Что будет, если backfill упадёт на середине при atomic=False?
Работает ли AddIndexConcurrently с MySQL?
Как убедиться, что в момент миграции нет длинных транзакций?
Можно ли откатить zero-downtime миграцию?
У меня другая версия PostgreSQL (9.6 / 10). Что изменится?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.