Пишем миграции баз данных без сюрпризов: безопасные схемы изменения таблиц
Научимcя менять структуру БД так, чтобы минимизировать блокировки: фазовые миграции, бэкапы, совместимые изменения и откаты.
Содержание
Пишем миграции баз данных без сюрпризов: безопасные схемы изменения таблиц
Миграции — это один из тех участков инженерной практики, где «работает на моём стенде» быстро превращается в «мы простояли прод». Изменения схемы базы данных затрагивают не только DDL, но и транзакционность, блокировки, репликацию, бэкапы, схемные кеши ORM, миграционные фреймворки и — в распределённых системах — совместимость версий приложения.
Хорошая новость: большинству проблем можно заранее поставить «ограждения». Плохая — универсальной кнопки «без сюрпризов» не существует: безопасность зависит от СУБД (PostgreSQL, MySQL, MS SQL), характера изменений и того, как приложение читает/пишет данные.
В этой статье — практическая методология безопасных схем изменения таблиц через фазовые миграции, совместимые изменения, план отката и контрольные точки. Подход применим в большинстве SQL-ориентированных систем; примеры ниже ориентированы на PostgreSQL, но идеи сохраняются и в других движках.
Если вы хотите глубже именно в прикладную дисциплину миграций и стратегий совместимости, можете посмотреть разборы в рамках курса — например, курс. Но принципы, приведённые здесь, полезны независимо от конкретного учебного трека.
Как миграции превращаются в инциденты
Прежде чем перейти к стратегиям, разберём типовые источники «сюрпризов»:
Блокировки и ожидания (DDL lock)
DDL-команды часто ставят табличные или ролевые блокировки. Например, ALTER TABLE может ожидать завершения текущих транзакций или блокировать конкурентные операции. В проде это означает:
- рост latency на запросах;
- очереди на приложении;
- временами — deadlocks или таймауты клиентов.
Особенно опасны миграции, которые:
- переписывают таблицу (например, меняют тип колонки или добавляют/удаляют столбцы с пересчётом);
- требуют перестроения индексов;
- выполняются «в один шаг» на большой таблице.
Разрыв совместимости «старое приложение ↔ новая схема»
Даже если СУБД справилась с DDL, остаётся слой приложения. Классика:
- вы добавили колонку и поменяли бизнес-логику так, что старый код падает на чтении/записи;
- вы переименовали колонку, но старые инстансы приложения ещё работают;
- вы изменили тип/формат данных, а фоновые джобы или запросы используют старый тип.
В результате получаете ошибки на уровне ORM/SQL (например, column does not exist) или логические ошибки.
Репликация и лог-шиппинг
На реплицированных системах миграции могут:
- существенно увеличить нагрузку на репликацию;
- создать задержку (replication lag);
- потребовать специфичных опций безопасности (например, управление
lock_timeout,statement_timeout, контроль порядка миграций).
Отсутствие отката или «откат из другого мира»
Если миграция прошла частично, откат может оказаться невозможен или опасен:
- вы удалили колонку, данные из которой нужны для восстановления;
- вы модифицировали формат так, что старый код не умеет интерпретировать данные;
- миграция была необратимой (например, разрушили историю).
Поэтому план отката должен быть частью дизайна миграции, а не постфактум.
Принцип: думайте не «ALTER TABLE», а «состояния системы»
Безопасная миграция — это не одна команда, а последовательность согласованных состояний:
- Состояние A: работает старое приложение + старая схема.
- Состояние B: обновили схему так, чтобы и старое, и новое приложение могли сосуществовать.
- Состояние C: обновили приложение до новой версии (которая понимает изменения).
- Состояние D: убрали временные элементы (старые колонки/таблицы/индексы) и зафиксировали «новый мир».
На практике это означает: вместо «удалить старую колонку и переименовать новую» делаем поэтапные операции, где переходы управляют совместимостью.
Фазовые миграции: модель «добавить → заполнить → переключить → убрать»
Ниже — универсальная схема для изменений, которые требуют пересборки данных или изменения смысла.
Шаг 1. Добавляем новый элемент в нейтральном режиме
Пример: нужно изменить тип/формат поля или добавить колонку под новую логику.
Правило: на этом шаге не ломаем старое приложение.
- Колонка должна быть nullable или иметь дефолт, не влияющий на старую запись.
- Индексы/ограничения — аккуратно: если они затронут большой объём данных, их можно отложить.
Пример (PostgreSQL):
-- 1) Добавляем новую колонку, не ломая существующие записи
ALTER TABLE orders
ADD COLUMN total_amount_cents bigint;
Почему nullable? Потому что старое приложение не знает про новую колонку и может не заполнять её.
Шаг 2. Заполняем данные батчами
Если необходимо перенести/преобразовать данные, делаем это отдельным процессом без долгих блокировок.
В PostgreSQL часто используют UPDATE с ограничением на количество строк, в несколько итераций.
Пример (условная батч-обработка):
-- 2) Заполняем батчами; примерная схема — зависит от вашей уникальности/индексов
WITH batch AS (
SELECT id
FROM orders
WHERE total_amount_cents IS NULL
ORDER BY id
LIMIT 10000
)
UPDATE orders o
SET total_amount_cents = (o.total_amount * 100)::bigint
FROM batch
WHERE o.id = batch.id;
Такой подход:
- уменьшает время одной транзакции;
- ограничивает рост блокировок;
- позволяет остановиться и вернуться к плану.
Шаг 3. Переключаем приложение на новый вариант
Когда данные подготовлены, обновляем код приложения.
- В новом коде запись идёт в новую колонку (а при необходимости — ещё и в старую, чтобы сохранить консистентность в период смешанной версии).
- Чтение — сначала безопасно переключаем через feature flag или поэтапно.
Если нужна двойная запись на период миграции, это особенно важно при rolling deploy (когда часть инстансов ещё старые).
Шаг 4. Добавляем ограничения и индексы (только после готовности данных)
Например, NOT NULL, CHECK, внешний ключ, уникальность.
Эти изменения часто самые опасные из-за необходимости проверить данные.
По возможности:
- сначала создайте индекс конкурентно (в PostgreSQL —
CREATE INDEX CONCURRENTLY); - затем включайте ограничение, когда таблица уже заполнена корректно.
Пример:
-- 4) Когда данные заполнены, можно сделать NOT NULL
ALTER TABLE orders
ALTER COLUMN total_amount_cents SET NOT NULL;
-- Индекс
CREATE INDEX CONCURRENTLY idx_orders_total_amount_cents
ON orders (total_amount_cents);
Важно: в PostgreSQL
CREATE INDEX CONCURRENTLYне требует длительных блокировок чтения, но требует аккуратности в управлении миграциями (например, транзакции).
Шаг 5. Убираем старое после полного перехода
Только когда:
- вы обновили все инстансы приложения;
- убедились по метрикам/логам, что старые пути не используются;
- выдержали окно времени, чтобы все кэш-пересборки/фоновые джобы завершились,
можно удалять старые колонки/таблицы/индексы.
Совместимые изменения схемы: что можно делать без «стопа мира»
Совместимость — это ядро безопасных миграций. Ниже — практические принципы, что обычно совместимо и что ломает.
Добавление колонок: чаще всего безопасно
Добавить колонку (особенно nullable) обычно можно без драм. Главное — чтобы:
- старое приложение игнорировало её (обычно оно делает
INSERTсписком колонок, а неINSERT ... VALUES (...)без списка); - не было триггеров/генераторов, которые внезапно начнут требовать значения.
Переименование: опасность в смешанных версиях
Переименование ломает старые запросы и ORM-схемы. В фазовой схеме лучше:
- добавить новую колонку;
- заполнить;
- обновить приложение;
- лишь потом (в отдельном релизном окне) убрать старую.
Если всё-таки нужно переименование, можно использовать view или синонимы, но в чистом виде это обычно сложнее и зависит от СУБД.
Изменение типа: часто необратимо и блокирующе
Изменение типа столбца может:
- требовать перестройки данных;
- вызвать долгую блокировку;
- быть несовместимым на уровне приложения и драйверов.
Паттерн безопасного изменения типа:
- добавить колонку нового типа;
- заполнить преобразованием;
- переключить чтение/запись;
- после подтверждения — удалить старую.
Удаление колонок/ограничений: делайте только в конце
Удаление — это почти всегда необратимое действие в рамках смешанной версии. Даже если «в теории» старый код больше не использует колонку, прод — живой организм.
Индексы и уникальность: скрытый источник блокировок
Индексы кажутся безопасными, но на практике есть подводные камни.
Создание индекса на большой таблице
Обычное CREATE INDEX может блокировать запись или ухудшать нагрузку.
В PostgreSQL:
- используйте
CREATE INDEX CONCURRENTLY, если вам критична минимизация блокировок; - помните, что команда не может выполняться внутри обычной транзакции.
Пример:
CREATE INDEX CONCURRENTLY idx_orders_customer_id
ON orders (customer_id);
Уникальность и дедлайны
Добавление UNIQUE:
- потребует проверки всех строк;
- может быть длительным;
- иногда ведёт к миграциям, которые «висели» у людей на проде.
Фазовая схема:
- сначала создать уникальный индекс (если возможно и приемлемо);
- обработать конфликты в данных;
- только затем включить
ALTER TABLE ... ADD CONSTRAINT.
Ограничения (NOT NULL, CHECK, FK) без риска
Чаще всего проблемы возникают, когда ограничение включают «сразу», не подготовив данные.
NOT NULL
Безопасный подход:
- добавьте колонку
nullable; - заполните;
- только затем
SET NOT NULL.
Пример:
ALTER TABLE payments
ADD COLUMN provider_reference text;
-- заполняем батчами...
-- потом:
ALTER TABLE payments
ALTER COLUMN provider_reference SET NOT NULL;
CHECK
Если CHECK зависит от выражений или новых колонок:
- проверьте, что все данные удовлетворяют условию;
- включайте CHECK после backfill.
Внешние ключи
FK часто требуют долгой проверки. Плюс есть риск, что в проде появятся записи, которые не соответствуют новой ссылочной структуре.
Фазовые варианты:
- сначала добавьте колонку внешнего ключа nullable;
- заполните и почистите;
- затем добавьте constraint в конце.
Управление блокировками: время ожидания и «границы» транзакций
Даже фазовая миграция может стать опасной, если любая операция будет ждать слишком долго.
Ограничьте время ожидания DDL
В PostgreSQL полезно выставлять:
lock_timeout— сколько ждать блокировку;statement_timeout— сколько длиться запросу;- размеры батчей — чтобы транзакции были короткими.
Пример:
SET lock_timeout = '5s';
SET statement_timeout = '30s';
И затем выполняйте DML/DDL в рамках этих ограничений. Если что-то не получилось — миграцию можно повторить, но вы не превращаете прод в «сервис ожиданий».
Следите за планами выполнения
Даже в батчах можно случайно построить дорогие запросы. Проверьте:
- есть ли индексы на предикаты
WHERE ... IS NULL/WHERE updated_at < ...; - используете ли вы корректный порядок.
Резервные копии и точки восстановления: что именно бэкапить
«Сделать бэкап» звучит просто, но важно уточнить:
- бэкап должен позволять восстановиться до конкретного состояния схемы и данных;
- миграции должны быть разворачиваемы повторно в нужной последовательности;
- если миграция включает батчи, вы должны понимать, что будет, если часть батчей выполнилась, а часть — нет.
Подход с точками восстановления
На PostgreSQL обычно применяют:
- логические бэкапы (pg_dump) — для меньших систем или гибкой миграции схемы;
- физические бэкапы (base backup) + PITR (point-in-time recovery) — когда важна точность отката по времени.
Если у вас включён репликационный журнал/WAL, PITR часто даёт наиболее предсказуемый откат «как было».
Бэкап схемы vs бэкап данных
Схема меняется миграциями. Для отката полезно иметь:
- фиксацию версий миграций (какие номера применены);
- возможность вернуться к предыдущей схеме;
- автоматизацию развёртывания (чтобы не было «мы откатили таблицу руками»).
Проектирование отката: как сделать rollback реальным
Откат должен быть определён заранее. Есть два уровня отката:
1) Схемный откат (rollback DDL)
Если вы сделали только обратимые шаги — это легко. Но часто необратимость проявляется в:
- удалении колонок;
- изменении типов;
- разрушении данных.
Поэтому в фазовой схеме откат обычно сводится к тому, чтобы:
- вернуть приложение на старую схему;
- оставить новые колонки как есть (они не мешают);
- либо откатить дополнительные индексы/ограничения.
Иными словами: часто откат — это не “удалить всё”, а “отключить новую ветку”.
2) Данные (backfill/transform rollback)
Если вы заполняли новые колонки батчами, а потом решили откатиться:
- можно просто перестать читать новую колонку и снова использовать старую;
- новые данные остаются, но не вредят.
Если же вы меняли старую колонку (например, обновляли её в месте), откат может требовать восстановления из бэкапа или обратной трансформации.
Практическое правило: при возможности не трогайте старое до тех пор, пока не переключились полностью.
Типовые сценарии и безопасные рецепты
Рассмотрим несколько распространённых «боевых» кейсов и разложим их на безопасные шаги.
Сценарий A: добавляем новую колонку и меняем формулу расчёта
Цель: вместо total_amount используем total_amount_cents с другой точностью/округлением.
Шаги:
- Добавить новую колонку
total_amount_cents nullable. - Backfill.
- Обновить приложение: запись/чтение по новой колонке (можно в период перехода писать в обе).
SET NOT NULL.- Удалить старую колонку в конце.
Критичные моменты:
- во время смешанных версий прод должен сохранять консистентность;
- проверьте, как обрабатываются исторические записи.
Сценарий B: переименовать поле для API/ORM
Если приложение использует ORM-метаданные, переименование ломает маппинги.
Решение: не переименовывайте сразу на уровне таблицы.
- Добавьте новую колонку (или создайте view);
- заполните;
- обновите ORM маппинг;
- через время удалите старую.
Если есть возможность — view может служить мостом, но это усложняет производительность и планы запросов.
Сценарий C: смена типа (например, int → bigint)
Менять тип «в лоб» опасно. Лучше:
- добавить новую колонку нового типа;
- backfill;
- переключить приложение;
- добавить ограничения;
- удалить старую колонку.
Это также удобно для проверки данных: можно сравнить значения до переключения.
Сценарий D: добавление внешнего ключа в существующие данные
Сначала создайте колонку nullable для FK. Затем:
- очистите/приведите данные, чтобы они соответствовали ссылочной таблице;
- только потом добавляйте constraint.
Операционная дисциплина: как выполнять миграции без хаоса
Техническая схема — половина успеха. Вторая половина — процесс.
Тестируйте миграции как продукт
Проверьте:
- миграцию на стенде с данными, близкими к реальным (объём и распределение);
- время выполнения и влияние на индекс/план запросов;
- поведение под конкурентной нагрузкой.
Если вы не можете поднять такую нагрузку — хотя бы смоделируйте «тяжёлые» запросы и профилируйте.
Используйте миграционные окна и наблюдаемость
В проде вам нужны ответы на вопросы:
- что происходит с latency/throughput во время DDL?
- растёт ли блокировочный счётчик?
- падает ли процент успешных запросов?
- увеличилась ли задержка репликации?
В PostgreSQL полезно мониторить:
pg_stat_activity(блокировки/ожидания);- метрики блокировок/дедлоков (в зависимости от системы
Комментарии
Пока нет комментариев