
Неправильная команда DDL (изменение схемы) может взять «эксклюзивную» блокировку таблицы и остановить записи. Длинная проверка ограничения — подвесить запросы. Создание индекса «в лоб» — на часы съесть CPU и заблокировать обновления. В пиковые часы это означает потерянные заказы и всплеск ошибок 5xx.
Главная идея безостановочной миграции — разделить изменения на маленькие безопасные шаги, которые совместимы с текущей версией кода. Сначала расширяем схему (expand), затем перестраиваем данные и трафик, и только потом сужаем (contract), удаляя старое.
Такой план снимает риск «всё или ничего» и позволяет откатываться по частям.
Пример безопасного «обвеса» для миграций:
-- Выполняйте в начале каждой миграции
SET lock_timeout = '2s'; -- не стоим в очереди на блокировку
SET statement_timeout = '15min'; -- миграция не должна жить вечно
SET idle_in_transaction_session_timeout = '1min';
SET application_name = 'ddl_migration_2026_04_28';
Диагностика блокировок во время миграции:
SELECT pid, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE application_name = 'ddl_migration_2026_04_28';
Задача: добавить колонку locale с дефолтом 'ru' и сделать её NOT NULL, не блокируя запись.
Безопасный план для PostgreSQL 11+:
-- Шаг A: расширяем схему
ALTER TABLE users ADD COLUMN locale text; -- быстро
ALTER TABLE users ALTER COLUMN locale SET DEFAULT 'ru'; -- метаданные
-- Шаг B: фоновый бэкфилл малыми порциями (пример ниже на Python)
-- Шаг C: вешаем CHECK как NOT VALID и валидируем
ALTER TABLE users
ADD CONSTRAINT users_locale_nn CHECK (locale IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_locale_nn; -- долгая проверка без долгих блокировок
-- (Опционально) Шаг D: переводим в строгий NOT NULL, когда уверены
ALTER TABLE users ALTER COLUMN locale SET NOT NULL; -- короткая операция, когда данные уже валидны
Фоновый бэкфилл батчами на Python (psycopg2):
import os, time
import psycopg2
DSN = os.getenv("PG_DSN", "dbname=app user=app password=secret host=127.0.0.1")
BATCH = 5_000
SLEEP = 0.2
sql_pick = """
SELECT id FROM users
WHERE locale IS NULL
ORDER BY id
LIMIT %s
"""
sql_update = """
UPDATE users u
SET locale = 'ru'
FROM (SELECT id FROM users WHERE locale IS NULL ORDER BY id LIMIT %s FOR UPDATE SKIP LOCKED) t
WHERE u.id = t.id
RETURNING u.id
"""
with psycopg2.connect(DSN) as conn:
with conn.cursor() as cur:
cur.execute("SET application_name = 'backfill_users_locale'")
cur.execute("SET lock_timeout = '1s'")
while True:
cur.execute(sql_update, (BATCH,))
rows = cur.fetchall()
if not rows:
break
conn.commit()
time.sleep(SLEEP)
print("done")
Ключевые моменты: SKIP LOCKED избегает конкуренции с прод-записями; коммиты — часто; паузы — щадим нагрузку.
На лобовую (ALTER TABLE ... RENAME COLUMN) — опасно: старый код отвалится. Делаем совместимо:
-- Expand: добавляем новую колонку
ALTER TABLE orders ADD COLUMN total_cents bigint;
-- Двойная запись (в коде): при изменении суммы пишем и в total, и в total_cents
-- Временный триггер как страховка (если правки кода катятся дольше):
CREATE OR REPLACE FUNCTION orders_mirror_total()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP IN ('INSERT', 'UPDATE') THEN
IF NEW.total_cents IS NULL AND NEW.total IS NOT NULL THEN
NEW.total_cents := (NEW.total * 100)::bigint;
END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER orders_total_mirror
BEFORE INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION orders_mirror_total();
-- Бэкфилл существующих строк
UPDATE orders SET total_cents = (total * 100)::bigint WHERE total_cents IS NULL;
-- Switch: код начинает читать total_cents, а total больше не используется в чтении
-- Contract: удаляем старое поле и триггер
DROP TRIGGER orders_total_mirror ON orders;
ALTER TABLE orders DROP COLUMN total;
-- Быстро и без блокировки записи
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users (email);
-- Удаление старого
DROP INDEX CONCURRENTLY IF EXISTS idx_users_email_old;
Нельзя выполнять CREATE INDEX CONCURRENTLY внутри транзакции, которую открывает большинство миграционных фреймворков. Либо отключите обёртку в транзакцию для этой миграции, либо используйте отдельный шаг.
Общий шаблон:
SQL-пример для «раздувшейся» таблицы events, переносим поле payload_text в payload_json:
WITH moved AS (
SELECT id FROM events
WHERE payload_json IS NULL
ORDER BY id
LIMIT 2000
FOR UPDATE SKIP LOCKED
)
UPDATE events e
SET payload_json = to_jsonb(e.payload_text)
FROM moved
WHERE e.id = moved.id;
Повторяем до опустошения очереди. После — CHECK NOT VALID + VALIDATE, затем удаляем устаревшее поле.
В PostgreSQL можно добавить значение в enum:
ALTER TYPE order_status ADD VALUE IF NOT EXISTS 'refunded';
Но удалить или переименовать значение нельзя без трюков. Поэтому для эволюции статусов лучше:
Пример с CHECK:
ALTER TABLE orders
ADD CONSTRAINT orders_status_check CHECK (status IN ('new','paid','shipped','refunded')) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_status_check;
Шаблон «разнесения» без простоя:
-- Expand: новая таблица под детали
CREATE TABLE IF NOT EXISTS user_devices (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
device_id text NOT NULL,
platform text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_devices_user ON user_devices(user_id);
-- Функция и триггер для двойной записи из users.profile_jsonb
CREATE OR REPLACE FUNCTION users_device_mirror()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE d jsonb;
BEGIN
IF TG_OP IN ('INSERT','UPDATE') THEN
d := COALESCE(NEW.profile_jsonb->'devices', '[]'::jsonb);
-- Простая стратегия: удаляем старые и вставляем новые для пользователя
DELETE FROM user_devices WHERE user_id = NEW.id;
INSERT INTO user_devices(user_id, device_id, platform)
SELECT NEW.id, (x->>'id')::text, (x->>'platform')::text
FROM jsonb_array_elements(d) AS x;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER users_device_mirror
AFTER INSERT OR UPDATE OF profile_jsonb ON users
FOR EACH ROW EXECUTE FUNCTION users_device_mirror();
-- Бэкфилл исторических данных батчами
INSERT INTO user_devices(user_id, device_id, platform)
SELECT u.id,
(x->>'id')::text,
(x->>'platform')::text
FROM users u,
LATERAL jsonb_array_elements(COALESCE(u.profile_jsonb->'devices','[]'::jsonb)) AS x
LEFT JOIN user_devices d ON d.user_id = u.id AND d.device_id = (x->>'id')::text
WHERE d.id IS NULL
ORDER BY u.id
LIMIT 5000; -- повторяем
-- Switch: код читает из user_devices
-- Contract: убираем хранение devices в JSONB или оставляем как кэш по бизнес-решению
В MySQL/InnoDB часть ALTER TABLE поддерживает ALGORITHM=INPLACE, LOCK=NONE, но не все типы изменений. Для тяжёлых миграций таблиц в проде — инструменты «теневого копирования»:
Пример gh-ost:
gh-ost \
--host=db.example.internal \
--user=app --password='secret' \
--database=shop --table=orders \
--alter="ADD COLUMN locale varchar(8)" \
--cut-over=default \
--max-load=Threads_running=50 \
--critical-load=Threads_running=80 \
--approve-renamed-columns \
--verbose --execute
Параметры max-load/critical-load защищают прод от деградации под миграцией.
Классические «down»-миграции редко работают в проде: данные уже уехали. Надёжнее стратегия «вперёд-только» с мелкими шагами:
Полезно иметь «кнопку» быстрого отката чтений (конфигурация сервиса), отдельно от релиза кода.
Мониторинг во время миграции:
Защита от блокировок:
Префлайт PostgreSQL перед «взрослой» миграцией:
-- Долгие транзакции, которые могут держать старые снимки
SELECT pid, xact_start, now()-xact_start AS age, query
FROM pg_stat_activity
WHERE state <> 'idle' AND xact_start IS NOT NULL
ORDER BY age DESC
LIMIT 10;
-- Потенциальные блокировки целевой таблицы
SELECT bl.pid AS blocked_pid, a.query AS blocked_query,
kl.pid AS locker_pid, ka.query AS locker_query, bl.locktype
FROM pg_locks bl
JOIN pg_stat_activity a ON a.pid = bl.pid
JOIN 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_stat_activity ka ON ka.pid = kl.pid
WHERE NOT bl.granted;
Чек-лист перед запуском:
Итог: команды разработки доставляют изменения в данные и схему так же уверенно, как код — без ночных «окон», ручных переключений и нервов в проде.