Database migrations в production: как накатывать изменения схемы без даунтайма и отката на прод

Database migrations в production: как накатывать изменения схемы без даунтайма и отката на прод

Вы добавляете новую колонку в таблицу на 50 миллионов строк. Запускаете ALTER TABLE. Через секунду мониторинг начинает краснеть: запросы встают в очередь, latency растет, пользователи видят ошибки. База заблокировала таблицу на время изменения схемы, и весь трафик встал.

Это не гипотетический сценарий. Именно так выглядит большинство первых столкновений с изменениями схемы на живой нагрузке. Разработчик знает SQL, умеет писать миграции, но не знает, что в production те же команды работают совсем иначе, чем на локальной машине с пустой базой.

В этой статье разберем конкретные техники: как добавлять колонки и индексы без блокировок, как переименовывать поля через промежуточный этап, как откатить схему если что-то пошло не так, и какие инструменты помогают держать всё под контролем.

Коротко:

  • Большинство ALTER TABLE в PostgreSQL блокируют таблицу - для production нужны специальные подходы или CONCURRENTLY.
  • Expand-contract pattern позволяет переименовывать колонки и менять типы без остановки сервиса.
  • Откат схемы - это не просто «запустить down-миграцию»: нужно заранее продумать обратную совместимость данных.
  • Flyway и Liquibase решают версионирование, но не защищают от блокировок - это разные проблемы.
  • Индексы в PostgreSQL можно создавать через CREATE INDEX CONCURRENTLY без блокировки чтения и записи.
  • Самые опасные операции - удаление колонок и изменение типов с конвертацией данных.

Почему production - это другой мир

На локальной машине таблица пустая или содержит тестовые данные. ALTER TABLE users ADD COLUMN phone VARCHAR(20) выполняется за миллисекунды. В production та же команда на таблице с 30 миллионами строк держит ACCESS EXCLUSIVE блокировку всё время выполнения. Никакие SELECT, INSERT, UPDATE не проходят.

Вторая проблема - одновременное развертывание кода и схемы. Если вы сначала деплоите новый код, а потом запускаете миграцию, между этими двумя событиями код обращается к колонке, которой ещё нет. Если наоборот - старый код видит новую колонку, которую не ожидал.

Третья проблема - rolling deployment. Когда несколько инстансов приложения обновляются поочередно, в какой-то момент одновременно работают старая и новая версия кода. Схема должна быть совместима с обеими.

Expand-contract pattern: основа безопасных изменений

Expand-contract (или «расширить-сократить») - это подход, при котором любое ломающее изменение схемы разбивается на несколько безопасных шагов. Идея простая: сначала расширяем схему так, чтобы она работала со старым и новым кодом одновременно, потом переключаем код, потом убираем лишнее.

Классический пример - переименование колонки. Нельзя просто выполнить ALTER TABLE users RENAME COLUMN name TO full_name в production, потому что в момент выполнения старый код перестанет работать.

Правильная последовательность выглядит так:

  1. Expand: добавляем новую колонку full_name, пишем триггер или логику приложения, которая синхронизирует оба поля.
  2. Migrate: копируем данные из name в full_name, убеждаемся что новый код читает из full_name.
  3. Contract: после того как все инстансы перешли на новый код и старая колонка больше не используется - удаляем name.

Каждый шаг - отдельный деплой. Между ними может пройти несколько дней. Это медленнее, чем «просто переименовать», но зато без даунтайма и без риска потери данных.

Пример: переименование колонки через expand-contract

Шаг 1 - добавляем новую колонку и синхронизацию:

-- Миграция 001: добавляем full_name
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
UPDATE users SET full_name = name WHERE full_name IS NULL;

-- Триггер для синхронизации при записи
CREATE OR REPLACE FUNCTION sync_user_name()
RETURNS TRIGGER AS $$
BEGIN
  IF NEW.name IS DISTINCT FROM OLD.name THEN
    NEW.full_name := NEW.name;
  END IF;
  IF NEW.full_name IS DISTINCT FROM OLD.full_name THEN
    NEW.name := NEW.full_name;
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER sync_names
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_user_name();

Шаг 2 - деплоим код, который читает full_name. Шаг 3 - удаляем старое:

