
Схему и данные приходится менять постоянно: новые поля в профиле клиента, уникальные индексы против дублей, приведение типов для аналитики. Если для этого останавливать сервис — вы теряете деньги и доверие пользователей. Онлайн‑миграции позволяют эволюционировать базу без простоев: приложение продолжает обслуживать трафик, а изменения «подрастают» рядом.
Польза для бизнеса:
Безопаснее всего:
Опасные изменения (делайте в несколько шагов):
Пример: хотим добавить users.email_normalized, заполнить из users.email, сделать обязательно заполненной, а потом навесить уникальность.
ALTER TABLE users ADD COLUMN email_normalized text;
Переносим данные партиями (см. раздел ниже), например: email_normalized = lower(email).
Ставим дефолт для новых строк:
ALTER TABLE users ALTER COLUMN email_normalized SET DEFAULT '';
ALTER TABLE users
ADD CONSTRAINT users_email_normalized_not_null
CHECK (email_normalized IS NOT NULL AND email_normalized <> '') NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_email_normalized_not_null;
При желании позже можно заменить на «настоящее» NOT NULL в короткую операцию (когда уверены, что проверки пройдены), но часто достаточно валидированного CHECK.
CREATE UNIQUE INDEX CONCURRENTLY users_email_norm_unique
ON users (email_normalized)
WHERE deleted_at IS NULL; -- условная уникальность живых пользователей
ALTER TABLE users
ADD CONSTRAINT users_email_normalized_unique
UNIQUE USING INDEX users_email_norm_unique;
ALTER TABLE orders ADD COLUMN customer_phone_e164 text;
-- После двусторонней записи и переноса данных
ALTER TABLE orders DROP COLUMN customer_phone;
CREATE INDEX CONCURRENTLY idx_orders_created_at ON orders (created_at);
REINDEX INDEX CONCURRENTLY idx_orders_created_at;
ALTER TABLE orders
ADD CONSTRAINT orders_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_user_id_fkey;
Пример: text -> jsonb.
ALTER TABLE events ADD COLUMN payload_json jsonb;
Двусторонняя запись в приложении: при вставке/обновлении заполняем обе колонки.
Перенос истории партиями:
WITH cte AS (
SELECT id FROM events
WHERE payload_json IS NULL AND payload_text IS NOT NULL
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1000
)
UPDATE events e
SET payload_json = to_jsonb(e.payload_text)
FROM cte
WHERE e.id = cte.id;
Переключаем чтение на payload_json.
Удаляем старую колонку, при необходимости — переименовываем новую в старое имя.
ALTER TABLE events DROP COLUMN payload_text;
ALTER TABLE events RENAME COLUMN payload_json TO payload;
Пакетная миграция снижает нагрузку и избегает долгих блокировок. Ниже — минимальный скрипт на Python (psycopg), который заполняет users.email_normalized из users.email, беря по 1000 строк за раз, с таймаутами на запрос и блокировки.
import os
import time
from psycopg import connect
DSN = os.environ.get("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/app")
BATCH_SIZE = 1000
SLEEP_SECONDS = 0.05
update_sql = """
WITH cte AS (
SELECT id
FROM users
WHERE email_normalized IS NULL
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT %s
)
UPDATE users u
SET email_normalized = lower(u.email)
FROM cte
WHERE u.id = cte.id
RETURNING u.id;
"""
with connect(DSN) as conn:
total = 0
while True:
with conn.transaction():
with conn.cursor() as cur:
# Защитимся от зависаний
cur.execute("SET LOCAL lock_timeout = '2s';")
cur.execute("SET LOCAL statement_timeout = '5min';")
cur.execute(update_sql, (BATCH_SIZE,))
rows = cur.fetchall()
count = len(rows)
total += count
if count == 0:
print(f"Done. Updated total {total} rows")
break
else:
print(f"Updated {count} rows (total {total})")
time.sleep(SLEEP_SECONDS)
Замечания:
Рекомендуемый порядок (пример на добавлении колонки и индекса):
Откат:
Полезные настройки сеанса на время миграций:
SET lock_timeout = '2s'; -- не ждём блокировки бесконечно
SET statement_timeout = '5min'; -- длинные запросы прерываются
SET idle_in_transaction_session_timeout = '30s'; -- защита от «зависших» транзакций
Смотрим, кто кого блокирует:
SELECT
a.pid, a.usename, a.query, a.state, a.wait_event_type, a.wait_event,
l.relation::regclass AS locked_rel
FROM pg_stat_activity a
LEFT JOIN pg_locks l ON a.pid = l.pid AND l.mode LIKE '%%Exclusive%%'
WHERE a.datname = current_database()
ORDER BY a.query_start;
Прогресс долгих операций (создание индекса/вакуум):
SELECT * FROM pg_stat_progress_create_index;
SELECT * FROM pg_stat_progress_vacuum;
Автовакуум и раздувание таблиц:
SELECT relname, n_dead_tup, vacuum_count, autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC LIMIT 20;
Ещё полезно наблюдать: p95/p99 задержку запросов к таблице, количество таймаутов, рост размеров таблицы/индексов, частоту конфликтов блокировок.
Онлайн‑миграции — это дисциплина и чек‑листы, а не геройство админов ночью. Дробите изменения, держите обратную совместимость, используйте CONCURRENTLY и NOT VALID, переносите данные партиями с таймаутами. Результат — предсказуемые релизы, спокойная команда и отсутствие «окна простоя» в календаре продукта.