Изменение схемы БД без простоя: паттерн expand and contract
Продвинутое4 минРедакция Innovialabs
Коротко
Не меняйте живую схему одним ломающим шагом: сначала расширьте её (добавьте новые структуры рядом со старыми), постепенно перенесите данные и код, а потом сожмите — удалите старое, когда им уже никто не пользуется.
Коротко
Изменение схемы приводит к простою по двум причинам: блокировки, которые останавливают запросы во время изменения, и несовместимость новой схемы с кодом приложения, который ещё работает. Паттерн expand and contract решает обе:
- Expand (расширение): добавляете новые колонки, таблицы или индексы, ничего не удаляя. Работают и старый, и новый код.
- Миграция: выкатываете код, который пишет в обе структуры, переносите старые данные порциями, переключаете чтение.
- 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.
- Expand:
ALTER TABLE users ADD COLUMN full_name text; - Двойная запись: выкатите код, который пишет в обе колонки (или триггер, копирующий
nameвfull_name). - Backfill небольшими порциями, чтобы избежать долгих транзакций и отставания реплик:
UPDATE users SET full_name = name
WHERE id IN (
SELECT id FROM users
WHERE full_name IS NULL
LIMIT 5000
);
-- повторять, пока не обновится 0 строк
- Переключите чтение на
full_name, затем перестаньте писать вname. - 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 может кешировать список колонок и падать после удаления.