-- Миграция 002: убираем лишнее
DROP TRIGGER sync_names ON users;
DROP FUNCTION sync_user_name();
ALTER TABLE users DROP COLUMN name;

ALTER TABLE без блокировки: что реально работает в PostgreSQL

PostgreSQL блокирует таблицу при большинстве DDL-операций. Но некоторые из них можно выполнить безопаснее.

Добавление колонки с DEFAULT. До PostgreSQL 11 любой ADD COLUMN ... DEFAULT переписывал всю таблицу. Начиная с версии 11, если значение по умолчанию не изменяемое (константа, а не функция вроде now()), PostgreSQL сохраняет его в метаданных и не трогает строки. Это делает операцию почти мгновенной даже на больших таблицах.

-- PostgreSQL 11+: быстро, не переписывает строки
ALTER TABLE orders ADD COLUMN is_archived BOOLEAN DEFAULT false NOT NULL;

-- Осторожно: DEFAULT now() всё равно переписывает таблицу
ALTER TABLE orders ADD COLUMN created_at TIMESTAMPTZ DEFAULT now();

Создание индексов. Обычный CREATE INDEX держит SHARE блокировку - чтение работает, но запись заблокирована. CREATE INDEX CONCURRENTLY строит индекс в фоне без блокировки записи, но работает дольше и требует двух проходов по таблице.

-- Блокирует запись на время построения
CREATE INDEX idx_users_email ON users(email);

-- Не блокирует, но медленнее
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

Есть нюанс: CONCURRENTLY нельзя запускать внутри транзакции. Если ваш инструмент миграций оборачивает каждый скрипт в транзакцию - нужно явно отключить это поведение для конкретного файла.

Изменение типа колонки почти всегда переписывает таблицу. Единственное безопасное исключение - увеличение длины VARCHAR (но не уменьшение). Для остального используйте expand-contract: добавьте новую колонку с нужным типом, скопируйте данные батчами, переключите код.

Большие таблицы: UPDATE батчами вместо одной транзакции

Когда нужно заполнить новую колонку данными на таблице с миллионами строк, один большой UPDATE - плохая идея. Он держит блокировки на строки, раздувает WAL, может упереться в таймаут и откатиться целиком.

Правильный подход - обновлять данные небольшими батчами с паузами между ними:

-- Заполняем full_name батчами по 10 000 строк
DO $$
DECLARE
  batch_size INT := 10000;
  updated INT;
BEGIN
  LOOP
    UPDATE users
    SET full_name = name
    WHERE full_name IS NULL
    AND id IN (
      SELECT id FROM users
      WHERE full_name IS NULL
      LIMIT batch_size
    );
    
    GET DIAGNOSTICS updated = ROW_COUNT;
    EXIT WHEN updated = 0;
    
    PERFORM pg_sleep(0.1); -- пауза 100ms между батчами
  END LOOP;
END $$;

Такой подход занимает больше времени, но не создает длинных транзакций и дает базе «дышать» между батчами. Для очень больших таблиц паузу можно увеличить или вынести логику в отдельный скрипт, который запускается в фоне.

Откат миграции: когда всё пошло не так

Откат схемы - одна из самых переоцениваемых возможностей. Многие думают: «есть down-миграция, значит можно всегда откатиться». На практике это работает только для простых случаев.

Проблема в данных. Если миграция добавила колонку и новый код уже записал туда данные, откат через DROP COLUMN уничтожит эти данные. Если миграция изменила тип поля и конвертировала значения, обратная конвертация может быть невозможна без потерь.

Поэтому правильная стратегия откатов строится на нескольких принципах:

  • Откат кода, а не схемы. Если что-то сломалось после деплоя, сначала откатывают код на предыдущую версию. Схема остается как есть - старый код должен уметь работать с новой схемой (это и есть требование обратной совместимости).
  • Down-миграции только для аддитивных изменений. Добавили колонку - можно безопасно удалить, если данных ещё нет. Добавили индекс - можно удалить. Удалили колонку - откат невозможен без резервной копии.
  • Резервная копия перед деструктивными операциями. Перед любым DROP COLUMN, DROP TABLE, изменением типа с потерей данных - снимайте снапшот или делайте CREATE TABLE ... AS SELECT.

