Вы добавляете новую колонку в таблицу на 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, потому что в момент выполнения старый код перестанет работать.
Правильная последовательность выглядит так:
- Expand: добавляем новую колонку
full_name, пишем триггер или логику приложения, которая синхронизирует оба поля. - Migrate: копируем данные из
nameвfull_name, убеждаемся что новый код читает изfull_name. - 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 TABLEDROP TABLE- Удаление уникального ограничения, если после этого появились дубли
Для всех этих операций единственный надежный откат - восстановление из резервной копии.
Flyway и Liquibase: что они решают и чем отличаются
Оба инструмента решают одну задачу - версионирование схемы базы данных. Они отслеживают, какие скрипты уже применены, и применяют только новые. Это избавляет от ручного контроля «а эта миграция уже накатана на prod?».
| Критерий | Flyway | Liquibase |
|---|---|---|
| Формат миграций | 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:
- Добавить колонку без NOT NULL, с DEFAULT.
- Заполнить данные батчами.
- Добавить ограничение 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, остается за разработчиком.
Хорошая проверка готовности: если вы можете объяснить, какие блокировки создает каждая операция в вашей следующей миграции и сколько времени она займет на реальном объеме данных - вы готовы к деплою.