
Миграции базы — рутина, которая может внезапно превратиться в остановку продукта. Достаточно «невинного» ALTER TABLE, и бэкенд встанет на минуту-другую — для онлайнового бизнеса это потерянные заказы, звонки в поддержку и тревожные метрики. Хорошая новость: большинство изменений схемы можно проводить без даунтайма. Для этого не нужны дорогие решения — достаточно дисциплины, пары безопасных паттернов и правильных настроек.
Что получает бизнес:
Типичные причины зависаний и блокировок:
ALTER TABLE (перестройка таблицы, смена типа).UPDATE на миллионы строк одним оператором.NOT NULL/FOREIGN KEY с полной проверкой всех строк.Почти всё это лечится дроблением изменений и использованием «безопасных» вариантов команд.
Ключевая идея: система должна быть совместима вперёд и назад, а изменения — обратимыми. Делим изменения на две фазы:
Такой подход даёт:
Ниже — наиболее «колючие» операции и безопасные альтернативы.
CREATE INDEX ... и DROP INDEX ... — берут блокировки, мешающие записи/чтению.CREATE INDEX CONCURRENTLY ... и DROP INDEX CONCURRENTLY ... — почти не блокируют пользователей (требуют отдельной транзакции).-- правильно
CREATE INDEX CONCURRENTLY idx_order_created_at ON orders (created_at);
-- удаление тоже «параллельно»
DROP INDEX CONCURRENTLY IF EXISTS idx_order_created_at;
ADD COLUMN ... DEFAULT переписывает всю таблицу — больно.NOT NULL сразу не ставим — сначала nullable + бэкфил + валидация.-- безопасно на PG 11+: дефолт — константа, без переписывания
ALTER TABLE users ADD COLUMN is_active boolean DEFAULT true;
-- NOT NULL — позже, после бэкфила
NOT NULL сканирует всю таблицу и может идти долго.-- даёт семантику NOT 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, если допустимо недолгое окно
ADD CONSTRAINT ... FOREIGN KEY с полной проверкой.NOT VALID, затем VALIDATE CONSTRAINT.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;
ALTER TABLE ... ALTER COLUMN ... TYPE ... USING ... может переписать столбец целиком и занять много времени.UPDATE на миллионы строк.Задача: в таблице orders поле total_cents integer заменить на total numeric(12,2). Нужно, чтобы старый код не упал, а новый перешёл на точный decimal.
Шаг 0. Подготовим сессию миграции: таймауты и безопасность блокировок.
-- выполняем в начале каждой миграции
SET statement_timeout = '5min'; -- общий предел на запрос
SET lock_timeout = '3s'; -- не ждём долгих блокировок
SET idle_in_transaction_session_timeout = '30s';
Шаг 1. Expand: добавляем новый столбец и индекс.
ALTER TABLE orders ADD COLUMN total numeric(12,2);
-- если часто фильтруем по total
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_total ON orders (total);
Шаг 2. Обновляем приложение (релиз A):
total_cents и total.total_cents, чтобы не ломать существующие клиенты.Пример на сервере (условно на Go):
// запись заказа с двойной записью
func SaveOrder(db *sql.DB, o Order) error {
// вычисляем оба значения
totalCents := o.TotalCents
totalDecimal := decimal.NewFromInt(int64(totalCents)).Div(decimal.NewFromInt(100))
_, err := db.Exec(`
INSERT INTO orders(id, user_id, total_cents, total)
VALUES($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET total_cents = EXCLUDED.total_cents,
total = EXCLUDED.total`,
o.ID, o.UserID, totalCents, totalDecimal,
)
return err
}
// чтение пока из старого поля
func (o *Order) TotalMoney() int64 { return o.TotalCents }
Шаг 3. Пакетный бэкфил старых данных. Делаем фоновую задачу: обновляет 5–10 тысяч строк за проход, с паузами, чтобы не мешать продовым запросам.
-- создадим вспомогательный индекс для сканирования пачками по первичному ключу
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_backfill ON orders (id) WHERE total IS NULL;
Простой скрипт бэкфила на стороне БД (исполняем многократно):
WITH cte AS (
SELECT id, total_cents
FROM orders
WHERE total IS NULL
ORDER BY id
LIMIT 10000
)
UPDATE orders o
SET total = (cte.total_cents::numeric / 100.0)
FROM cte
WHERE o.id = cte.id;
Или фоновый процесс на Python с паузой и метриками:
import time
import psycopg2
conn = psycopg2.connect(dsn="postgres://...")
conn.autocommit = True
BATCH = 10000
SLEEP = 0.2
while True:
with conn.cursor() as cur:
cur.execute("""
WITH cte AS (
SELECT id, total_cents FROM orders
WHERE total IS NULL
ORDER BY id
LIMIT %s
)
UPDATE orders o
SET total = (cte.total_cents::numeric / 100.0)
FROM cte
WHERE o.id = cte.id
RETURNING o.id;
""", (BATCH,))
rows = cur.fetchall()
if not rows:
break
time.sleep(SLEEP)
Шаг 4. Валидация: убеждаемся, что расхождений нет.
-- должно вернуть 0
SELECT COUNT(*) AS mismatches
FROM orders
WHERE total IS NOT NULL AND (total * 100)::bigint != total_cents;
Шаг 5. Переключение чтения (релиз B):
total как основное поле.// чтение теперь из decimal
func (o *Order) TotalMoney() int64 {
// бизнес-логика переехала на точный decimal
return o.Total.Mul(decimal.NewFromInt(100)).IntPart()
}
Шаг 6. Contract: наведение порядка.
ALTER TABLE orders ADD CONSTRAINT orders_total_nn CHECK (total IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_total_nn;
ALTER TABLE orders DROP COLUMN total_cents;
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_backfill;
Таким образом, ни на одном шаге не было долгих эксклюзивных блокировок или массовых операций, которые «замораживают» таблицу.
Полезно иметь универсальный «шаблон» бэкфила, который уважает таймауты и не держит блокировки.
#!/usr/bin/env bash
set -euo pipefail
export PGOPTIONS="-c statement_timeout=5min -c lock_timeout=3s -c idle_in_transaction_session_timeout=30s"
# Пример: бэкфил колонки orders.total из total_cents
BATCH=10000
SLEEP=0.2
while true; do
UPDATED=$(psql "$DATABASE_URL" -At -c "
WITH cte AS (
SELECT id, total_cents FROM orders
WHERE total IS NULL
ORDER BY id
LIMIT $BATCH
)
UPDATE orders o
SET total = (cte.total_cents::numeric / 100.0)
FROM cte
WHERE o.id = cte.id
RETURNING 1;" | wc -l)
echo "updated: $UPDATED"
if [ "$UPDATED" -eq 0 ]; then break; fi
sleep "$SLEEP"
done
Замечания:
lock_timeout, чтобы миграция не висела в ожидании блокировки и не мешала продовым транзакциям.Что включить в стандарт миграций:
statement_timeout, lock_timeout, idle_in_transaction_session_timeout.pg_locks), репликационная задержка, длительные транзакции.Пример запросов для диагностики:
-- кто держит блокировки
SELECT pid, locktype, relation::regclass, mode, granted, query
FROM pg_locks l LEFT JOIN pg_stat_activity a USING (pid)
WHERE NOT granted;
-- длительные запросы
SELECT pid, now() - query_start AS dur, state, query
FROM pg_stat_activity
WHERE state <> 'idle' AND (now() - query_start) > interval '30s';
CONCURRENTLY, NOT VALID/VALIDATE, избегайте массовых переписей.Придерживаясь этих правил, можно регулярно менять схему в PostgreSQL без остановок и с предсказуемыми рисками — а значит, выпускать продукт быстрее и увереннее.