Опасные операции, которые нельзя откатить без потерь:

  • DROP COLUMN - данные удалены безвозвратно
  • ALTER COLUMN TYPE с конвертацией (например, VARCHAR в INT)
  • TRUNCATE TABLE
  • DROP TABLE
  • Удаление уникального ограничения, если после этого появились дубли

Для всех этих операций единственный надежный откат - восстановление из резервной копии.

Flyway и Liquibase: что они решают и чем отличаются

Оба инструмента решают одну задачу - версионирование схемы базы данных. Они отслеживают, какие скрипты уже применены, и применяют только новые. Это избавляет от ручного контроля «а эта миграция уже накатана на prod?».

КритерийFlywayLiquibase
Формат миграцийSQL-файлы или Java-классыXML, YAML, JSON или SQL
Откат (rollback)Только в платной версииЕсть в бесплатной версии
Условные миграцииОграниченноПоддерживает preconditions
Порог входаНиже, проще настроитьВыше, больше возможностей
Популярность в Java-экосистемеОчень высокаяВысокая

Для Python-проектов чаще используют Alembic (стандарт для SQLAlchemy) или встроенные миграции Django. Alembic умеет генерировать миграции автоматически по diff между моделями и схемой, но автогенерацию всегда нужно проверять вручную - она не знает о ваших требованиях к блокировкам.

Важный момент: ни один из этих инструментов не защищает от блокировок таблиц. Они управляют порядком и версионированием, но не знают, безопасен ли конкретный SQL для production-нагрузки. Это ответственность разработчика.

Настройка Flyway для PostgreSQL

Базовая конфигурация для Spring Boot-проекта:

# application.properties
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
spring.flyway.baseline-on-migrate=true
spring.flyway.out-of-order=false

Файлы миграций называются по схеме V{версия}__{описание}.sql, например V20240115__add_full_name_column.sql. Flyway хранит историю в таблице flyway_schema_history и не применяет уже выполненные скрипты повторно.

Если нужно запустить CREATE INDEX CONCURRENTLY через Flyway, скрипт должен выполняться вне транзакции. Для этого добавьте в начало файла:

-- flyway:executeInTransaction=false
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);

Schema migration в PostgreSQL: специфика и инструменты

PostgreSQL предоставляет несколько возможностей, которых нет в других базах.

Transactional DDL. В отличие от MySQL, PostgreSQL выполняет DDL-операции внутри транзакций. Это значит, что если миграция содержит несколько команд и одна из них упала - всё откатится автоматически. Схема не останется в полусломанном состоянии.

pg_repack. Расширение для реорганизации таблиц и индексов без длительных блокировок. Полезно, когда нужно перестроить таблицу (например, изменить тип колонки) на живой нагрузке. Работает по принципу: создает новую таблицу, копирует данные, переключает имена.

Мониторинг блокировок. Перед запуском тяжелой миграции полезно следить за активными блокировками:

-- Смотрим на активные блокировки и ожидающие запросы
SELECT
  pid,
  now() - pg_stat_activity.query_start AS duration,
  query,
  state
FROM pg_stat_activity
WHERE state != 'idle'
  AND query_start < now() - interval '5 seconds'
ORDER BY duration DESC;

lock_timeout и statement_timeout. Если миграция не может получить блокировку за разумное время, лучше пусть она упадет с ошибкой, чем будет ждать и держать очередь запросов:

SET lock_timeout = '5s';
SET statement_timeout = '60s';
ALTER TABLE users ADD COLUMN phone VARCHAR(20);

Типичные ошибки при работе со схемой на живой базе

ОшибкаПоследствиеКак избежать
Обычный CREATE INDEX на большой таблицеБлокировка записи на минутыИспользовать CONCURRENTLY
Один большой UPDATE для заполнения новой колонкиДлинная транзакция, таймаут, откатБатчевое обновление с паузами
Деплой кода и схемы одновременноОкно несовместимости между кодом и базойСначала схема (аддитивно), потом код
Удаление колонки без проверки использованияОшибки в коде, который ещё читает эту колонкуExpand-contract, проверка через grep/AST
NOT NULL без DEFAULT на непустой таблицеОшибка при выполнении миграцииСначала добавить с DEFAULT, потом добавить NOT NULL

