Пошук уроків, статей та іншого контенту
Організуйте контрольовані зміни структури бази даних через послідовні міграції та зворотну сумісність.
Схема бази даних змінюється разом із програмою:
додаються таблиці та колонки;
змінюються обмеження;
створюються індекси;
переносяться або видаляються дані;
змінюються типи полів.
Якщо виконувати такі зміни вручну, швидко виникають проблеми:
невідомо, які зміни вже застосовано;
різні середовища мають різну схему;
неможливо відтворити базу даних з нуля;
незрозуміло, як повернутися до попередньої версії;
розгортання нової версії програми може зламати стару.
Міграція — це версійований набір SQL-команд, який переводить схему з одного стану в інший.
Замість опису лише поточного стану бази даних ми зберігаємо послідовність змін:
001_create_users.sql
002_add_display_name.sql
003_create_user_email_index.sqlПісля виконання всіх міграцій база має актуальну версію схеми.
Зазвичай у базі створюють службову таблицю, яка зберігає ідентифікатори виконаних міграцій:
CREATE TABLE IF NOT EXISTS schema_migrations (
version bigint PRIMARY KEY,
name text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now()
);Приклад записів:
version | name | applied_at
--------+-------------------------+--------------------------
1 | create_users | 2026-08-20 10:15:00+00
2 | add_display_name | 2026-08-20 10:16:12+00
3 | create_user_email_index | 2026-08-20 10:16:30+00Перед виконанням міграції інструмент перевіряє, чи є її version у цій таблиці. Якщо запис уже існує, міграція повторно не запускається.
Номер міграції має бути:
унікальним;
монотонно зростаючим;
незмінним після застосування;
достатньо однозначним для команди.
Наприклад, часто використовують формат:
20260820101500_add_display_name.sqlабо короткі послідовні номери:
001_create_users.sql
002_add_display_name.sqlОкремі міграції краще зберігати у системі контролю версій разом із кодом застосунку:
migrations/
├── 001_create_users.sql
├── 002_add_display_name.sql
└── 003_create_user_email_index.sqlКожна міграція повинна мати одну чітку мету. Не варто об'єднувати в один файл незалежне створення таблиці, зміну десятка колонок і масове перетворення даних.
Файл 001_create_users.sql:
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);Цю міграцію можна виконати в транзакції разом із записом про її застосування:
BEGIN;
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO schema_migrations (version, name)
VALUES (1, 'create_users');
COMMIT;Якщо команда завершиться помилкою, транзакція не буде зафіксована. Таблиця users і запис у schema_migrations не з'являться частково.
Файл 002_add_display_name.sql:
ALTER TABLE users
ADD COLUMN display_name text;
INSERT INTO schema_migrations (version, name)
VALUES (2, 'add_display_name');Нова колонка допускає NULL, тому старі рядки залишаються коректними, а стара версія програми не перестає працювати.
Повний приклад запуску з psql:
BEGIN;
-- Додаємо колонку без обов'язкового значення для наявних рядків.
ALTER TABLE users
ADD COLUMN display_name text;
INSERT INTO schema_migrations (version, name)
VALUES (2, 'add_display_name');
COMMIT;Міграції часто поділяють на два напрямки:
up — застосувати зміну;
down — скасувати зміну.
Наприклад:
-- up: 002_add_display_name
ALTER TABLE users
ADD COLUMN display_name text;Зворотна операція:
-- down: 002_add_display_name
ALTER TABLE users
DROP COLUMN display_name;Однак скасування не завжди є безпечним. Якщо нова колонка вже містить важливі дані, DROP COLUMN призведе до їх втрати. Тому down не слід вважати універсальною заміною резервній копії.
Для необоротних операцій потрібно:
заздалегідь створити резервну копію;
перевірити міграцію на копії даних;
за потреби реалізувати окрему міграцію для відновлення;
не видаляти дані одразу, якщо ще може знадобитися повернення старої версії програми.
У production-середовищі часто застосовують лише нові міграції вперед, а помилки виправляють новими міграціями.
PostgreSQL підтримує транзакційне виконання багатьох операцій DDL. Це дає змогу виконати зміну схеми атомарно:
BEGIN;
ALTER TABLE users
ADD COLUMN last_login_at timestamptz;
INSERT INTO schema_migrations (version, name)
VALUES (4, 'add_last_login_at');
COMMIT;Якщо ALTER TABLE завершиться помилкою, INSERT також не буде зафіксовано.
Переваги такого підходу:
схема не залишається у проміжному стані;
запис про міграцію відповідає фактичному стану бази;
помилки можна обробити одним відкатом.
Не всі операції можна виконувати всередині транзакції. Важливий приклад — CREATE INDEX CONCURRENTLY. PostgreSQL не дозволяє виконувати його в транзакційному блоці:
CREATE INDEX CONCURRENTLY idx_users_email
ON users (email);Таку міграцію потрібно запускати окремо, без автоматичного BEGIN:
CREATE INDEX CONCURRENTLY idx_users_email
ON users (email);
INSERT INTO schema_migrations (version, name)
VALUES (3, 'create_user_email_index');Під час планування міграції необхідно знати, чи підтримує конкретна операція транзакційне виконання. Якщо міграційний інструмент автоматично обгортає всі файли в транзакції, для нетранзакційних операцій потрібно використати його спеціальний режим.
Алгоритм міграційного інструмента зазвичай має такий вигляд:
Підключитися до бази даних.
Створити schema_migrations, якщо її ще немає.
Прочитати всі файли міграцій.
Відсортувати їх за версією.
Прочитати вже застосовані версії.
Виконати відсутні міграції по черзі.
Після успішного виконання кожної міграції записати її версію.
Спрощений приклад такого алгоритму на SQL для конкретної міграції:
BEGIN;
-- Перевірку наявності версії зазвичай виконує міграційний інструмент.
ALTER TABLE users
ADD COLUMN display_name text;
INSERT INTO schema_migrations (version, name)
VALUES (2, 'add_display_name');
COMMIT;Запис у schema_migrations має відбуватися в тій самій транзакції, що й зміна схеми. Інакше можлива небезпечна ситуація:
схема змінилася;
процес завершився до запису версії;
під час наступного запуску та сама міграція виконується повторно.
Міграція зазвичай виконується один раз, тому не кожна команда повинна бути ідемпотентною. Проте обережне використання IF NOT EXISTS може допомогти під час відновлення або ручної перевірки:
CREATE TABLE IF NOT EXISTS schema_migrations (
version bigint PRIMARY KEY,
name text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now()
);Не слід бездумно додавати IF NOT EXISTS до всіх операцій. Наприклад, якщо колонка вже існує, але має неправильний тип, команда:
ALTER TABLE users
ADD COLUMN IF NOT EXISTS display_name text;не повідомить про невідповідність типу. Міграція може формально завершитися успішно, хоча схема залишиться неправильною.
Краще, щоб міграції були:
малими;
передбачуваними;
перевіреними;
незмінними після потрапляння в спільний репозиторій.
Якщо вже застосовану міграцію потрібно виправити, створюють нову міграцію, а не редагують стару.
Під час оновлення застосунку деякий час можуть одночасно існувати:
старі екземпляри програми;
нові екземпляри програми;
стара схема;
схема, яка поступово змінюється.
Тому міграції мають бути сумісними з обома версіями програми.
Припустімо, стара програма читає колонку email, а нова програма одразу перейменовує її на address:
ALTER TABLE users
RENAME COLUMN email TO address;Старі екземпляри програми більше не зможуть виконувати запити до email. Якщо розгортання не є повністю зупинним, це спричинить помилки.
Для змін, які можуть порушити сумісність, застосовують послідовність expand-contract.
Спочатку додають нову структуру, не видаляючи стару:
ALTER TABLE users
ADD COLUMN address text;Стара програма продовжує працювати з email, а нова може бути підготовлена до використання address.
Нова версія програми:
записує дані в обидві колонки;
читає нову колонку, якщо вона заповнена;
інакше використовує стару колонку.
Для наявних даних можна виконати backfill:
UPDATE users
SET address = email
WHERE address IS NULL;На великих таблицях масове оновлення потрібно планувати окремо: воно може довго утримувати блокування, створювати багато WAL-записів і збільшувати навантаження на базу.
Потрібно переконатися, що:
нова програма більше не залежить від старої колонки;
усі потрібні рядки перенесені;
нові записи коректно заповнюють нову колонку;
фонові процеси також оновлені.
Лише після цього стару колонку можна видалити окремою міграцією:
ALTER TABLE users
DROP COLUMN email;Такий підхід потребує більше кроків, зате дозволяє оновлювати програму поступово та зменшує ризик простою.
Додавання обов'язкової колонки до таблиці, де вже є рядки, вимагає обережності.
Потенційно проблемний приклад:
ALTER TABLE users
ADD COLUMN status text NOT NULL;Для наявних рядків немає значення status, тому операція завершиться помилкою.
Безпечніший поетапний варіант:
BEGIN;
-- Спочатку додаємо колонку без NOT NULL.
ALTER TABLE users
ADD COLUMN status text;
-- Заповнюємо значення для наявних рядків.
UPDATE users
SET status = 'active'
WHERE status IS NULL;
-- Після перевірки даних додаємо обмеження.
ALTER TABLE users
ALTER COLUMN status SET NOT NULL;
COMMIT;На великій таблиці UPDATE краще виконувати частинами або окремим контрольованим процесом. Так можна зменшити тривалість блокувань і навантаження.
Операції зміни схеми можуть блокувати паралельні запити. Наприклад, ALTER TABLE часто потребує блокування таблиці. На маленькій таблиці це непомітно, але на production-базі з активним трафіком очікування блокування може спричинити чергу запитів.
Перед виконанням міграції варто перевірити:
скільки рядків має таблиця;
чи є активні довгі транзакції;
який тип блокування потрібен;
скільки може тривати операція;
чи можна виконати її в період низького навантаження.
Для індексу на активній таблиці часто використовують:
CREATE INDEX CONCURRENTLY idx_users_created_at
ON users (created_at);Ця команда зменшує блокування звичайних операцій із таблицею, але може виконуватися довше і не може бути частиною транзакції. Якщо вона перерветься, може залишитися незавершений індекс, який потрібно перевірити перед повторним запуском.
Зміну структури та перетворення даних іноді розділяють на окремі міграції.
Наприклад:
004_add_status_column.sql
005_backfill_user_status.sql
006_require_user_status.sqlПереваги такого поділу:
простіше визначити причину помилки;
можна перевірити результат кожного кроку;
важку обробку даних можна виконати окремо;
зміни структури не змішуються з довгими операціями.
Міграція даних повинна бути повторно перевірною. Умова WHERE status IS NULL у прикладі нижче не змінює вже оброблені рядки:
UPDATE users
SET status = 'active'
WHERE status IS NULL;Проте це не означає, що будь-який backfill можна безпечно запускати повторно. Операції, які збільшують значення, додають записи або змінюють дані без умови, можуть створювати дублікати чи повторно обробляти рядки.
Міграції потрібно перевіряти щонайменше у двох сценаріях.
Порожня база має пройти всі міграції послідовно:
001 → 002 → 003 → 004Це перевіряє, що:
файли міграцій правильно впорядковані;
кожна міграція враховує стан після попередньої;
схема відтворюється без ручних кроків.
Потрібно взяти базу на попередній версії, наприклад після міграції 002, і застосувати лише нові міграції:
002 → 003 → 004Це перевіряє реальний сценарій оновлення та взаємодію з уже наявними даними.
Також перевіряють:
коректність обмежень;
наявність індексів;
роботу старої та нової версій програми під час переходу;
поведінку при помилці міграції;
час виконання на наближеному до production обсязі даних.
Два процеси не повинні одночасно застосовувати одну й ту саму міграцію. Інакше вони можуть одночасно виконати ALTER TABLE.
Міграційні інструменти зазвичай використовують один із механізмів:
блокування на рівні бази;
advisory lock PostgreSQL;
блокування через окрему службову таблицю.
PostgreSQL має advisory lock для координації операцій:
SELECT pg_advisory_lock(874321);
-- Тут міграційний інструмент виконує міграції.
SELECT pg_advisory_unlock(874321);У реальному інструменті потрібно також передбачити звільнення блокування у разі помилки або завершення процесу. Не слід самостійно додавати такий код до кожного SQL-файлу, якщо міграційний інструмент уже керує блокуваннями.
Практичний процес може виглядати так:
Створити нову міграцію.
Перевірити її на локальній базі.
Перевірити створення бази з нуля.
Перевірити оновлення копії попередньої версії.
Оцінити блокування та тривалість операції.
Застосувати міграцію в тестовому середовищі.
Застосувати її в production.
Розгорнути код, сумісний із новою схемою.
Після завершення переходу виконати окремі cleanup-міграції.
Для критичних змін перед міграцією необхідно мати актуальну резервну копію та план відновлення.
Якщо змінити файл, який уже виконувався в іншому середовищі, вміст файлу більше не відповідає фактичній історії бази.
Правильний підхід — створити нову міграцію, яка виправляє попередню зміну.
Стара версія програми може ще використовувати стару колонку. Спочатку потрібно перенести код на нову структуру, перевірити його роботу і лише потім видаляти стару колонку.
NOT NULL без заповнення старих рядківНаявні рядки не мають значення для нової колонки. Спочатку колонку додають без обмеження, заповнюють дані, перевіряють їх і лише потім встановлюють NOT NULL.
CREATE INDEX CONCURRENTLY у транзакціїPostgreSQL забороняє цю операцію всередині транзакції. Міграцію потрібно позначити як нетранзакційну або виконати окремо.
Довгий UPDATE у тій самій транзакції, що й зміна схеми, може довго утримувати блокування та збільшити розмір транзакції. Великі перетворення даних краще планувати окремо.
Міграція може бути правильною для фінальної версії, але несумісною з версією програми, яка ще працює під час розгортання. Перевіряйте щонайменше переходи «стара програма + нова схема» та «нова програма + стара або розширена схема».
Якщо для запуску застосунку потрібно вручну створити індекс або змінити колонку, цей крок має стати міграцією. Інакше нове середовище неможливо буде відтворити надійно.
Міграція — це версійована зміна схеми або даних.
Застосовані міграції зберігають у службовій таблиці.
Міграції виконують послідовно та не змінюють після застосування.
Транзакції PostgreSQL допомагають уникати частково застосованих змін.
CREATE INDEX CONCURRENTLY потрібно виконувати поза транзакцією.
Зміни мають бути зворотно сумісними з кодом, який ще працює.
Для несумісних змін застосовують шаблон expand-contract.
Обов'язкові колонки додають поетапно: додавання, заповнення, перевірка, обмеження.
Міграції перевіряють і на порожній базі, і під час оновлення існуючої.
Уже застосовані міграції не редагують — помилки виправляють новими міграціями.