Перейти к содержанию
Innovialabs

Изменение схемы БД без простоя: паттерн expand and contract

Продвинутое4 мин

Коротко

Не меняйте живую схему одним ломающим шагом: сначала расширьте её (добавьте новые структуры рядом со старыми), постепенно перенесите данные и код, а потом сожмите — удалите старое, когда им уже никто не пользуется.

Коротко

Изменение схемы приводит к простою по двум причинам: блокировки, которые останавливают запросы во время изменения, и несовместимость новой схемы с кодом приложения, который ещё работает. Паттерн expand and contract решает обе:

  1. Expand (расширение): добавляете новые колонки, таблицы или индексы, ничего не удаляя. Работают и старый, и новый код.
  2. Миграция: выкатываете код, который пишет в обе структуры, переносите старые данные порциями, переключаете чтение.
  3. Contract (сжатие): когда старую структуру никто не использует, удаляете её.

Каждый шаг — отдельный небольшой обратимый деплой.

Правило ноль: защита от очереди блокировок

В PostgreSQL большинство команд ALTER TABLE требуют блокировку ACCESS EXCLUSIVE. Даже если само изменение мгновенное, оно ждёт завершения текущих транзакций, а все новые запросы встают в очередь за ним. Один долгий отчёт может заморозить таблицу для всех.

Всегда задавайте короткий таймаут блокировки в сессии миграции и повторяйте при неудаче:

SET lock_timeout = '3s';
SET statement_timeout = '15min';
ALTER TABLE orders ADD COLUMN note text;

Если блокировку не удалось взять вовремя, миграция аккуратно падает, а не роняет сервис.

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

ALTER TABLE ... RENAME COLUMN выполняется мгновенно, но ломает все работающие экземпляры старого кода. Делайте это поэтапно. Пример: name превращается в full_name.

  1. Expand: ALTER TABLE users ADD COLUMN full_name text;
  2. Двойная запись: выкатите код, который пишет в обе колонки (или триггер, копирующий name в full_name).
  3. Backfill небольшими порциями, чтобы избежать долгих транзакций и отставания реплик:
UPDATE users SET full_name = name
WHERE id IN (
  SELECT id FROM users
  WHERE full_name IS NULL
  LIMIT 5000
);
-- повторять, пока не обновится 0 строк
  1. Переключите чтение на full_name, затем перестаньте писать в name.
  2. Contract: ALTER TABLE users DROP COLUMN name; — в одном из следующих релизов.

Добавление колонки NOT NULL

Добавить nullable-колонку или колонку с константным значением по умолчанию в современных версиях PostgreSQL можно без перезаписи таблицы — меняются только метаданные. Рискованная часть — включить NOT NULL на существующей колонке: обычный SET NOT NULL сканирует всю таблицу под эксклюзивной блокировкой.

Безопасная последовательность:

-- 1. Добавляем без ограничения
ALTER TABLE orders ADD COLUMN currency text;

-- 2. Код всегда заполняет поле, старые строки заполняем порциями

-- 3. Добавляем непроверенный CHECK (быстро)
ALTER TABLE orders ADD CONSTRAINT orders_currency_nn
  CHECK (currency IS NOT NULL) NOT VALID;

-- 4. Проверяем, не блокируя запись
ALTER TABLE orders VALIDATE CONSTRAINT orders_currency_nn;

-- 5. По желанию превращаем в настоящий NOT NULL (свежие версии
--    PostgreSQL используют проверенный CHECK и не сканируют таблицу)
ALTER TABLE orders ALTER COLUMN currency SET NOT NULL;
ALTER TABLE orders DROP CONSTRAINT orders_currency_nn;

Тот же приём NOT VALID, затем VALIDATE работает и для внешних ключей.

Индексы без блокировки

Обычный CREATE INDEX блокирует запись в таблицу до завершения. На большой таблице это может занять много времени. Используйте:

CREATE INDEX CONCURRENTLY idx_orders_customer
  ON orders (customer_id);

Что важно знать:

  • Команда не работает внутри транзакции, поэтому многим инструментам миграций нужен специальный флаг, отключающий обёртку в транзакцию.
  • Она медленнее и проходит по таблице дважды.
  • При ошибке остаётся INVALID-индекс. Удалите его через DROP INDEX CONCURRENTLY и повторите.
  • Для удаления и пересборки есть DROP INDEX CONCURRENTLY и REINDEX CONCURRENTLY.

В MySQL InnoDB многие операции поддерживают online DDL (ALGORITHM=INPLACE, LOCK=NONE), а инструменты вроде gh-ost или pt-online-schema-change перестраивают таблицу в фоне.

Чек-лист перед миграцией

  • Изменение обратно совместимо с кодом, который сейчас в продакшене?
  • Какие команды берут эксклюзивную блокировку или перезаписывают таблицу? Проверьте документацию для конкретной формы ALTER.
  • Задан ли lock_timeout и есть ли повтор?
  • Backfill идёт порциями, с паузами и контролем отставания реплик?
  • Каждый шаг можно откатить отдельно?
  • Вы прогнали миграцию на копии данных продакшен-объёма?

Частые ошибки

  • Менять тип колонки на месте (ALTER COLUMN TYPE) — часто это перезапись всей таблицы. Лучше новая колонка и backfill.
  • Удалять колонку в том же релизе, где код перестал её использовать, пока старые экземпляры ещё работают.
  • Один гигантский UPDATE для backfill: таблица раздувается, реплики отстают.
  • Забывать, что ORM может кешировать список колонок и падать после удаления.

Частые вопросы

На маленьких таблицах прямое изменение обычно проходит мгновенно. Но проблема совместимости остаётся: если во время деплоя ещё работает старый код, переименование или удаление колонки всё равно сломает запросы.

Поможем с этим

Если хотите не разбираться самим, а получить результат — этим мы занимаемся каждый день.

Обсудить проект

Читайте также

Есть идея или задача? Расскажите нам

Ответим в течение рабочего дня и предложим решение. Консультация бесплатная.