Опирается на правила: PG-M-001PG-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:

  1. ADD COLUMN priority NULL + код пишет.
  2. UPDATE ... SET priority = ... WHERE priority IS NULL; батчами.
  3. 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:

  1. ADD COLUMN new_name <type> + dual-write.
  2. Backfill UPDATE table SET new_name = old_name WHERE new_name IS NULL батчами.
  3. Код читает new_name, dual-write остаётся.
  4. Код только new_name.
  5. 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:

  1. ADD COLUMN amount_v2 numeric(15, 2) + dual-write.
  2. Backfill.
  3. Код читает v2, dual-write.
  4. Код только v2.
  5. 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: нативно невозможно.

  1. CREATE TYPE order_status_v2 AS ENUM ('NEW','PAID','SHIPPED');.
  2. ADD COLUMN status_v2 order_status_v2.
  3. Backfill UPDATE order_doc SET status_v2 = status::text::order_status_v2.
  4. Релиз: dual-write, read v2.
  5. 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:

  1. Миграция накатывается.
  2. Один-два инстанса (canary).
  3. Через 5 минут — остальные.
  4. Через час-сутки — следующая фаза, если есть.

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.

Чек-лист ревью

  1. ALTER TABLE с ACCESS EXCLUSIVE — есть lock_timeout?
  2. CREATE INDEX с CONCURRENTLY + runInTransaction="false"?
  3. SET NOT NULL через CHECK NOT VALID + VALIDATE?
  4. FK через NOT VALID + VALIDATE?
  5. Rename? — expand-contract на 3+ релиза?
  6. UPDATE миллионов — backfill-job, не миграция?
  7. DROP COLUMN — код не использует?
  8. ALTER TYPE — теневая колонка?
  9. N-1 совместимость?

Что запрещено

АнтипаттернПравилоЧто взамен
ALTER TYPE напрямую на большойPG-M-050теневая колонка
SET NOT NULL без CHECK NOT VALIDPG-M-031CHECK + VALIDATE + SET NOT NULL
CREATE INDEX без CONCURRENTLYPG-M-080CONCURRENTLY
ADD FK напрямуюPG-M-070NOT VALID + VALIDATE
UPDATE миллионов в миграцииPG-M-110backfill-job
RENAME COLUMN без expand-contractPG-M-0403+ релиза
Удаление enum-значения через DROPPG-M-090теневой тип
Миграция без lock_timeoutPG-M-022SET LOCAL lock_timeout = '3s'
Doверять <rollback> LiquibasePG-M-120forward-fix

Куда дальше

  • PG → Миграции — нормативные формулировки.
  • Блокировки — ACCESS EXCLUSIVE детали.
  • Spring @Transactional — backfill в @Scheduled.
  • Enum, boolean и перечисления — удаление enum-значения.
  • REST API → versioning — координация с API.
  • Distributed → idempotency — dual-write idempotent.