Отдельно стоит упомянуть ошибку с NOT NULL. Если добавить колонку с NOT NULL без значения по умолчанию на непустую таблицу - PostgreSQL откажет. Но даже если добавить DEFAULT, в старых версиях это переписывало всю таблицу. Правильный порядок для PostgreSQL до версии 11:

  1. Добавить колонку без NOT NULL, с DEFAULT.
  2. Заполнить данные батчами.
  3. Добавить ограничение NOT NULL через ALTER TABLE ... ALTER COLUMN ... SET NOT NULL.

Чеклист перед запуском миграции в production

  • Миграция проверена на копии production-данных (не на пустой базе).
  • Оценено время выполнения - нет операций, которые заблокируют таблицу дольше допустимого.
  • Для индексов используется CONCURRENTLY, для больших UPDATE - батчи.
  • Установлены lock_timeout и statement_timeout.
  • Новый код совместим со старой схемой (на случай если нужно откатить код).
  • Старый код совместим с новой схемой (на время rolling deployment).
  • Для деструктивных операций есть резервная копия или CREATE TABLE ... AS SELECT.
  • Есть план отката: что делать если миграция упала на середине.
  • Мониторинг настроен: latency, ошибки, активные блокировки.
  • Команда предупреждена о времени выполнения.

FAQ

Можно ли запускать миграции автоматически при старте приложения?

Можно, и многие фреймворки делают это по умолчанию (Spring Boot + Flyway, Django). Но для production это рискованно при горизонтальном масштабировании: несколько инстансов стартуют одновременно и пытаются применить одну и ту же миграцию. Flyway и Liquibase решают это через блокировку в базе, но лучше запускать миграции отдельным шагом в CI/CD до деплоя приложения.

Чем expand-contract отличается от обычного поэтапного деплоя?

Поэтапный деплой - это про код. Expand-contract - про схему базы данных. Это разные уровни, но они связаны: expand-contract как раз обеспечивает совместимость схемы с несколькими версиями кода, которые работают одновременно во время rolling deployment.

Как откатить миграцию если данные уже изменились?

Если новый код уже записал данные в новую схему, откат схемы означает потерю этих данных. В такой ситуации правильнее откатить код (он должен уметь работать со старой схемой), а схему оставить как есть. Данные сохраняются, сервис работает, а исправление схемы делается следующей миграцией.

Что выбрать: Flyway или Liquibase?

Для большинства Java-проектов Flyway проще в старте и достаточен по функциональности. Liquibase стоит выбрать если нужны rollback-скрипты в бесплатной версии, условные миграции (preconditions) или мультибазовая поддержка. Для Python - Alembic если используете SQLAlchemy, встроенные миграции если Django.

Как безопасно добавить NOT NULL колонку на большую таблицу?

В PostgreSQL 11+ можно добавить колонку с константным DEFAULT и NOT NULL за одну операцию - она не переписывает строки. Для более ранних версий или для DEFAULT с функцией: добавляете колонку без NOT NULL, заполняете батчами, потом добавляете ограничение. Ещё один вариант - добавить NOT NULL через ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID, а потом VALIDATE CONSTRAINT - это позволяет разделить операцию на два шага с меньшими блокировками.

Как проверить, что колонка больше нигде не используется перед удалением?

Grep по кодовой базе - минимум. Для более надежной проверки: добавьте логирование или метрику на чтение этой колонки в коде, подождите несколько дней, убедитесь что счетчик нулевой. В PostgreSQL можно временно добавить триггер, который логирует обращения к колонке через NEW.column_name.

Итог

Безопасная работа со схемой в production - это не один приём, а набор привычек. Expand-contract для ломающих изменений, CONCURRENTLY для индексов, батчи для массовых обновлений, lock_timeout как страховка от зависших операций. Каждый из этих инструментов решает конкретную проблему.

Flyway и Liquibase снимают головную боль с версионированием, но не заменяют понимание того, что происходит на уровне базы. Инструмент применит любой SQL - безопасный или нет. Ответственность за то, чтобы миграция не положила production, остается за разработчиком.

Хорошая проверка готовности: если вы можете объяснить, какие блокировки создает каждая операция в вашей следующей миграции и сколько времени она займет на реальном объеме данных - вы готовы к деплою.