Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Онлайн‑миграции в PostgreSQL: как менять схему и данные без простоя и ночных релизов

Разработка и технологии30 января 2026 г.
Миграции базы часто превращаются в ночные релизы, замороженные продажи и испорченные нервы. Разбираемся, как безопасно добавлять колонки, индексы и ограничения, переносить данные партиями и менять типы, не останавливая продукт. Пошаговые рецепты, готовые SQL и скрипт для пакетной миграции. В итоге — быстрее релизы, меньше инцидентов и никаких окон простоя.
Онлайн‑миграции в PostgreSQL: как менять схему и данные без простоя и ночных релизов

  • Оглавление
  • Зачем бизнесу онлайн‑миграции
  • Принципы безостановочных изменений (expand/contract)
  • Какие изменения безопасны, а какие опасны
  • Рецепты: колонки, индексы, ограничения, типы
  • Миграции данных партиями: рабочий код
  • План выкатки и отката
  • Мониторинг, таймауты и защита от блокировок
  • Чек‑лист и частые ошибки

Зачем бизнесу онлайн‑миграции

Схему и данные приходится менять постоянно: новые поля в профиле клиента, уникальные индексы против дублей, приведение типов для аналитики. Если для этого останавливать сервис — вы теряете деньги и доверие пользователей. Онлайн‑миграции позволяют эволюционировать базу без простоев: приложение продолжает обслуживать трафик, а изменения «подрастают» рядом.

Польза для бизнеса:

  • релизы без ночных окон и «морозильников» в продажах;
  • меньше инцидентов из‑за блокировок и долгих транзакций;
  • предсказуемые сроки: миграции идут партиями, их можно поставить на паузу, ускорить или откатить.

Принципы безостановочных изменений (expand/contract)

  • Обратная совместимость. Новая версия кода должна работать со старой схемой, и наоборот, хотя бы некоторое время.
  • Две фазы: expand/contract.
    • Expand: добавляем новое (колонки, индексы, ограничения «невалидные», теневые таблицы), включаем двустороннюю запись, переносим данные.
    • Contract: переключаем чтение на новое, удаляем старое, убираем временные конструкции.
  • Короткие транзакции. Любые масcовые UPDATE/DELETE — партиями, с ограничением по времени и количеству строк.
  • Таймауты на блокировки. Ставим предел ожидания блокировок, чтобы миграция не повисла и не держала ресурсы.
  • Тест на копии. Прогоняем на свежем дампе: оценка времени, индексы, план запросов, влияние на автосборщик мусора (autovacuum).

Какие изменения безопасны, а какие опасны

Безопаснее всего:

  • ADD COLUMN без значения по умолчанию (NULL) — метаданные, быстро.
  • CREATE INDEX CONCURRENTLY — индекс строится без длинной блокировки таблицы.
  • REINDEX CONCURRENTLY — перестройка индекса без долгого стопа.
  • Ограничения с NOT VALID + VALIDATE CONSTRAINT — проверка данных без долголетних блокировок записи.
  • Добавление значения в enum через ALTER TYPE ... ADD VALUE — без переписывания таблицы.

Опасные изменения (делайте в несколько шагов):

  • Массовый UPDATE миллионов строк одной транзакцией.
  • ALTER TABLE ... ADD COLUMN ... DEFAULT ... NOT NULL на больших таблицах (на старых версиях PG переписывает таблицу; даже на новых — аккуратнее через две фазы).
  • ALTER COLUMN TYPE, если требуется преобразование — часто переписывает таблицу.
  • Удаление/переименование колонок, используемых приложением, без двусторонней записи и переключения чтения.
  • CREATE INDEX (без CONCURRENTLY) — блокировка записи на всё время сборки индекса.

Рецепты: колонки, индексы, ограничения, типы

Добавить колонку с дефолтом и NOT NULL без простоя

Пример: хотим добавить users.email_normalized, заполнить из users.email, сделать обязательно заполненной, а потом навесить уникальность.

  1. Добавляем колонку без ограничений:
ALTER TABLE users ADD COLUMN email_normalized text;
  1. Переносим данные партиями (см. раздел ниже), например: email_normalized = lower(email).

  2. Ставим дефолт для новых строк:

ALTER TABLE users ALTER COLUMN email_normalized SET DEFAULT '';
  1. Добавляем «мягкое» ограничение нотации через CHECK, чтобы не блокировать надолго:
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.

  1. Делаем индекс и уникальность без простоя:
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;

Переименование или удаление колонки

  • Expand: добавьте новую колонку new_name, начните «двойную запись» из приложения (и в старую, и в новую), перенесите историю.
  • Переключите чтение на новую колонку (фича‑флаг).
  • Contract: остановите запись в старую колонку, убедитесь, что чтений нет, удалить старую.
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.

  1. Добавляем новую колонку:
ALTER TABLE events ADD COLUMN payload_json jsonb;
  1. Двусторонняя запись в приложении: при вставке/обновлении заполняем обе колонки.

  2. Перенос истории партиями:

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;
  1. Переключаем чтение на payload_json.

  2. Удаляем старую колонку, при необходимости — переименовываем новую в старое имя.

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)

Замечания:

  • FOR UPDATE SKIP LOCKED позволяет нескольким инстансам скрипта работать параллельно без конфликтов.
  • Короткая транзакция + SLEEP снижают конкуренцию с рабочей нагрузкой.
  • На старте прогоните на копии базы, чтобы подобрать размер пакета и таймауты.

План выкатки и отката

Рекомендуемый порядок (пример на добавлении колонки и индекса):

  1. Expand:
    • Добавьте колонку без ограничений.
    • Выложите версию приложения, которая пишет в новую колонку и умеет читать из старой и новой.
    • Запустите пакетный перенос истории.
  2. Переключение:
    • Переведите чтение на новую колонку через флаг в конфиге.
    • Добавьте индексы/ограничения (CONCURRENTLY, NOT VALID + VALIDATE).
  3. Contract:
    • Уберите запись в старую колонку.
    • Через время наблюдения удалите старую колонку и временные конструкции.

Откат:

  • Пока не сделан contract, у вас есть старая колонка и код, который умеет из неё читать — возврат к старой версии безопасен.
  • Если успели удалить старое — откат сложнее: держите резервную точку восстановления (backup/снимок), или отложите удаление до стабильного периода.

Мониторинг, таймауты и защита от блокировок

Полезные настройки сеанса на время миграций:

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 + VALIDATE CONSTRAINT.
  • Не меняйте тип «в лоб», если требуется переписывание данных — делайте «теневую» колонку и двустороннюю запись.
  • Настройте таймауты: lock_timeout, statement_timeout, idle_in_transaction_session_timeout.
  • Прогоните миграцию на копии продовой базы с тем же объёмом данных.
  • Следите за автосборщиком мусора и мёртвыми строками.
  • Не удаляйте старое сразу — выдержите «период наблюдения».
  • Документируйте порядок отката: какие флаги переключить, какие задачи остановить, как почистить частично перенесённые данные.

Итог

Онлайн‑миграции — это дисциплина и чек‑листы, а не геройство админов ночью. Дробите изменения, держите обратную совместимость, используйте CONCURRENTLY и NOT VALID, переносите данные партиями с таймаутами. Результат — предсказуемые релизы, спокойная команда и отсутствие «окна простоя» в календаре продукта.


PostgreSQLмиграциибез простоя