Миграция БД — это не «выполнил SQL и пошёл дальше». На живой нагрузке ALTER TABLE берёт ACCESS EXCLUSIVE lock и блокирует всё — INSERT, UPDATE, SELECT. На таблице 100M строк это минуты-часы простоя. Эта статья — как менять схему так, чтобы прод не падал.
Правила пронумерованы кодами PG-M-NNN — на них ссылается скилл ucp-pg-migration-review.
1. Что такое breaking change
PG-M-001 Любая операция, которая ломает работу СТАРОЙ версии кода, — breaking change.
Не-breaking (можно катить):
ADD COLUMN ... NULL(PG11+ сDEFAULTтоже).ADD INDEX CONCURRENTLY.CREATE TABLE.CREATE INDEX CONCURRENTLY.ADD CONSTRAINT ... NOT VALID.
Breaking (нужен expand-contract):
DROP COLUMN.RENAME COLUMN.ALTER TYPE(изменение типа).ADD COLUMN ... NOT NULLбез default (на больших таблицах).DROP TABLE.RENAME TABLE.- Удаление значения из enum.
- Сужение
CHECK-constraint (был>=0, стал>0).
PG-M-002 Правило N-1: миграция должна быть совместима с предыдущей версией кода. Деплой идёт в порядке: миграция → новый код. Между ними — окно, где старый код работает с новой схемой. Если миграция breaking, в этом окне старый код упадёт.
2. Expand-Contract — ключевой паттерн
PG-M-010 Любое breaking-изменение режется на 3+ релиза.
Шаги:
- Expand — добавили новое (старое работает).
- Migrate data + dual-write в коде (пишем и в старое, и в новое).
- Switch reads — читатели переключены на новое.
- Contract — старое удалено.
Между шагами — отдельный релиз приложения.
Релиз 1 (expand): миграция: добавить новую колонку/таблицу/индекс
код: пишет в старое, опционально dual-write в новое
Релиз 2 (migrate): миграция: backfill старых данных в новое
код: dual-write обязателен; читатели всё ещё на старом
Релиз 3 (switch reads): код: читатели переключены на новое
миграция: ничего
Релиз 4 (contract): миграция: дропнуть старое
код: убрать упоминания старого
3 деплоя — не баг, а фича. Каждый релиз можно откатить независимо.
3. ALTER TABLE — что переписывает таблицу
PG-M-020 Что берёт ACCESS EXCLUSIVE (блокирует всё) и переписывает таблицу:
| Операция | Перепис. таблицу | Lock |
|---|---|---|
ADD COLUMN без DEFAULT | нет (PG11+) | ACCESS EXCLUSIVE кратко |
ADD COLUMN ... NULL DEFAULT 'X' | нет (PG11+) | ACCESS EXCLUSIVE кратко |
ADD COLUMN ... NOT NULL DEFAULT 'X' | нет (PG11+) | ACCESS EXCLUSIVE кратко |
ADD COLUMN ... NOT NULL без DEFAULT | нет (но требует данных в колонке) | ACCESS EXCLUSIVE |
DROP COLUMN | нет (помечает invisible) | ACCESS EXCLUSIVE кратко |
ALTER TYPE (с приведением) | да, всю | ACCESS EXCLUSIVE минуты-часы |
SET NOT NULL | нет, но проверяет каждую строку | ACCESS EXCLUSIVE |
ADD CONSTRAINT CHECK | нет, но проверяет каждую строку | ACCESS EXCLUSIVE |
ADD CONSTRAINT FOREIGN KEY | нет, но проверяет каждую строку | ACCESS EXCLUSIVE |
ADD CONSTRAINT ... NOT VALID | нет, не проверяет | ACCESS EXCLUSIVE кратко |
PG-M-021 ACCESS EXCLUSIVE lock встаёт в очередь после ВСЕХ ждущих и блокирует ВСЕХ новых. Если хоть один долгий SELECT идёт — миграция ждёт его, а за ней копится очередь новых запросов. Эффект — кратковременный stall на проде, даже если сама миграция занимает миллисекунды.
PG-M-022 SET LOCAL lock_timeout = '3s' в начале каждой миграции. Если за 3 секунды лок не получен — миграция упала, лучше повторить позже, чем заблокировать прод.
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE order_doc ADD COLUMN ...;
COMMIT;
4. Рецепты конкретных breaking changes
4.1. ADD COLUMN NOT NULL
PG-M-030 На PG11+ с DEFAULT — мгновенно. Без — нужен expand-contract.
Хороший путь (PG11+):
ALTER TABLE order_doc ADD COLUMN priority integer NOT NULL DEFAULT 0;
-- мгновенно: PG не переписывает строки, читает default «виртуально»
Плохой путь до PG11 (или если default — функция типа gen_random_uuid()):
Шаги:
- Релиз 1:
ADD COLUMN priority integer NULL+ код пишет значение в новых строках. - Релиз 2 (через время или миграция):
UPDATE order_doc SET priority = 0 WHERE priority IS NULL;(батчами). - Релиз 3:
ALTER TABLE ... ALTER COLUMN priority SET NOT NULL;.
PG-M-031 SET NOT NULL блокирует таблицу на проверку каждой строки. На большой таблице — минуты.
Безопасный паттерн (PG12+):
-- 1. CHECK NOT VALID — мгновенно, не проверяет существующие строки
ALTER TABLE order_doc ADD CONSTRAINT ck_order_priority_not_null
CHECK (priority IS NOT NULL) NOT VALID;
-- 2. VALIDATE — проверяет, но НЕ берёт ACCESS EXCLUSIVE (только SHARE UPDATE EXCLUSIVE)
ALTER TABLE order_doc VALIDATE CONSTRAINT ck_order_priority_not_null;
-- 3. SET NOT NULL — теперь дёшево, потому что PG знает из CHECK что null'ов нет
ALTER TABLE order_doc ALTER COLUMN priority SET NOT NULL;
-- 4. Опционально: дропнуть избыточный CHECK
ALTER TABLE order_doc DROP CONSTRAINT ck_order_priority_not_null;
4.2. RENAME COLUMN
PG-M-040 Невозможно одним коммитом без даунтайма. Старый код знает old_name, новый — new_name.
Шаги:
- Релиз 1:
ADD COLUMN new_name <type>;+ dual-write в коде (UPDATE ... SET new_name = ?, old_name = ?). - Бэкфилл:
UPDATE table SET new_name = old_name WHERE new_name IS NULL;(батчами). - Релиз 2: код читает из
new_name, продолжает dual-write. - Релиз 3: код только
new_name. - Миграция:
ALTER TABLE ... DROP COLUMN old_name;.
Долго и нудно. Альтернатива — view-обёртка:
ALTER TABLE order_doc RENAME COLUMN old_name TO new_name;
CREATE VIEW order_doc_v AS SELECT *, new_name AS old_name FROM order_doc;
-- старый код через view, новый — через таблицу. Через релиз view дропнуть.
Работает только на чтение (INSERT/UPDATE через view с computed column не пройдут). Для read-only переходов — отличный путь.
4.3. ALTER TYPE (изменение типа колонки)
PG-M-050 ALTER TABLE ... ALTER COLUMN ... TYPE ... переписывает всю таблицу. На больших — часы под ACCESS EXCLUSIVE.
Безопасный паттерн (expand-contract):
- Релиз 1:
ADD COLUMN amount_v2 numeric(15, 2);+ dual-write(amount, amount_v2). - Бэкфилл:
UPDATE order_doc SET amount_v2 = amount WHERE amount_v2 IS NULL;батчами. - Релиз 2: код читает
amount_v2, dual-write остаётся. - Релиз 3: код только
amount_v2. - Миграция:
DROP COLUMN amount;+RENAME COLUMN amount_v2 TO amount;.
Исключение — приводимые типы внутри одной семьи (int → bigint, varchar(50) → varchar(100), varchar → text). PG умеет без переписи в некоторых случаях:
varchar → text— мгновенно (одинаковое представление).varchar(50) → varchar(100)— мгновенно (расширение).varchar(100) → varchar(50)— переписывает (надо проверить).int → bigint— переписывает.
4.4. DROP COLUMN
PG-M-060 DROP COLUMN дёшев (PG только помечает invisible), но требует expand-contract по коду: старая версия упадёт на INSERT INTO ... (col, ...) VALUES (...).
Шаги:
- Релиз 1: код перестал писать в колонку (только пишет, можно оставить
NULL). - Релиз 2: код перестал упоминать колонку вообще.
- Миграция:
ALTER TABLE ... DROP COLUMN ...;.
4.5. ADD FOREIGN KEY
PG-M-070 ADD CONSTRAINT FOREIGN KEY проверяет каждую существующую строку под ACCESS EXCLUSIVE. На больших — больно.
Безопасный паттерн:
-- 1. Добавить как NOT VALID — мгновенно, проверяет только новые INSERT
ALTER TABLE order_item
ADD CONSTRAINT fk_order_item_order_id
FOREIGN KEY (order_id) REFERENCES order_doc(id)
NOT VALID;
-- 2. VALIDATE отдельно — берёт SHARE UPDATE EXCLUSIVE (не блокирует чтение/запись)
ALTER TABLE order_item VALIDATE CONSTRAINT fk_order_item_order_id;
То же для UNIQUE через индекс:
-- 1. Создать unique index concurrently (без блокировки)
CREATE UNIQUE INDEX CONCURRENTLY uk_customer_email ON customer (email);
-- 2. Прицепить к констрейнту (мгновенно)
ALTER TABLE customer ADD CONSTRAINT uk_customer_email UNIQUE USING INDEX uk_customer_email;
4.6. Индексы
PG-M-080 В продакшен-миграциях — всегда CREATE INDEX CONCURRENTLY и DROP INDEX CONCURRENTLY.
Без CONCURRENTLY:
CREATE INDEXберётSHARElock — блокируетINSERT/UPDATE/DELETEдо конца построения. На big table — десятки минут даунтайма записи.DROP INDEXберётACCESS EXCLUSIVE— блокирует и чтение тоже.
С CONCURRENTLY:
CREATE INDEX CONCURRENTLY— два прохода без блокировки. Минусы: нельзя в транзакции, в LiquibaserunInTransaction="false".- Если упало — индекс остаётся
INVALID, надоDROP INDEXи пересоздать.
<changeSet id="20260507-add-customer-status-index" author="vadim" runInTransaction="false">
<sql>CREATE INDEX CONCURRENTLY ix_customer_status ON customer (status);</sql>
<rollback>DROP INDEX CONCURRENTLY IF EXISTS ix_customer_status;</rollback>
</changeSet>
4.7. Удаление значения из enum
PG-M-090 Нативно невозможно. Замена enum через теневой тип.
Шаги:
- Создать новый тип:
CREATE TYPE order_status_v2 AS ENUM ('NEW','PAID','SHIPPED');. - Добавить временную колонку:
ALTER TABLE order_doc ADD COLUMN status_v2 order_status_v2;. - Бэкфилл:
UPDATE order_doc SET status_v2 = status::text::order_status_v2;(для удаляемого значения — отдельно мапнуть в новое). - Релиз кода: dual-write обоих, читать новый.
- Удалить старую колонку, переименовать новую:
ALTER TABLE order_doc DROP COLUMN status; ALTER TABLE order_doc RENAME COLUMN status_v2 TO status; DROP TYPE order_status;
PG-M-091 Добавление значения в enum (PG12+) — мгновенно (ALTER TYPE ... ADD VALUE), но новое значение нельзя использовать в той же транзакции:
BEGIN;
ALTER TYPE order_status ADD VALUE 'PARTIALLY_REFUNDED';
INSERT INTO order_doc (status) VALUES ('PARTIALLY_REFUNDED'); -- ОШИБКА
COMMIT;
В Liquibase это значит: ADD VALUE и использование — в разных changeset'ах.
PG-M-092 Переименование значения (PG10+): ALTER TYPE order_status RENAME VALUE 'OLD' TO 'NEW';. Мгновенно. Но координация с кодом — N-1 правило.
4.8. DROP TABLE / RENAME TABLE
PG-M-100 Тривиально по DDL, но требует, чтобы вся версия кода уже не трогала таблицу.
Шаги для drop:
- Релиз 1: код перестал ссылаться на таблицу.
- Релиз 2 (через несколько дней — на случай rollback): миграция
DROP TABLE old_name;.
5. Long-running data migrations
PG-M-110 UPDATE миллионов строк одним statement — открывает гигантскую транзакцию, копит WAL, блокирует autovacuum. Делай батчами.
-- плохо: одна гигантская TX
UPDATE order_doc SET priority = 0 WHERE priority IS NULL;
-- лучше: батчи через cursor / LIMIT
DO $$
DECLARE
rows_updated integer := 1;
BEGIN
WHILE rows_updated > 0 LOOP
UPDATE order_doc
SET priority = 0
WHERE id IN (
SELECT id FROM order_doc
WHERE priority IS NULL
LIMIT 10000
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
COMMIT; -- между батчами
PERFORM pg_sleep(0.1); -- даём дыхание replica/autovacuum
END LOOP;
END $$;
PG-M-111 На очень больших таблицах — отдельный backfill-job в коде, не в миграции. Миграция должна выполниться за минуту максимум.
@Scheduled(cron = "0 * * * * *")
public void backfillPriority() {
int updated;
do {
updated = jdbc.update("""
UPDATE order_doc SET priority = 0
WHERE id IN (
SELECT id FROM order_doc
WHERE priority IS NULL
LIMIT 10000
FOR UPDATE SKIP LOCKED
)
""");
} while (updated == 10000);
}
SKIP LOCKED чтобы не конфликтовать с пользовательскими UPDATE-ами.
6. Rollback миграций — почему forward-fix лучше
PG-M-120 down-миграции (rollback) почти всегда не работают на проде.
Liquibase <rollback> блок предполагает, что можно «открутить» изменение. На практике:
- Если миграция применилась + работала с данными — rollback теряет данные (
DROP COLUMNпосле backfill необратим). - Если миграция в составе нескольких — rollback одной разрушает консистентность.
- Если уже видели pull request с rollback'ом — это значит «приходится крутить туда-сюда», что само по себе плохой знак.
PG-M-121 Реальный «откат» — это новая forward-миграция, отменяющая последствия. Если catastrophe — restore из backup, не rollback.
7. Координация с приложением
PG-M-130 Стандартный flow деплоя:
- Миграция накатывается.
- Один-два инстанса приложения обновляются (canary).
- Через минут 5 — остальные инстансы.
- Через час-сутки — следующая миграция, если есть «фаза 2».
PG-M-131 Миграция совместима с предыдущей версией кода (N-1). Это значит:
ADD COLUMNвсегда нullable (или с default).- Не добавлять
NOT NULLconstraint, пока старый код может писать строки без значения. - Не дропать колонки, пока в коде есть упоминания.
PG-M-132 CI-проверка совместимости. Запустить тесты предыдущей версии кода против новой схемы. Если упало — миграция не N-1.
8. Lint миграций
PG-M-140 squawk — линтер для миграций. Ловит:
ALTER TABLE ... ADD COLUMNсDEFAULT(для PG <11 — переписывает таблицу).CREATE INDEXбезCONCURRENTLY.ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEYбезNOT VALID.ALTER TABLE ... ALTER COLUMN ... TYPE(переписывает таблицу).- Renaming колонок/таблиц.
DROP COLUMN.
squawk db/changelog/v0042.sql
Подключи в pre-commit hook + CI.
9. Чек-лист на ревью миграции
- Это transaction-safe? Можно ли откатить, не потеряв данные? (Обычно нет — спросить, если кажется надо.)
ALTER TABLEберётACCESS EXCLUSIVE? Если да — естьlock_timeout?CREATE INDEXимеетCONCURRENTLY? Если да —runInTransaction="false"в Liquibase?SET NOT NULL? Используется паттернCHECK NOT VALID + VALIDATE + SET NOT NULL?- Foreign key?
NOT VALID+ отдельныйVALIDATE? - Renaming колонки/таблицы? Расписан expand-contract на 3+ релиза?
UPDATEмиллионов строк? Вынесено в backfill-job, не в миграцию?DROP COLUMN? Подтверждено, что код не использует колонку?ALTER TYPE? Переписывает таблицу — есть план через теневую колонку?- N-1 совместимость? Старая версия кода работает с новой схемой?
Чек-лист DDL для скилла
- [ ]
SET LOCAL lock_timeout = '3s'в начале миграции сALTER TABLE. - [ ]
CREATE INDEX/DROP INDEX— сCONCURRENTLY,runInTransaction="false". - [ ]
ADD CONSTRAINT FOREIGN KEY— сNOT VALID+ отдельныйVALIDATE. - [ ]
SET NOT NULLна больших таблицах — черезCHECK NOT VALID + VALIDATE + SET NOT NULL + DROP CHECK. - [ ]
ADD COLUMN NOT NULL DEFAULT— только PG11+. - [ ]
RENAME COLUMN/DROP COLUMN— расписан expand-contract. - [ ]
ALTER TYPE— через теневую колонку, не одним statement. - [ ] Удаление значения из enum — через теневой тип.
- [ ] Big
UPDATE(>1M строк) — батчами или вынесено в backfill-job. - [ ] N-1 совместимость с предыдущей версией кода.
- [ ] Нет
down-миграций (или явно описан, почему форвард не подходит). - [ ] Подключён
squawklint.
Связанные
- WAL — миграции с
UPDATE/CREATE INDEXгенерят гигабайты WAL. - Блокировки —
lock_timeout,ACCESS EXCLUSIVE, behavior. - VACUUM и bloat —
VACUUM ANALYZEпосле big-миграции. - Composite-индексы —
CONCURRENTLYобязателен.