Изменить схему базы данных и не уронить прод — это отдельная наука. Разберём, почему обычные ALTER TABLE опасны на живом трафике, и как правильно накатывать изменения.
Слева — очередь к таблице. Долгий отчёт держит orders, ALTER TABLE ждёт за ним ACCESS EXCLUSIVE, а обычные SELECT и INSERT встают уже за ALTER: сервис молчит, хотя сама правка схемы занимает миллисекунды. Справа то же изменение разложено на четыре коротких релиза: старая колонка живёт рядом с новой, данные переносятся фоном, и на каждом шаге предыдущая версия кода продолжает работать.
Почему ALTER TABLE блокирует всё
Представьте, что вы добавляете колонку в таблицу с десятками миллионов строк. Пишете ALTER TABLE orders ADD COLUMN priority integer — сама правка занимает миллисекунды, а прод встаёт на несколько минут.
Причина: PostgreSQL для большинства изменений схемы берёт блокировку ACCESS EXCLUSIVE. Эта блокировка — самая жёсткая: она не пускает никого — ни SELECT, ни INSERT, ни UPDATE. И что ещё хуже, она встаёт в очередь за всеми текущими запросами. Если в момент миграции идёт долгий отчёт, ALTER TABLE ждёт его, а за ним копится очередь всех новых запросов.
Поэтому первое правило: каждая миграция начинается с SET LOCAL lock_timeout = '3s'. Если блокировку не удаётся получить за 3 секунды — миграция падает с ошибкой вместо того, чтобы подвесить прод.
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN priority integer;
COMMIT;
Только «упасть быстро» — это половина правила. Упавшую миграцию надо повторить, иначе на нагруженной таблице она не накатится вовсе, а правило прочитается как «миграция обязана падать». Поэтому такой changeset прогоняют несколькими попытками подряд с паузой между ними — руками, скриптом наката или повторным запуском задачи деплоя — либо выбирают время, когда долгих запросов меньше.
Вторая тонкость: SET LOCAL живёт только внутри транзакции. Если changeset помечен runInTransaction="false" — а так придётся сделать и для CONCURRENTLY, и для DO-блока с COMMIT ниже, — база ответит WARNING: SET LOCAL can only be used in transaction blocks, и таймаут останется нулевым. Там нужен SET lock_timeout = '3s' без LOCAL либо тот же параметр в строке подключения.
Миграция встала: кто её держит
lock_timeout спасает от бесконечного ожидания, но не отвечает на вопрос «почему не дали». Отвечает на него база, и смотреть надо в два места.
SELECT pid, usename, state, wait_event_type, wait_event,
left(query, 80) AS query, now() - xact_start AS xact_age
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY xact_start;
SELECT pid, pg_blocking_pids(pid) AS kto_derzhit, left(query, 60) AS query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
Первый запрос показывает всех, второй — кто кого ждёт. Виновник почти всегда одного из трёх видов. Сессия в состоянии idle in transaction — кто-то открыл транзакцию и ушёл; её снимают pg_terminate_backend(pid) без угрызений совести, потому что она всё равно ничего не делает. Долгий SELECT по той же таблице — миграция ждёт его честно, и снимать его надо осознанно. И автоматическая уборка в режиме предотвращения переполнения счётчика транзакций — эту не трогают, она важнее миграции.
Тонкость, которая портит первую попытку: ALTER TABLE встаёт в очередь за блокировкой, и пока он в ней стоит, за ним копятся все остальные запросы к таблице. То есть одна забытая транзакция плюс ожидающая миграция останавливают работу с таблицей целиком, хотя сама миграция ещё не начиналась. Поэтому lock_timeout ставят маленьким (секунда-две) и повторяют попытку, а не ждут долго.
Одна команда — один шаг миграции
Несколько ALTER TABLE в одной транзакции держат тяжёлую блокировку всё время до коммита, а не каждая по отдельности. Пять быстрых изменений, собранных в один файл миграции, дают одно окно блокировки длиной в сумму всех пяти — плюс ожидание блокировки в начале.
Отсюда правило: одна команда — один шаг (в Liquibase один changeSet, в Flyway одна миграция или явный --no-transaction). Тогда каждая берёт блокировку, делает своё дело и отпускает, а остальной трафик проходит в промежутках. И отдельно: команды, которые вообще не выполняются в транзакции (CREATE INDEX CONCURRENTLY, DROP INDEX CONCURRENTLY, ALTER TYPE … ADD VALUE в старых версиях), обязаны жить в своём шаге с отключённой транзакцией.
Breaking change и N-1 совместимость
Между накатом миграции и стартом нового кода есть минуты, а при откате выпуска часы, когда старый код работает с новой схемой. Поэтому изменения схемы делят не по размеру, а по двум вопросам: переживёт ли их старый код и как долго они держат блокировку.
Безопасные изменения (мгновенные, таблицу не переписывают, старый код переживают):
ADD COLUMN ... NULL— колонка без ограниченияNOT NULLADD COLUMN ... NOT NULL DEFAULT 'x'(PostgreSQL 11+, постоянное значение)CREATE TABLEADD CONSTRAINT ... NOT VALID
Опасные изменения требуют особого подхода, но опасны по двум разным причинам — их полезно не путать.
Одни долго держат блокировку, потому что переписывают таблицу целиком:
- изменение типа колонки (
ALTER TYPE) ADD COLUMN NOT NULLбез значения по умолчанию на большой таблице
Другие отрабатывают мгновенно, а ломают уже работающий код:
DROP COLUMN,RENAME COLUMN- удаление значения из enum-типа
DROP COLUMN данные не трогает: он только помечает колонку удалённой в системном каталоге, и на таблице в 40 млн строк это миллисекунды. Беда не в нём самом, а в том, что старый код продолжает эту колонку спрашивать.
Опасные изменения ещё называют breaking change — они могут сломать уже работающий код. Причём проблема не только в блокировке, но и во времени деплоя. Типичный деплой: сначала накатывается миграция, потом запускается новая версия приложения. Между ними есть окно, где старая версия кода работает с новой схемой. Если схема изменилась несовместимо — старый код упадёт.
Это правило называют N-1 совместимостью: миграция должна быть совместима с предыдущей версией кода.
Сколько на самом деле живёт окно N-1 — вопрос не теоретический. При постепенной выкатке старые и новые поды работают одновременно минуты, иногда десятки минут. При откате релиза (а откат — штатная операция) старый код возвращается в работу на часы, пока чинят причину. А если релизы выкатывают раз в неделю, окно между шагами расширения и сжатия — неделя.
Отсюда правило, которое нарушают чаще всего: шаги нельзя склеивать в один релиз. «Добавили колонку и в том же релизе перестали писать в старую» означает, что при откате приложение снова начнёт писать в старую колонку, а новые строки уже без неё. Между шагом «пишем в обе» и шагом «читаем только новую» должен пройти хотя бы один полностью выкаченный и не откаченный релиз.
Expand-Contract: одно изменение за несколько релизов
Чтобы безопасно сделать опасное изменение, его разбивают на несколько релизов. Этот паттерн называется Expand-Contract (расширить — мигрировать — свернуть).
| Релиз | Что делаем со схемой | Что в это время делает код |
|---|---|---|
| 1 — расширение | добавить новую структуру: колонку, таблицу | пишет в старое место, при желании и в новое |
| 2 — миграция | перенести данные из старого в новое (backfill) | пишет в оба места, читает из старого |
| 3 — чтение нового | схему не трогаем | читает из нового, пишет по-прежнему в оба |
| 4 — свёртывание | удалить старое | старого места в коде уже нет |
Каждый релиз может быть откачен независимо. Если что-то пошло не так в релизе 2 — можно вернуться к релизу 1 без потери данных.
Механику видно на маленькой программе: строка таблицы — набор пар «колонка → значение», старый код читает amount, новый — total_amount.
живой пример
import java.util.LinkedHashMap;
import java.util.Map;
public class RenameDemo {
static Map<String, Long> schema(String... columns) {
Map<String, Long> row = new LinkedHashMap<>();
for (String column : columns) {
row.put(column, 1200L);
}
return row;
}
static String read(Map<String, Long> row, String column) {
Long value = row.get(column);
return value == null ? "упал: нет колонки " + column : "прочитал " + value;
}
public static void main(String[] args) {
Map<String, Long> renamed = schema("total_amount");
Map<String, Long> expanded = schema("amount", "total_amount");
System.out.println("RENAME одним релизом, старый код: " + read(renamed, "amount"));
System.out.println("релизы 1-3, старый код: " + read(expanded, "amount"));
System.out.println("релизы 1-3, новый код: " + read(expanded, "total_amount"));
System.out.println("релиз 4, новый код: " + read(renamed, "total_amount"));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Первая строка вывода — обычный RENAME: схема новая, а старый код ищет колонку, которой нет. Дальше обе колонки живут рядом, работают обе версии кода, и старую убирают, когда старого кода не осталось.
Откат миграций почти не работает
Секция <rollback> в Liquibase или Flyway создаёт иллюзию безопасности. На практике откатить миграцию на продуктивной базе почти невозможно:
- Если удалена колонка — данные потеряны
- Если нарушена целостность данных — откат не восстановит состояние
- Одна миграция в цепочке expand-contract откатиться не может без разрушения всей цепочки
Реальные варианты при проблеме:
- Forward-fix — написать новую миграцию, которая исправляет ситуацию
- Восстановление из резервной копии — если данные потеряны
Именно поэтому expand-contract так важен: каждый шаг безопасен сам по себе и не ломает предыдущее состояние.
Как это выглядит на практике. Миграция добавила NOT NULL на колонку, где оказались пустые значения, и упала на середине; часть строк обновлена, часть нет, схема в промежуточном состоянии. Попытка «откатить» вернёт схему, но не данные, которые миграция успела переписать.
Правильный ход — починка вперёд, отдельной миграцией:
-- V43__fix_status_backfill.sql
UPDATE orders SET status = 'UNKNOWN' WHERE status IS NULL;
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
Три правила для таких починок. Она должна быть идемпотентной: условие WHERE status IS NULL не сломается, если часть строк уже обновлена, а ADD COLUMN IF NOT EXISTS и DROP INDEX IF EXISTS переживут повторный прогон. Она должна быть отдельным файлом с новым номером, а не правкой упавшего — упавшую миграцию инструмент помнит, и её контрольная сумма уже записана. И перед ней снимают отметку о неудаче: flyway repair или liquibase clearCheckSums, иначе цепочка не пойдёт дальше.
squawk — линтер для миграций
squawk — инструмент статического анализа SQL-миграций. Он находит опасные паттерны до того, как они попадут на прод:
ADD COLUMNсDEFAULTна PostgreSQL ниже 11CREATE INDEXбезCONCURRENTLYADD FOREIGN KEYбезNOT VALIDALTER TYPEс переписью таблицы- переименования колонок и таблиц
squawk db/changelog/v0042.sql
Squawk стоит добавить в pre-commit хук и в CI — так опасные миграции не пройдут ревью незамеченными.
Одна настройка, без которой линтер ругается зря: --pg-version. Правила у него зависят от мажорной версии — то, что было опасно в PostgreSQL 10, в 12-й безопасно (ADD COLUMN … DEFAULT без перезаписи таблицы), а в 11-й ещё нет. Без явного указания версии squawk берёт умолчание и предупреждает о вещах, которые на вашей базе безвредны, — а команда быстро учится не читать его вывод.
squawk --pg-version=17 migrations/*.sql
Глубже: Как добавить NOT NULL колонкурасширенное
PostgreSQL 11 и новее умеют добавлять колонку с константным значением по умолчанию мгновенно — значение хранится «виртуально» в метаданных, а не пишется в каждую строку:
-- Мгновенно даже на 100M строк
ALTER TABLE orders ADD COLUMN priority integer NOT NULL DEFAULT 0;
Быстрый путь работает, пока значение по умолчанию можно вычислить один раз на всю таблицу. NOW() под это подходит: база берёт время начала транзакции, кладёт одно и то же значение в метаданные, и старые строки не переписываются — на 200 000 строк файл таблицы не вырастает ни на байт. А вот gen_random_uuid() каждой строке обязан дать своё значение — «виртуально» такое не хранится, и PostgreSQL пойдёт переписывать всю таблицу.
Критерий простой: переписывание включает volatile-функция — та, что при каждом вызове возвращает новый результат. gen_random_uuid(), random(), clock_timestamp() — volatile. now() — stable, вызывается один раз. Если в DEFAULT попала volatile-функция, нужен expand-contract:
- Добавить колонку без
NOT NULL - Заполнить существующие строки порциями (см. раздел про backfill)
- Установить ограничение безопасным способом:
-- Шаг 3a: добавить CHECK-ограничение, не проверяя старые строки
ALTER TABLE orders ADD CONSTRAINT ck_orders_priority_not_null
CHECK (priority IS NOT NULL) NOT VALID;
-- Шаг 3b: проверить существующие строки (не блокирует запись)
ALTER TABLE orders VALIDATE CONSTRAINT ck_orders_priority_not_null;
-- Шаг 3c: SET NOT NULL дёшев — PostgreSQL 12+ доверяет проверенному CHECK
ALTER TABLE orders ALTER COLUMN priority SET NOT NULL;
-- Шаг 3d: убрать временный CHECK
ALTER TABLE orders DROP CONSTRAINT ck_orders_priority_not_null;
CHECK NOT VALID выполняется мгновенно. VALIDATE берёт мягкую блокировку SHARE UPDATE EXCLUSIVE, которая не мешает обычным запросам.
Вся эта последовательность держится на шаге 3c, а он появился в PostgreSQL 12: только там SET NOT NULL умеет поверить уже проверенному CHECK и не идти по строкам. На PostgreSQL 11 и ниже четыре шага бессмысленны — SET NOT NULL всё равно просканирует таблицу целиком под ACCESS EXCLUSIVE, и выигрыша не будет.
Глубже: Как переименовать колонкурасширенное
RENAME COLUMN — нельзя сделать в один релиз. Старый код сразу сломается, потому что будет обращаться к несуществующей колонке.
Безопасная последовательность:
- Добавить новую колонку (
ADD COLUMN new_name <тип>) - Код начинает писать в обе колонки
- Перенести существующие данные порциями
- Код начинает читать из новой колонки
- Код перестаёт писать в старую
- Удалить старую колонку (
DROP COLUMN old_name)
Для read-only сценариев есть более простой вариант — подменить таблицу представлением. Идея такая: старый код обращается к orders и переименовывать его никто не будет, поэтому имя orders должно остаться доступным. Значит, уезжает сама таблица, а на её месте появляется представление со старым набором колонок:
ALTER TABLE orders RENAME TO orders_tbl;
ALTER TABLE orders_tbl RENAME COLUMN old_name TO new_name;
CREATE VIEW orders AS SELECT *, new_name AS old_name FROM orders_tbl;
Строка за строкой: таблица стала orders_tbl и получила новое имя колонки, а представление orders заняло старое имя таблицы и отдаёт новую колонку ещё и под старым именем. Старый код читает orders.old_name и ничего не замечает, новый работает с orders_tbl.new_name.
Старый код продолжает читать orders и видит привычное имя колонки, новый работает с orders_tbl напрямую. Когда старого кода не останется, представление удаляют, а таблице возвращают исходное имя.
Записи такое представление, вопреки ожиданиям, не мешает: old_name здесь — простая ссылка на колонку, представление получается автообновляемым, и INSERT INTO orders (id, old_name) VALUES (1,'x') спокойно кладёт строку в базовую таблицу. Ловушка в другом: в представлении теперь на одну колонку больше, чем было в таблице. Старый код, который делает SELECT *, получит и new_name, и old_name — то есть лишнюю колонку, а в него часто упирается маппинг строки в объект. И INSERT INTO orders VALUES (...) без списка колонок начинает раскладывать значения по новому порядку. Оба места лечатся одинаково: перечислять колонки явно, а не полагаться на *.
Глубже: Как изменить тип колонкирасширенное
ALTER COLUMN ... SET DATA TYPE с приведением типов переписывает всю таблицу — получается то же, что ALTER TABLE на миллионах строк. Блокировка ACCESS EXCLUSIVE на долгое время.
Исключения, которые работают мгновенно (без переписи):
varchar→text(расширение без приведения)varchar(50)→varchar(100)(только расширение)
Для всего остального (например, integer → bigint) нужен expand-contract: добавить теневую колонку нового типа, заполнить данными, переключить код.
Глубже: Как добавить внешний ключрасширенное
Обычный ADD FOREIGN KEY блокирует обе таблицы и проверяет все существующие строки. На больших таблицах — долго.
Безопасный способ — двухшаговый:
-- Шаг 1: создать FK, не проверяя существующие строки (мгновенно)
ALTER TABLE order_items
ADD CONSTRAINT fk_order_items_order_id
FOREIGN KEY (order_id) REFERENCES orders(id)
NOT VALID;
-- Шаг 2: проверить существующие строки (не блокирует запись)
ALTER TABLE order_items VALIDATE CONSTRAINT fk_order_items_order_id;
NOT VALID означает: новые строки будут проверяться сразу, старые — при VALIDATE.
Глубже: Как добавить индексрасширенное
CREATE INDEX без дополнительных ключевых слов берёт блокировку SHARE, которая не пускает INSERT/UPDATE/DELETE. На большой таблице — несколько минут без записи.
Решение — CREATE INDEX CONCURRENTLY. Он строит индекс в несколько проходов без блокировки:
CREATE INDEX CONCURRENTLY ix_orders_status ON orders (status);
DROP INDEX тоже берёт жёсткую блокировку — используйте DROP INDEX CONCURRENTLY.
Если CREATE INDEX CONCURRENTLY был прерван, он оставляет INVALID индекс. Его нужно удалить и создать заново:
DROP INDEX CONCURRENTLY IF EXISTS ix_orders_status;
CREATE INDEX CONCURRENTLY ix_orders_status ON orders (status);
Важная деталь для инструментов миграций (Liquibase, Flyway): CONCURRENTLY нельзя выполнить внутри транзакции. Changesets с индексами должны иметь runInTransaction="false":
<changeSet id="20260507-add-status-index" runInTransaction="false">
<sql>CREATE INDEX CONCURRENTLY ix_orders_status ON orders (status);</sql>
<rollback>DROP INDEX CONCURRENTLY IF EXISTS ix_orders_status;</rollback>
</changeSet>
У DROP INDEX CONCURRENTLY те же грабли, что у создания, и о них забывают: он тоже не выполняется внутри транзакции (значит, шаг миграции должен быть без неё) и не умеет CASCADE. Если на индексе висит ограничение (UNIQUE, первичный ключ), его так не удалить — сначала снимают ограничение, а оно уже уносит индекс с собой, но уже под тяжёлой блокировкой. Проверяют это заранее по pg_constraint, а не после отказа на проде.
Глубже: Как удалить значение из enumрасширенное
В PostgreSQL нет команды REMOVE VALUE FROM ENUM. Нативно удалить значение нельзя.
Единственный способ — создать новый тип без ненужного значения и переключить колонку на него:
CREATE TYPE order_status_v2 AS ENUM ('NEW', 'PAID', 'SHIPPED')(без удаляемого значения)ADD COLUMN status_v2 order_status_v2 NULL- Перенести данные порциями:
UPDATE orders SET status_v2 = status::text::order_status_v2 - Переключить код на чтение и запись в
status_v2 DROP COLUMN status,RENAME COLUMN status_v2 TO status,DROP TYPE order_status
Добавить значение (ADD VALUE) — наоборот, мгновенно: тип не переписывается. С PostgreSQL 12 такую команду разрешено выполнять внутри транзакции, но воспользоваться новым значением до её завершения всё равно нельзя — это должен быть отдельный changeset.
Глубже: Перенос данных порциямирасширенное
Большой UPDATE в одной транзакции — плохая идея: транзакция держит блокировки на всё время выполнения, копит WAL и мешает autovacuum.
Правильный подход — обновлять по небольшим частям с коммитом между ними:
DO $$
DECLARE rows_updated integer := 1;
BEGIN
WHILE rows_updated > 0 LOOP
UPDATE orders SET priority = 0
WHERE id IN (
SELECT id FROM orders
WHERE priority IS NULL
LIMIT 10000
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
COMMIT;
PERFORM pg_sleep(0.1);
END LOOP;
END $$;
COMMIT внутри DO работает, только если сам блок запущен не внутри транзакции: инструмент миграций такой changeset тоже должен выполнять с runInTransaction="false".
Для очень больших таблиц SQL-пакет в миграции всё равно занимает одно соединение и держит деплой. Надёжнее — фоновое задание в коде приложения: оно работает независимо от деплоя, его можно остановить и перезапустить. Используйте FOR UPDATE SKIP LOCKED, чтобы не конфликтовать с пользовательскими запросами:
@Component
public class BackfillPriorityJob {
private final JdbcTemplate jdbc;
public BackfillPriorityJob(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Scheduled(cron = "0 * * * * *")
public void backfillPriority() {
int updated;
do {
updated = jdbc.update("""
UPDATE orders SET priority = 0
WHERE id IN (
SELECT id FROM orders
WHERE priority IS NULL
LIMIT 10000
FOR UPDATE SKIP LOCKED
)
""");
} while (updated > 0);
}
}
Условие выхода тут именно updated > 0, а не updated == 10000. SKIP LOCKED пропускает строки, занятые пользовательскими транзакциями, — на живой базе порция почти всегда окажется чуть меньше запрошенной, и проверка на ровные 10 000 остановила бы работу на первой же такой порции, оставив часть NULL незаполненными.
Глубже: роли и права: кто владеет таблицами после миграциирасширенное
Миграции обычно запускают от роли с широкими правами, и однажды выясняется, что приложение не видит новую таблицу: permission denied for table order_events. Таблицей владеет тот, кто её создал, и права остальным не выдаются сами.
Схема прав, которая держится годами, простая: три роли. shop_owner владеет всеми объектами и запускает миграции. shop_app работает от имени приложения и умеет только SELECT, INSERT, UPDATE, DELETE по таблицам и USAGE по последовательностям. shop_readonly для отчётов и аналитиков, только чтение. Чтобы новые таблицы получали права сразу, их задают по умолчанию один раз:
ALTER DEFAULT PRIVILEGES FOR ROLE shop_owner IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO shop_app;
ALTER DEFAULT PRIVILEGES FOR ROLE shop_owner IN SCHEMA public
GRANT USAGE ON SEQUENCES TO shop_app;
ALTER DEFAULT PRIVILEGES FOR ROLE shop_owner IN SCHEMA public
GRANT SELECT ON TABLES TO shop_readonly;
Без строки про последовательности первая же вставка с IDENTITY упадёт: право на таблицу есть, на счётчик нет. Приложение не должно быть владельцем таблиц, и статья про multi-tenancy показывает, почему: владелец обходит политики RLS. Не должно оно быть и суперпользователем, потому что тогда любая инъекция или ошибка в коде становится правом на всё.
Кто вообще может подключиться, решает pg_hba.conf: по строке на сочетание «откуда, к какой базе, какой ролью, как проверять пароль». Рабочее правило для сервисов: подключения только из сети приложения, только с scram-sha-256, и hostssl вместо host, чтобы трафик шёл через TLS; строка trust допустима только на локальном стенде. Пароли ролей и параметры соединения живут в секретах, а не в миграциях и не в репозитории.
Глубже: переезд таблицы целиком: двойная запись и сверкарасширенное
Expand-contract на колонке — частный случай. Иногда переезжает таблица целиком: нужна другая схема, другой ключ разбиения, другой тип идентификатора. ALTER тут не поможет, и делают это в пять шагов, каждый из которых отдельный релиз.
Шаг 1. Новая таблица. Создаётся рядом, пустая, с нужной схемой и индексами. На работу приложения не влияет.
Шаг 2. Двойная запись. Код начинает писать в обе таблицы в одной транзакции: старая остаётся источником правды, новая наполняется. Важно, чтобы запись в новую не могла уронить операцию: ошибка на ней ловится и логируется, а не пробрасывается наверх, иначе переезд начнёт ломать основной сценарий.
Шаг 3. Перелив истории. Фоновое задание переносит старые строки порциями — теми же приёмами, что в разделе про перенос данных порциями выше: по ключу, пачками по несколько тысяч, с паузами, с записью прогресса, чтобы продолжить после остановки. Перелив пишет только те строки, которых в новой таблице ещё нет.
Шаг 4. Сверка. Шаг, который пропускают чаще всего и из-за которого переезды заканчиваются потерей данных. Сверяют не «на глаз», а запросом: сначала количество строк за одинаковые периоды, потом контрольные суммы по ключевым полям, потом выборочно строки, которые менялись во время перелива, — именно они попадают в щель между переливом и двойной записью.
SELECT count(*) FROM orders_old WHERE created_at >= :from AND created_at < :to;
SELECT count(*) FROM orders_new WHERE created_at >= :from AND created_at < :to;
SELECT id FROM orders_old
EXCEPT
SELECT id FROM orders_new;
Сверку гоняют по расписанию, пока идёт двойная запись, и расхождение должно быть нулевым несколько дней подряд.
Шаг 5. Переключение чтения и сжатие. Чтение переводят на новую таблицу — лучше под флагом, который можно вернуть обратно мгновенно, не выкатывая релиз. Живут так неделю: если что-то не так, флаг возвращают. Потом убирают двойную запись, а ещё через релиз — старую таблицу. Между «перестали читать» и «удалили» должен пройти срок, за который точно заметили бы проблему: удалённую таблицу возвращают только из резервной копии.
Коротко
ALTER TABLEберётACCESS EXCLUSIVEи встаёт в очередь за текущими запросами, а за ним копятся все новые: на большой таблице это минуты без сервиса. Кто именно держит таблицу, показываютpg_stat_activityиpg_blocking_pids(), и чаще всего это забытая сессияidle in transaction.- Каждая миграция начинается с
SET LOCAL lock_timeout = '3s'и повторяется несколькими попытками с паузой, пока не поймает окно: упасть быстро лучше, чем подвесить прод. Вне транзакции (runInTransaction="false") нуженSET lock_timeoutбезLOCAL, иначе таймаут останется нулевым. - N-1 совместимость: между накатом миграции и стартом новой версии старый код работает с новой схемой, и миграция обязана его пережить. Опасные изменения бывают двух разных видов: долгая перепись таблицы (
ALTER TYPE,ADD COLUMN NOT NULLбез значения по умолчанию) и мгновенные, но ломающие код (DROP COLUMN,RENAME COLUMN, удаление значения enum). - Expand-Contract разбивает опасное изменение на 4 релиза: добавить новое рядом со старым, перенести данные, переключить чтение, удалить старое; склеивать шаги нельзя — при откате релиза старый код вернётся в работу на часы. Переезд таблицы целиком устроен так же: двойная запись, фоновый перелив, ежедневная сверка, переключение чтения под флагом.
ADD COLUMN ... NOT NULL DEFAULT 0с PostgreSQL 11 мгновенна, пока значение по умолчанию вычисляется один раз на таблицу:now()подходит,gen_random_uuid()переписывает всё.NOT NULLна существующей колонке ставят черезCHECK ... NOT VALID,VALIDATE,SET NOT NULL(PostgreSQL 12+); внешний ключ так же:NOT VALIDи отдельныйVALIDATE.- Индексы только через
CREATE INDEX CONCURRENTLYиDROP INDEX CONCURRENTLY, вне транзакции (runInTransaction="false"). ПрерванныйCONCURRENTLYоставляетINVALIDиндекс: его удаляют и строят заново. - Значение enum нативно не удаляется: нужен новый тип и теневая колонка.
ADD VALUEмгновенен, но отдельным changeset. - Большой
UPDATEделают порциями по 10 000 строк сCOMMITмежду ними, надёжнее фоновым заданием в приложении сFOR UPDATE SKIP LOCKED; условие выходаupdated > 0, а неupdated == 10000. - Откат миграции на проде почти не работает: удалённые данные не вернуть, план на случай проблемы это forward-fix и резервная копия. Опасные паттерны до прода ловит линтер squawk в pre-commit и CI.
- Три роли: владелец для миграций, роль приложения без владения и суперправ, роль только для чтения; права на новые таблицы и последовательности выдают через
ALTER DEFAULT PRIVILEGES; доступ по сети ограничиваетpg_hba.confсscram-sha-256иhostssl.
Что почитать дальше
- Блокировки PostgreSQL — какие бывают уровни и почему
ACCESS EXCLUSIVEне пускает никого. - Типы индексов — что именно строит
CREATE INDEX CONCURRENTLY. - Enum, boolean и перечисления — почему значение легко добавить и нельзя убрать.
- VACUUM и bloat — что остаётся после переноса данных порциями и кто это подчищает.