Опирается на правила:
PG-M-001…PG-M-140из PostgreSQL Style Guide → раздел Миграции.
Важно знать
ALTER TABLEберётACCESS EXCLUSIVE— блокирует всё. На 100M строк — минуты-часы.- Правило N-1: миграция совместима с предыдущей версией кода.
- Expand-Contract: breaking-изменение режется на 3+ релиза.
SET LOCAL lock_timeout = '3s'в начале каждой миграции.SET NOT NULLчерезCHECK NOT VALID+VALIDATE+SET NOT NULL.ADD FKчерезNOT VALID+VALIDATEотдельно.CREATE INDEX/DROP INDEXвсегдаCONCURRENTLY,runInTransaction="false".- Удаление enum-значения нативно невозможно — теневой тип.
UPDATEмиллионов — батчами 10K +SKIP LOCKED+ COMMIT между.- Rollback почти не работает — forward-fix или restore из backup.
ALTER TABLE берёт ACCESS EXCLUSIVE lock и блокирует всё — INSERT, UPDATE, SELECT. UCP формулирует, как менять схему так, чтобы прод не падал.
Breaking change
PG-M-001..002:
Не-breaking (можно катить):
ADD COLUMN ... NULL(PG11+ сDEFAULTтоже).ADD INDEX CONCURRENTLY.CREATE TABLE.ADD CONSTRAINT ... NOT VALID.
Breaking (expand-contract):
DROP COLUMN,RENAME COLUMN.ALTER TYPE(изменение типа).ADD COLUMN ... NOT NULLбез default на больших.- Удаление значения из enum.
- Сужение
CHECK.
N-1: деплой = миграция → новый код. Между ними окно, где старый код работает с новой схемой. Если миграция breaking — старый код упадёт.
Expand-Contract
PG-M-010: 3+ релиза.
Релиз 1 (expand): миграция: добавить новую колонку/таблицу
код: пишет в старое, опционально dual-write
Релиз 2 (migrate): миграция: backfill в новое
код: dual-write обязателен; читатели на старом
Релиз 3 (switch reads): код: читатели на новом
миграция: ничего
Релиз 4 (contract): миграция: дропнуть старое
код: убрать упоминания
Каждый релиз откатывается независимо.
ACCESS EXCLUSIVE — что переписывает таблицу
PG-M-020..022:
| Операция | Перепис. | Lock |
|---|---|---|
ADD COLUMN NULL (PG11+) | нет | ACCESS EXCLUSIVE кратко |
ADD COLUMN NOT NULL DEFAULT 'X' (PG11+) | нет | ACCESS EXCLUSIVE кратко |
DROP COLUMN | нет (invisible) | ACCESS EXCLUSIVE кратко |
ALTER TYPE (с приведением) | да, всю | ACCESS EXCLUSIVE минуты-часы |
SET NOT NULL | нет, но проверяет каждую строку | ACCESS EXCLUSIVE |
ADD CHECK / ADD FK | нет, проверяет каждую | ACCESS EXCLUSIVE |
ADD CONSTRAINT NOT VALID | нет, не проверяет | ACCESS EXCLUSIVE кратко |
ACCESS EXCLUSIVE встаёт в очередь после всех ждущих и блокирует всех новых. Если хоть один долгий SELECT идёт — миграция ждёт его, за ней копится очередь новых.
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE order_doc ADD COLUMN ...;
COMMIT;
Рецепты
ADD COLUMN NOT NULL
PG-M-030..031:
PG11+:
ALTER TABLE order_doc ADD COLUMN priority integer NOT NULL DEFAULT 0;
-- мгновенно, default «виртуально»
Для динамических defaults (gen_random_uuid()) — expand-contract:
ADD COLUMN priority NULL+ код пишет.UPDATE ... SET priority = ... WHERE priority IS NULL;батчами.SET NOT NULLчерезCHECK NOT VALID+VALIDATE:
ALTER TABLE order_doc ADD CONSTRAINT ck_order_priority_not_null
CHECK (priority IS NOT NULL) NOT VALID;
ALTER TABLE order_doc VALIDATE CONSTRAINT ck_order_priority_not_null;
ALTER TABLE order_doc ALTER COLUMN priority SET NOT NULL;
ALTER TABLE order_doc DROP CONSTRAINT ck_order_priority_not_null;
CHECK NOT VALID мгновенно. VALIDATE берёт SHARE UPDATE EXCLUSIVE (не блокирует чтение/запись). SET NOT NULL теперь дёшево — PG знает из CHECK что null-ов нет.
RENAME COLUMN
PG-M-040: невозможно одним коммитом.
Steps:
ADD COLUMN new_name <type>+ dual-write.- Backfill
UPDATE table SET new_name = old_name WHERE new_name IS NULLбатчами. - Код читает new_name, dual-write остаётся.
- Код только new_name.
DROP COLUMN old_name.
Альтернатива — view-обёртка (только read-only):
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;
ALTER TYPE
PG-M-050: переписывает таблицу.
Expand-contract:
ADD COLUMN amount_v2 numeric(15, 2)+ dual-write.- Backfill.
- Код читает v2, dual-write.
- Код только v2.
DROP COLUMN amount,RENAME amount_v2 TO amount.
Исключения (без переписи):
varchar → text— мгновенно.varchar(50) → varchar(100)— мгновенно (расширение).varchar(100) → varchar(50)— переписывает.int → bigint— переписывает.
ADD FK
PG-M-070:
-- 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:
CREATE UNIQUE INDEX CONCURRENTLY uk_customer_email ON customer (email);
ALTER TABLE customer ADD CONSTRAINT uk_customer_email UNIQUE USING INDEX uk_customer_email;
Индексы
PG-M-080: всегда CONCURRENTLY.
<changeSet id="20260507-add-status-index" runInTransaction="false">
<sql>CREATE INDEX CONCURRENTLY ix_customer_status ON customer (status);</sql>
<rollback>DROP INDEX CONCURRENTLY IF EXISTS ix_customer_status;</rollback>
</changeSet>
Без CONCURRENTLY:
CREATE INDEX— SHARE lock, блокирует INSERT/UPDATE/DELETE.DROP INDEX— ACCESS EXCLUSIVE, блокирует SELECT тоже.
Если CONCURRENTLY упало — индекс остаётся INVALID, надо DROP + пересоздать.
Удаление enum-значения
PG-M-090: нативно невозможно.
CREATE TYPE order_status_v2 AS ENUM ('NEW','PAID','SHIPPED');.ADD COLUMN status_v2 order_status_v2.- Backfill
UPDATE order_doc SET status_v2 = status::text::order_status_v2. - Релиз: dual-write, read v2.
DROP COLUMN status; RENAME status_v2 TO status; DROP TYPE order_status;.
ADD VALUE мгновенно (PG12+), но нельзя использовать в той же транзакции — разные changeset.
Long-running migrations
PG-M-110..111: батчами.
-- ✗ — одна гигантская TX, копит WAL, блокирует autovacuum
UPDATE order_doc SET priority = 0 WHERE priority IS NULL;
-- ✓ — батчи с COMMIT между
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);
END LOOP;
END $$;
На очень больших — 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-ами. Миграция должна выполниться за минуту максимум.
Rollback не работает
PG-M-120..121: forward-fix.
Liquibase <rollback> не работает на проде:
- Backfill необратим (
DROP COLUMNтеряет данные). - Rollback одной из цепочки рушит консистентность.
Реальный откат = новая forward-миграция или restore из backup.
Координация с приложением
PG-M-130..132:
- Миграция накатывается.
- Один-два инстанса (canary).
- Через 5 минут — остальные.
- Через час-сутки — следующая фаза, если есть.
CI: запустить тесты предыдущей версии кода против новой схемы (N-1 совместимость).
squawk
PG-M-140: линтер.
Ловит:
ADD COLUMNсDEFAULTна PG <11.CREATE INDEXбезCONCURRENTLY.ADD FKбезNOT VALID.ALTER TYPE.- Renaming колонок/таблиц.
DROP COLUMN.
squawk db/changelog/v0042.sql
Pre-commit hook + CI.
Чек-лист ревью
ALTER TABLEсACCESS EXCLUSIVE— естьlock_timeout?CREATE INDEXсCONCURRENTLY+runInTransaction="false"?SET NOT NULLчерез CHECK NOT VALID + VALIDATE?- FK через NOT VALID + VALIDATE?
- Rename? — expand-contract на 3+ релиза?
UPDATEмиллионов — backfill-job, не миграция?DROP COLUMN— код не использует?ALTER TYPE— теневая колонка?- N-1 совместимость?
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
ALTER TYPE напрямую на большой | PG-M-050 | теневая колонка |
SET NOT NULL без CHECK NOT VALID | PG-M-031 | CHECK + VALIDATE + SET NOT NULL |
CREATE INDEX без CONCURRENTLY | PG-M-080 | CONCURRENTLY |
ADD FK напрямую | PG-M-070 | NOT VALID + VALIDATE |
UPDATE миллионов в миграции | PG-M-110 | backfill-job |
RENAME COLUMN без expand-contract | PG-M-040 | 3+ релиза |
| Удаление enum-значения через DROP | PG-M-090 | теневой тип |
Миграция без lock_timeout | PG-M-022 | SET LOCAL lock_timeout = '3s' |
Doверять <rollback> Liquibase | PG-M-120 | forward-fix |
Куда дальше
- PG → Миграции — нормативные формулировки.
- Блокировки —
ACCESS EXCLUSIVEдетали. - Spring @Transactional — backfill в
@Scheduled. - Enum, boolean и перечисления — удаление enum-значения.
- REST API → versioning — координация с API.
- Distributed → idempotency — dual-write idempotent.