
• Оглавление
Простой во время миграции — это потерянные заказы и нервы поддержки. «Без простоя» означает, что изменения в схеме можно выкатывать днём, вместе с кодом, без ночных окон и риска остановить приём платежей. Это снижает стоимость релизов, ускоряет развитие продукта и упрощает планирование.
Все эти риски лечатся шаблоном «расширяй–сжимай» и приёмами PostgreSQL, которые снижают блокировки до миллисекунд.
Дополнительно:
В PostgreSQL 11+ добавление колонки с константным DEFAULT не переписывает таблицу и обычно занимает миллисекунды. На проде всё равно лучше действовать аккуратно:
-- Безопасные таймауты на сессию
SET lock_timeout = '1s';
SET statement_timeout = '15min';
ALTER TABLE users ADD COLUMN phone_verified_at timestamptz; -- быстро
-- Значение по умолчанию лучше проставлять в приложении или отдельно
ALTER TABLE users ALTER COLUMN phone_verified_at SET DEFAULT NULL; -- метаданные
Если версия PostgreSQL ниже 11 или DEFAULT вычисляемый, используйте подход «добавить nullable → фоновая заливка → NOT NULL».
Прямой ALTER TABLE ... SET NOT NULL может сканировать таблицу. Безопаснее так:
-- 1) Добавляем ограничение как NOT VALID — оно не проверяет прошлые строки и не блокирует запись
ALTER TABLE users ADD CONSTRAINT users_country_nn CHECK (country_code IS NOT NULL) NOT VALID;
-- 2) Фоновая заливка пропущенных значений (см. пример скрипта ниже)
-- 3) Валидируем ограничение онлайн: допускает запись
ALTER TABLE users VALIDATE CONSTRAINT users_country_nn;
-- 4) Делаем колонку NOT NULL — операция быстрая, т.к. условие уже гарантировано
ALTER TABLE users ALTER COLUMN country_code SET NOT NULL;
Аналогично внешним ключам:
ALTER TABLE orders
ADD CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;
-- Фоново приводим данные к целостному виду
ALTER TABLE orders VALIDATE CONSTRAINT orders_user_fk;
Создавайте и удаляйте индексы без остановки записи:
-- Создание онлайн
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_created_at ON orders (created_at);
-- Удаление онлайн
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_created_at;
Важно: операции CONCURRENTLY нельзя запускать внутри транзакции. Скрипт миграции должен учитывать это.
Переименование «в лоб» (ALTER TABLE ... RENAME COLUMN) может сломать старый код. Безопаснее:
CREATE OR REPLACE FUNCTION sync_user_email() RETURNS trigger AS $$
BEGIN
IF NEW.email IS NULL AND NEW.login IS NOT NULL THEN
NEW.email := NEW.login; -- упрощённый пример
ELSIF NEW.email IS NOT NULL AND NEW.login IS NULL THEN
NEW.login := NEW.email;
END IF;
RETURN NEW;
END;$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_sync_user_email ON users;
CREATE TRIGGER trg_sync_user_email
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_user_email();
Иногда удобно сделать совместимое представление:
-- Сохраняем старый контракт для части кода/отчётов
CREATE OR REPLACE VIEW v_users AS
SELECT id, email AS login, created_at FROM users;
Удаляйте только после того, как:
ALTER TABLE users DROP COLUMN IF EXISTS login;
ALTER TYPE ... ADD VALUE — дёшево и безопасно. Но удалить значение нельзя.-- Добавить новое значение безопасно
ALTER TYPE order_status ADD VALUE IF NOT EXISTS 'archived';
Задача: перейти с поля login на email, сделать уникальность по email, не останавливая запись.
Шаг 1. Расширение схемы
SET lock_timeout = '1s';
ALTER TABLE users ADD COLUMN IF NOT EXISTS email text; -- nullable, без ограничений
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users (lower(email));
Шаг 2. Двойная запись
login, и в email (если одно из полей пустое — копирует значение).Шаг 3. Фоновая заливка email из login
email там, где он ещё пуст.Шаг 4. Уникальность без падений
-- Добавим частичный уникальный индекс только на заполненные email
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS uq_users_email
ON users (lower(email)) WHERE email IS NOT NULL;
Шаг 5. Переключаем чтение на email, мониторим ошибки.
Шаг 6. Ужесточаем схему
-- Проверка не-null в два шага
ALTER TABLE users ADD CONSTRAINT users_email_nn CHECK (email IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_email_nn;
-- Теперь можно сделать столбец NOT NULL (операция быстрая)
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
Шаг 7. Очистка
-- Больше не нужен старый столбец и индекс
DROP INDEX CONCURRENTLY IF EXISTS idx_users_login;
ALTER TABLE users DROP COLUMN IF EXISTS login;
Безопаснее обновлять данные порциями. Пример на Python (psycopg), обновляем по первичному ключу батчами по 1000 строк:
# requirements: psycopg[binary]==3.*
import os
import time
import psycopg
DSN = os.getenv("DSN", "dbname=app user=app host=/var/run/postgresql")
BATCH_SIZE = int(os.getenv("BATCH", 1000))
SLEEP = float(os.getenv("SLEEP", 0.05)) # пауза между батчами
SQL_SELECT = """
SELECT id, login FROM users
WHERE email IS NULL AND login IS NOT NULL
ORDER BY id
LIMIT %s
"""
SQL_UPDATE = """
UPDATE users u
SET email = s.login
FROM (VALUES %s) AS s(id, login)
WHERE u.id = s.id AND u.email IS NULL
"""
def chunks(rows, size):
for i in range(0, len(rows), size):
yield rows[i:i+size]
with psycopg.connect(DSN) as conn:
with conn.cursor() as cur:
# Безопасные таймауты на сессию
cur.execute("SET lock_timeout = '1s'")
cur.execute("SET statement_timeout = '10min'")
while True:
with conn.cursor(row_factory=psycopg.rows.tuple_row) as cur:
cur.execute(SQL_SELECT, (BATCH_SIZE,))
rows = cur.fetchall()
if not rows:
print("Done")
break
# Готовим VALUES для массового апдейта
values = ",".join(cur.mogrify("(%s,%s)", r).decode() for r in rows)
cur.execute(SQL_UPDATE % values)
print(f"updated {cur.rowcount} rows")
conn.commit()
time.sleep(SLEEP)
Особенности:
Перед любым ALTER:
SET lock_timeout = '1s';
SET idle_in_transaction_session_timeout = '5s';
SET statement_timeout = '15min';
Следите за «залипшими» транзакциями, которые держат старые версии строк и мешают VACUUM:
SELECT pid, usename, now() - xact_start AS age, query
FROM pg_stat_activity
WHERE state <> 'idle' AND xact_start IS NOT NULL
ORDER BY age DESC
LIMIT 20;
-- В крайнем случае
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE pid <> pg_backend_pid() AND application_name = 'проблемный-воркер';
CREATE INDEX без CONCURRENTLY, DROP INDEX без CONCURRENTLY, тяжелые ALTER TABLE ... TYPE USING ....Пример GitHub Actions (упрощённо):
name: deploy
on: [workflow_dispatch]
jobs:
prod:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Prepare migrations (expand)
run: |
psql "$DSN" -v ON_ERROR_STOP=1 -f migrations/2026-03-09_expand.sql
- name: Deploy app v2
run: ./scripts/deploy_app.sh
- name: Post checks
run: ./scripts/checks.sh
- name: Cleanup (contract)
run: |
psql "$DSN" -v ON_ERROR_STOP=1 -f migrations/2026-03-09_contract.sql
Где в expand.sql нет операций внутри транзакции для CONCURRENTLY, а в contract.sql — только удаление старых артефактов и ужесточение ограничений.
Миграции без простоя — это не «магия», а дисциплина: совместимые изменения, короткие шаги и осознанная работа с блокировками. Следуя шаблону «расширяй–сжимай» и используя возможности PostgreSQL — индексы CONCURRENTLY, ограничения с NOT VALID, валидацию онлайн, — можно выкатывать изменения днём, без простоев и ночных дежурств. Для бизнеса это прямые деньги: меньше инцидентов, быстрее вывод фич, предсказуемые релизы.