Миграция БД — это не «выполнил 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+ релиза.

Шаги:

  1. Expand — добавили новое (старое работает).
  2. Migrate data + dual-write в коде (пишем и в старое, и в новое).
  3. Switch reads — читатели переключены на новое.
  4. 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. Релиз 1: ADD COLUMN priority integer NULL + код пишет значение в новых строках.
  2. Релиз 2 (через время или миграция): UPDATE order_doc SET priority = 0 WHERE priority IS NULL; (батчами).
  3. Релиз 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. Релиз 1: ADD COLUMN new_name <type>; + dual-write в коде (UPDATE ... SET new_name = ?, old_name = ?).
  2. Бэкфилл: UPDATE table SET new_name = old_name WHERE new_name IS NULL; (батчами).
  3. Релиз 2: код читает из new_name, продолжает dual-write.
  4. Релиз 3: код только new_name.
  5. Миграция: 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. Релиз 1: ADD COLUMN amount_v2 numeric(15, 2); + dual-write (amount, amount_v2).
  2. Бэкфилл: UPDATE order_doc SET amount_v2 = amount WHERE amount_v2 IS NULL; батчами.
  3. Релиз 2: код читает amount_v2, dual-write остаётся.
  4. Релиз 3: код только amount_v2.
  5. Миграция: 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. Релиз 1: код перестал писать в колонку (только пишет, можно оставить NULL).
  2. Релиз 2: код перестал упоминать колонку вообще.
  3. Миграция: 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 берёт SHARE lock — блокирует INSERT/UPDATE/DELETE до конца построения. На big table — десятки минут даунтайма записи.
  • DROP INDEX берёт ACCESS EXCLUSIVE — блокирует и чтение тоже.

С CONCURRENTLY:

  • CREATE INDEX CONCURRENTLY — два прохода без блокировки. Минусы: нельзя в транзакции, в Liquibase runInTransaction="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 через теневой тип.

Шаги:

  1. Создать новый тип: CREATE TYPE order_status_v2 AS ENUM ('NEW','PAID','SHIPPED');.
  2. Добавить временную колонку: ALTER TABLE order_doc ADD COLUMN status_v2 order_status_v2;.
  3. Бэкфилл: UPDATE order_doc SET status_v2 = status::text::order_status_v2; (для удаляемого значения — отдельно мапнуть в новое).
  4. Релиз кода: dual-write обоих, читать новый.
  5. Удалить старую колонку, переименовать новую:
    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. Релиз 1: код перестал ссылаться на таблицу.
  2. Релиз 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 деплоя:

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

PG-M-131 Миграция совместима с предыдущей версией кода (N-1). Это значит:

  • ADD COLUMN всегда нullable (или с default).
  • Не добавлять NOT NULL constraint, пока старый код может писать строки без значения.
  • Не дропать колонки, пока в коде есть упоминания.

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. Чек-лист на ревью миграции

  1. Это transaction-safe? Можно ли откатить, не потеряв данные? (Обычно нет — спросить, если кажется надо.)
  2. ALTER TABLE берёт ACCESS EXCLUSIVE? Если да — есть lock_timeout?
  3. CREATE INDEX имеет CONCURRENTLY? Если да — runInTransaction="false" в Liquibase?
  4. SET NOT NULL? Используется паттерн CHECK NOT VALID + VALIDATE + SET NOT NULL?
  5. Foreign key? NOT VALID + отдельный VALIDATE?
  6. Renaming колонки/таблицы? Расписан expand-contract на 3+ релиза?
  7. UPDATE миллионов строк? Вынесено в backfill-job, не в миграцию?
  8. DROP COLUMN? Подтверждено, что код не использует колонку?
  9. ALTER TYPE? Переписывает таблицу — есть план через теневую колонку?
  10. 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-миграций (или явно описан, почему форвард не подходит).
  • [ ] Подключён squawk lint.

Связанные

  • WAL — миграции с UPDATE/CREATE INDEX генерят гигабайты WAL.
  • Блокировки — lock_timeout, ACCESS EXCLUSIVE, behavior.
  • VACUUM и bloat — VACUUM ANALYZE после big-миграции.
  • Composite-индексы — CONCURRENTLY обязателен.