
• Оглавление
Миграция схемы в рабочее время часто воспринимается как «привезём ночью, когда никого нет». Но ночные окна — это задержки в поставке фич, усталые команды и всё равно риск. Подход «без простоя» позволяет:
Ключевая идея — совместимость. Приложение и схема должны некоторое время работать вместе в двух версиях данных.
Такой подход называют «expand → migrate → contract». Он уменьшает единичный риск и даёт пространство для отката.
Зная, какие DDL дорогие, можно заранее выбрать безопасный путь.
Цель: добавить колонку, заполнить исторические данные, сделать поле обязательным — без простоя.
Шаги:
SET lock_timeout = '3s';
SET statement_timeout = '30s';
ALTER TABLE orders ADD COLUMN source TEXT; -- пока NULL допустим
ALTER TABLE orders ALTER COLUMN source SET DEFAULT 'web';
-- Обновляем по 1000 строк за итерацию, делая паузы
DO $$
DECLARE
_batch_size int := 1000;
_updated int := 0;
BEGIN
LOOP
WITH cte AS (
SELECT ctid FROM orders WHERE source IS NULL LIMIT _batch_size
)
UPDATE orders o
SET source = 'web'
FROM cte
WHERE o.ctid = cte.ctid;
GET DIAGNOSTICS _updated = ROW_COUNT;
EXIT WHEN _updated = 0;
PERFORM pg_sleep(0.05); -- 50 мс пауза между батчами
END LOOP;
END$$ LANGUAGE plpgsql;
ALTER TABLE orders ADD CONSTRAINT orders_source_present CHECK (source IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_source_present; -- может идти долго, но без простоя
-- Выполнять в низкую нагрузку
ALTER TABLE orders ALTER COLUMN source SET NOT NULL;
Используем только CONCURRENTLY. Важно: команда должна выполняться вне транзакции.
-- Нельзя внутри BEGIN ... COMMIT
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_created_at ON orders (created_at);
Удаление тоже делайте CONCURRENTLY:
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_created_at;
Если надо заменить column_a на column_b:
Пример миграции данных:
DO $$
DECLARE
_batch int := 5000;
_moved int;
BEGIN
LOOP
WITH moved AS (
SELECT ctid, a FROM items WHERE b IS NULL LIMIT _batch
)
UPDATE items i
SET b = moved.a
FROM moved
WHERE i.ctid = moved.ctid;
GET DIAGNOSTICS _moved = ROW_COUNT;
EXIT WHEN _moved = 0;
PERFORM pg_sleep(0.05);
END LOOP;
END$$ LANGUAGE plpgsql;
-- Сначала без проверки существующих строк
ALTER TABLE payments
ADD CONSTRAINT payments_order_fk
FOREIGN KEY (order_id) REFERENCES orders(id) NOT VALID;
-- Отдельно валидируем: долго, но не блокирует INSERT/UPDATE
ALTER TABLE payments VALIDATE CONSTRAINT payments_order_fk;
Добавить новое значение:
ALTER TYPE order_status ADD VALUE IF NOT EXISTS 'refunded';
Если нужно удалить/переименовать значение, ENUM придётся заменить: завести новую колонку с новым типом, мигрировать данные, переключиться, потом удалить старую — по шаблону «замены колонки».
Релиз A (расширение):
Релиз B (переключение):
Релиз C (сужение):
Полезно запускать DDL через скрипт, который сразу ставит ограничители и проверяет блокировки.
#!/usr/bin/env bash
set -euo pipefail
DB_URL="${DB_URL:-postgres://user:pass@localhost:5432/app?sslmode=disable}"
SQL_FILE="$1"
# Базовые предохранители
cat > /tmp/_ddl_header.sql <<'SQL'
SET lock_timeout = '3s';
SET statement_timeout = '30s';
SET idle_in_transaction_session_timeout = '10s';
SET maintenance_work_mem = '512MB';
SQL
cat /tmp/_ddl_header.sql "$SQL_FILE" | psql "$DB_URL" --single-transaction --set ON_ERROR_STOP=1 || {
echo "DDL failed. Showing blockers..." >&2
psql "$DB_URL" -c "
SELECT pid, usename, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE state <> 'idle' AND pid <> pg_backend_pid()
ORDER BY query_start;
"
exit 1
}
Важно: команды с CONCURRENTLY нельзя исполнять внутри транзакции. Для них делайте отдельные файлы и запускайте psql без --single-transaction.
Набор полезных запросов в Postgres для диагностики.
Кто блокирует кого:
SELECT bl.pid AS blocked_pid,
ka.query AS blocking_query,
now() - ka.query_start AS blocking_for,
kl.locktype, kl.mode
FROM pg_locks bl
JOIN pg_locks kl ON bl.locktype = kl.locktype AND bl.locked_object = kl.locked_object AND bl.pid <> kl.pid
JOIN pg_stat_activity ka ON ka.pid = kl.pid
WHERE bl.granted = false;
Долгие транзакции:
SELECT pid, usename, now() - xact_start AS tx_age, query
FROM pg_stat_activity
WHERE state <> 'idle' AND xact_start IS NOT NULL
ORDER BY tx_age DESC
LIMIT 10;
Использование индексов по факту:
SELECT relname AS table,
idx_scan, seq_scan,
round(100.0 * idx_scan / NULLIF(idx_scan + seq_scan, 0), 2) AS idx_usage_pct
FROM pg_stat_user_tables
ORDER BY idx_usage_pct ASC NULLS LAST
LIMIT 20;
Миграции без простоя — это не «магия», а дисциплина небольших шагов и знание свойств PostgreSQL. Добавляйте новое безопасно, мигрируйте исторические данные батчами, валидируйте ограничения отдельно, индексы создавайте CONCURRENTLY, старое удаляйте в самом конце. В результате релизы станут короче, риски — ниже, а бизнес перестанет ждать «ночных окон», чтобы выкатить простое поле или индекс.