Миграции без простоя в Django: что ломается и как этого избежать
ALTER TABLE на таблице с десятками миллионов строк блокирует запросы на минуты, а пользователи видят 502. Разбираем, какие миграции Django безопасны под нагрузкой, какие — нет, и как переписать опасные операции так, чтобы деплой не требовал окна обслуживания.

Почему обычный migrate превращается в даунтайм
Django генерирует миграции через makemigrations, а потом выполняет их через migrate. Для небольшой таблицы это занимает миллисекунды. Но если в таблице 20–50 миллионов строк, один ALTER TABLE ... ADD COLUMN ... DEFAULT ... в PostgreSQL (до версии 11) захватывает блокировку ACCESS EXCLUSIVE и переписывает всю таблицу. Пока идёт переписывание, ни один SELECT не пройдёт. В итоге — очередь заблокированных соединений, таймауты на стороне gunicorn/uvicorn и каскад 502 на балансировщике.
Начиная с PostgreSQL 11 добавление колонки с неизменяемым DEFAULT не переписывает таблицу — СУБД просто сохраняет значение по умолчанию в метаданных. Но Django генерирует миграции, которые сначала создают колонку NULL, а потом отдельным RunSQL или RunPython заполняют значения, — и именно фаза заполнения может оказаться тяжёлой. Кроме того, далеко не каждая миграция — это ADD COLUMN. Переименование колонки, смена типа, создание индекса — всё это разные уровни боли.
Какие операции безопасны, а какие — нет
Ниже — таблица для PostgreSQL 14+. Для MySQL/MariaDB поведение блокировок заметно отличается; если вы на MySQL, проверяйте поведение по документации вашей версии.
| Операция | Блокировка | Переписывает таблицу | Безопасна без простоя |
|---|---|---|---|
| ADD COLUMN nullable, без DEFAULT | ACCESS EXCLUSIVE (кратковременно) | Нет | Да |
| ADD COLUMN с DEFAULT (неизменяемый) | ACCESS EXCLUSIVE (кратковременно) | Нет (PG 11+) | Да |
ADD COLUMN с DEFAULT (volatile, например now()) | ACCESS EXCLUSIVE | Да | Нет |
| DROP COLUMN | ACCESS EXCLUSIVE (кратковременно) | Нет | Условно: код не должен обращаться к колонке |
| ALTER COLUMN TYPE (например, varchar→text) | ACCESS EXCLUSIVE | Зависит от типов | Часто нет |
| RENAME COLUMN | ACCESS EXCLUSIVE (кратковременно) | Нет | Нет: старый код ломается мгновенно |
| CREATE INDEX | SHARE (блокирует INSERT/UPDATE) | — | Нет |
| CREATE INDEX CONCURRENTLY | Минимальная | — | Да |
Ключевой вывод: даже «кратковременная» ACCESS EXCLUSIVE блокировка может стать длительной, если перед ней в очереди ожидает долгая транзакция. PostgreSQL ставит запросы в очередь за ACCESS EXCLUSIVE, и вся очередь стоит, пока не завершится транзакция, начатая до запроса блокировки. Поэтому перед любой миграцией стоит проверить, нет ли длинных транзакций:
-- PostgreSQL: найти транзакции длиннее 30 секунд
SELECT pid, now() - xact_start AS duration, query
FROM pg_stat_activity
WHERE state != 'idle'
AND xact_start < now() - interval '30 seconds'
ORDER BY duration DESC;
Четыре приёма для миграций без даунтайма
1. Создавайте индексы конкурентно
Django по умолчанию генерирует обычный CREATE INDEX. Замена — AddIndex с классом AddIndexConcurrently из django.contrib.postgres.operations (Django 3.0+). Важный нюанс: конкурентные операции нельзя выполнять внутри транзакции, поэтому миграция должна явно отключить атомарность.
# Django 4.x / 5.x, PostgreSQL
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
class Migration(migrations.Migration):
atomic = False # обязательно для CONCURRENTLY
dependencies = [
('orders', '0042_previous'),
]
operations = [
AddIndexConcurrently(
model_name='order',
index=models.Index(
fields=['created_at', 'status'],
name='idx_order_created_status',
),
),
]
Проверка: после python manage.py migrate выполните \di+ idx_order_created_status в psql. Индекс должен быть valid. Если миграция упала на полпути, индекс останется в состоянии INVALID — его нужно удалить и запустить миграцию заново.
2. Добавляйте колонку в два этапа
Безопасный порядок для колонки с NOT NULL и DEFAULT:
- Деплой 1: добавить колонку как
nullable, безDEFAULTна уровне базы. В модели — поле сnull=True, default=.... Код уже пишет значение в новую колонку, но не читает оттуда критичные данные. - Заполнить существующие строки батчами (см. ниже).
- Деплой 2: убрать
null=True, добавитьNOT NULLконстрейнт. На таблице до ~10 млн строк в PostgreSQL 12+ это быстрая операция: СУБД проверяет метаданные, а не сканирует таблицу, если естьCHECK-констрейнт, созданный какNOT VALIDи затем валидированный.
3. Заполняйте данные батчами, а не RunPython на всю таблицу
Стандартная ошибка — RunPython с Model.objects.all().update(field=value). Это одна гигантская транзакция. На 30 млн строк она держит блокировку строк минуты. Вместо этого — батчи по первичному ключу:
# Батч-обновление вне миграции (management command)
# Django 4.x+, PostgreSQL
import time
from django.db import connection
BATCH_SIZE = 5000
SLEEP_SECONDS = 0.1 # даём реплике догнать
def backfill_priority():
with connection.cursor() as cursor:
cursor.execute("SELECT MIN(id), MAX(id) FROM orders_order")
min_id, max_id = cursor.fetchone()
if min_id is None:
return
current = min_id
while current <= max_id:
cursor.execute(
"UPDATE orders_order "
"SET priority = 0 "
"WHERE id >= %s AND id < %s AND priority IS NULL",
[current, current + BATCH_SIZE],
)
current += BATCH_SIZE
time.sleep(SLEEP_SECONDS)
Каждый батч — отдельная транзакция (autocommit). Блокировка — только на затронутые строки, и только на время одного UPDATE.
Правило: если миграция данных затрагивает больше 100 000 строк, выносите её из RunPython в management-команду. Миграцию в Django нельзя прервать и продолжить — management-команду можно.
4. Переименование колонки — через новую колонку
Django генерирует ALTER TABLE ... RENAME COLUMN. Блокировка короткая, но старый код (предыдущая версия, которая ещё работает на соседних воркерах) мгновенно падает с ProgrammingError. Безопасный путь:
- Создать новую колонку с нужным именем.
- Писать в обе колонки (через
save()или триггер). - Скопировать данные батчами.
- Переключить код на чтение из новой колонки.
- Удалить старую колонку в следующем деплое.
Три-четыре деплоя вместо одного — цена нулевого простоя.
Как проверять миграции до деплоя
Библиотека django-migration-linter анализирует SQL, который генерирует Django, и предупреждает об опасных операциях: неконкурентный индекс, NOT NULL без DEFAULT, изменение типа колонки. Её можно встроить в CI так, чтобы пулл-реквест с опасной миграцией не мержился без явного override.
Полезный приём — перед деплоем генерировать SQL миграции и смотреть глазами:
python manage.py sqlmigrate orders 0043_add_priority
Это покажет точный SQL, который будет выполнен. Если там CREATE INDEX без CONCURRENTLY на большой таблице — повод переписать миграцию до того, как она попадёт в прод.
Граничные случаи и где подход ломается
Безпростойные миграции — не бесплатный приём. Вот где он даёт трещины:
- Смена типа колонки (например,
IntegerField→BigIntegerField). PostgreSQL перепишет всю таблицу. Альтернатива — создать новую колонку нужного типа и мигрировать данные, но это долго и требует дублирования записи. - Уникальные констрейнты.
ADD CONSTRAINT ... UNIQUEсканирует таблицу и держит блокировку. Можно создать уникальный индекс конкурентно, а потом превратить его в констрейнт — но Django ORM это не умеет из коробки, придётся писатьRunSQL. - Реплики с задержкой. Если приложение читает с реплики, а батч-обновление идёт на мастере, реплика может отставать. Параметр
SLEEP_SECONDSв батч-скрипте — грубый, но рабочий способ ограничить отставание. Точнее — проверятьpg_stat_replication.replay_lsnмежду батчами. - Множественные экземпляры кода. При rolling deploy старая и новая версия кода работают одновременно. Если миграция удаляет колонку, а старый код к ней обращается — падение. Правило: удаление колонки из базы — всегда отдельный деплой после того, как код перестал её использовать.
ACCESS EXCLUSIVE — самая строгая блокировка в PostgreSQL. Она конфликтует со всеми другими блокировками, включая обычный SELECT. Пока она удерживается, никакие запросы к таблице не выполняются.
Что делать дальше
Встройте django-migration-linter в CI — это закроет 80% случаев, когда опасная миграция случайно проходит в прод. Настройте statement_timeout на уровне пользователя миграций в PostgreSQL (например, 5 секунд для DDL), чтобы любая блокировка, затянувшаяся дольше порога, откатывалась автоматически, а не висела неопределённо. И заведите привычку запускать sqlmigrate перед каждым мержем — это занимает десять секунд и иногда спасает часы.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: django-migration-linter — GitHub, PostgreSQL Documentation: ALTER TABLE
Частые вопросы
Можно ли использовать AddIndexConcurrently с SQLite или MySQL?
Что будет, если миграция с atomic = False упадёт на полпути?
У меня Django 3.1 и PostgreSQL 10 — подход с ADD COLUMN DEFAULT без переписывания таблицы сработает?
Как понять заранее, сколько времени займёт миграция на проде?
Грозит ли потеря данных, если удалить колонку до того, как весь код перестанет в неё писать?
django-migration-linter ловит все опасные миграции?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.