
База данных — сердце продукта. Любая блокировка таблицы, долгий апдейт или неудачный индекс могут заморозить приём заказов, поднять количество ошибок и съесть репутацию. Миграции без простоя позволяют:
ALTER TABLE может взять эксклюзивный блок и остановить записи/чтения.Идея: сначала добавляем новое рядом со старым (расширяем), переводим чтение/запись на новое, убеждаемся, что всё работает, потом удаляем старое (сжимаем).
Допустим, в таблице orders поле phone хранится как строка, но нужно нормализовать и хранить в формате E.164, а потом обеспечить уникальность.
Шаг 1. Добавляем новый столбец nullable — быстро и без блокировок:
-- PostgreSQL
ALTER TABLE orders ADD COLUMN phone_e164 text; -- без NOT NULL и DEFAULT
Шаг 2. Создаём индекс «на лету»:
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_phone_e164 ON orders (phone_e164);
Шаг 3. Переносим данные батчами (подробно — ниже). Параллельно меняем код приложения: при записи заказа — нормализуем телефон и пишем и в phone, и в phone_e164 (двойная запись); при чтении — сначала пробуем phone_e164, если пусто — читаем phone и нормализуем на лету.
Шаг 4. Когда backfill завершён и трафик читает новый столбец, включаем ограничение:
-- NOT VALID сначала проверит только новые строки
ALTER TABLE orders
ADD CONSTRAINT phone_e164_not_null CHECK (phone_e164 IS NOT NULL) NOT VALID;
-- Фоновая проверка существующих строк
ALTER TABLE orders VALIDATE CONSTRAINT phone_e164_not_null;
Шаг 5. Переключаем чтение только на phone_e164, останавливаем двойную запись.
Шаг 6. Чистим старый столбец, когда убедились по метрикам и логам, что обращения к нему прекратились:
ALTER TABLE orders DROP COLUMN phone;
CREATE INDEX CONCURRENTLY. Для уникальных — CREATE UNIQUE INDEX CONCURRENTLY.ALTER TABLE ... ADD INDEX ... , ALGORITHM=INPLACE, LOCK=NONE; (зависит от версии и типа изменения). При сомнениях — pt-online-schema-change.Пример для PostgreSQL:
-- создание индекса без блокировки записи
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users (email);
-- наблюдаем прогресс
SELECT phase, tuples_done, tuples_total
FROM pg_stat_progress_create_index;
CHECK ... NOT VALID + VALIDATE CONSTRAINT. Для реального NOT NULL — отдельный шаг:-- мягкая проверка
ALTER TABLE users ADD CONSTRAINT email_not_null CHECK (email IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT email_not_null;
-- теперь можно жёстко
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ux_users_email ON users (email);
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE USING INDEX ux_users_email;
Используйте короткие таймауты блокировок и запросов, чтобы миграция не зависла и не положила прод:
SET lock_timeout = '1s';
SET statement_timeout = '30s';
SET idle_in_transaction_session_timeout = '30s';
Главная ошибка — один гигантский UPDATE. Делайте маленькими порциями, с паузами, учитывая нагрузку.
-- Таблица: orders(id bigint PK, phone text, phone_e164 text)
-- Функция нормализации пусть будет в приложении, а здесь — перенос уже подготовленных данных
-- В приложении/воркере запускаем цикл:
-- 1) Берём партию идентификаторов, которые ещё не перенесены
WITH batch AS (
SELECT id
FROM orders
WHERE phone_e164 IS NULL AND phone IS NOT NULL
ORDER BY id
LIMIT 1000
FOR UPDATE SKIP LOCKED
)
UPDATE orders o
SET phone_e164 = normalize_phone(o.phone) -- для примера может быть SQL-функция
FROM batch b
WHERE o.id = b.id;
Если нормализация в приложении, сначала читаем партию ID, потом апдейтим. Между партиями — небольшая пауза (например, 50–200 мс), чтобы дать вздохнуть репликам и автовакауму.
import time
import psycopg
BATCH = 1000
SLEEP = 0.1
SQL_SELECT = """
SELECT id, phone FROM orders
WHERE phone_e164 IS NULL AND phone IS NOT NULL
ORDER BY id
LIMIT %s
FOR UPDATE SKIP LOCKED
"""
SQL_UPDATE = "UPDATE orders SET phone_e164 = %s WHERE id = %s"
def normalize_phone(phone: str) -> str:
# примитивный пример: оставим только цифры и добавим +
digits = ''.join(ch for ch in phone if ch.isdigit())
if not digits.startswith('7'):
digits = '7' + digits
return '+' + digits
with psycopg.connect("postgresql://app:pass@localhost/db") as conn:
conn.execute("SET statement_timeout='30s'")
conn.execute("SET lock_timeout='1s'")
while True:
with conn.transaction():
rows = conn.execute(SQL_SELECT, (BATCH,)).fetchall()
if not rows:
break
for _id, phone in rows:
conn.execute(SQL_UPDATE, (normalize_phone(phone), _id))
time.sleep(SLEEP)
print("done")
SELECT count(*) FROM orders WHERE phone_e164 IS NULL;Переименование «в лоб» ломает приложение. Надёжнее так:
Если всё-таки нужно единоразово «переименовать» на уровне SQL, можно временно создать представление со старым именем столбца:
ALTER TABLE users RENAME COLUMN full_name TO name;
CREATE OR REPLACE VIEW v_users AS SELECT id, name AS full_name, email FROM users;
Но лучше держать совместимость в приложении и не полагаться на представления под горячим трафиком.
Иногда проще создать новую таблицу с нужной схемой и постепенно «перелить» данные.
orders_v2 с нужными колонками и индексами.orders, и в orders_v2.orders_v2.orders_v2 для определённых маршрутов/пользователей (поэтапно).orders.Если таблица огромна, переносите по «окнам» ключей или по дате:
INSERT INTO orders_v2 (id, user_id, total, created_at, ...)
SELECT id, user_id, total, created_at, ...
FROM orders
WHERE created_at >= now() - interval '90 days'
ON CONFLICT (id) DO NOTHING;
log_lock_waits и следите за событиями ожиданий.SELECT bl.pid AS blocked_pid,
a.usename AS blocked_user,
ka.query AS blocking_query,
now() - ka.query_start AS blocking_age,
kl.pid AS blocking_pid,
a.query AS blocked_query,
now() - a.query_start AS blocked_age
FROM pg_catalog.pg_locks bl
JOIN pg_catalog.pg_stat_activity a
ON a.pid = bl.pid
JOIN pg_catalog.pg_locks kl
ON kl.locktype = bl.locktype
AND kl.DATABASE IS NOT DISTINCT FROM bl.DATABASE
AND kl.relation IS NOT DISTINCT FROM bl.relation
AND kl.page IS NOT DISTINCT FROM bl.page
AND kl.tuple IS NOT DISTINCT FROM bl.tuple
AND kl.virtualxid IS NOT DISTINCT FROM bl.virtualxid
AND kl.transactionid IS NOT DISTINCT FROM bl.transactionid
AND kl.classid IS NOT DISTINCT FROM bl.classid
AND kl.objid IS NOT DISTINCT FROM bl.objid
AND kl.objsubid IS NOT DISTINCT FROM bl.objsubid
AND kl.pid != bl.pid
JOIN pg_catalog.pg_stat_activity ka ON ka.pid = kl.pid
WHERE NOT bl.granted;
pg_stat_progress_create_index.pg_last_wal_replay_lsn и pg_stat_replication.RENAME COLUMN — новый столбец + миграция.lock_timeout, statement_timeout)?В PostgreSQL 11+ добавление столбца с «простым» DEFAULT не переписывает таблицу, но всё же лучше отделять схему и перенос данных: так вы контролируете нагрузку и валидацию. В старых версиях — переписывает таблицу и опасно.
autovacuum_vacuum_cost_limit, включите ручной VACUUM в тихое время.VACUUM FULL (требует эксклюзива и окна), лучше избегать ситуаций, когда он нужен.Триггеры — удобно, но дороже по производительности и сложнее отлаживать. Предпочтительнее двойная запись в коде: прозрачнее и проще контролировать откат. Триггеры — только если без них никак.
-- 1) Добавить колонку быстро
ALTER TABLE invoices ADD COLUMN paid_at timestamptz; -- без NOT NULL/DEFAULT
-- 2) Индекс без блокировки
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_invoices_paid_at ON invoices (paid_at);
-- 3) Мягкое ограничение и валидация
ALTER TABLE invoices ADD CONSTRAINT paid_at_in_past CHECK (paid_at <= now()) NOT VALID;
ALTER TABLE invoices VALIDATE CONSTRAINT paid_at_in_past;
-- 4) Жёсткое ограничение (после валидации и очистки)
-- ALTER TABLE invoices ALTER COLUMN paid_at SET NOT NULL; -- отдельным шагом
-- 5) Переименование без падений (лучше — новый столбец, но если нужно)
-- ALTER TABLE invoices RENAME COLUMN amount TO total_amount; -- делайте, когда код готов
-- 6) Таймауты на сессию миграции
SET lock_timeout = '1s';
SET statement_timeout = '30s';
SET application_name = 'db-migration';
Проверка оставшихся блокировок:
SELECT relname, mode, granted, pid
FROM pg_locks l
LEFT JOIN pg_class c ON c.oid = l.relation
WHERE relname IS NOT NULL
ORDER BY granted, relname;
-- Добавить индекс без полного блокирования (зависит от версии/движка)
ALTER TABLE users ADD INDEX idx_users_email (email) , ALGORITHM=INPLACE, LOCK=NONE;
Если команда выше недоступна или рискованна — используйте pt-online-schema-change (перенос с триггерами, постепенно):
pt-online-schema-change \
--alter "ADD INDEX idx_users_email (email)" \
--execute \
D=app,t=users
Миграции без простоя — это не магия, а дисциплина маленьких обратимых шагов: сначала создаём совместимость, потом переводим трафик, затем убираем старое. Индексы — конкурентно, ограничения — с валидацией, перенос данных — батчами. С метриками и планом отката изменения становятся рутинной операционной задачей, а не стрессом. Вы выиграете скорость вывода фич, стабильность и доверие бизнеса к инженерной практике.