PostgreSQL - мощная база, но стоит запустить ALTER TABLE на таблице с живым трафиком, и можно получить ступор всего сервиса. Даже безобидное добавление столбца на старых версиях превращалось в долгую блокировку. В этом руководстве разберем, как менять схему, не роняя прод: от физики блокировок до боевых паттернов и инструментов.
Почему обычные миграции опасны
Как PostgreSQL блокирует таблицы при DDL
Любая команда, меняющая структуру объекта, навешивает блокировку. Большинство DDL-операций требуют наивысшего уровня - ACCESS EXCLUSIVE. Эта блокировка запрещает кому‑либо ещё читать или писать в таблицу, пока ALTER TABLE не завершится. То есть приложение встаёт колом.
Проблема не в том, что блокировка есть - она нужна для целостности. Проблема в том, что операция может длиться минутами или часами (например, при перезаписи всей таблицы), и всё это время таблица недоступна.
Что такое ACCESS EXCLUSIVE и почему чтение тоже встаёт
Уровни блокировок в PostgreSQL выстроены по строгости. ACCESS EXCLUSIVE - самый сильный. Он конфликтует с любыми другими блокировками: и с ACCESS SHARE (которую берёт обычный SELECT), и с ROW EXCLUSIVE (для INSERT, UPDATE, DELETE). Поэтому даже чтение не проходит, пока миграция держит лок.
Реальные сценарии: когда ALTER TABLE вешает прод
Представь: ты выполнил ALTER TABLE orders ADD COLUMN metadata jsonb DEFAULT '{}'::jsonb на десятках миллионов строк. До PostgreSQL 11 это переписывало всю таблицу на диск, попутно удерживая ACCESS EXCLUSIVE. Приложение не может оформить заказ, отобразить историю - всё висит. А если в этот момент крутилась репликация - реплика тоже встанет, как только дойдёт до команды.
Какие операции можно делать без простоя
Добавление столбца без DEFAULT и NOT NULL
Добавить nullable-колонку без значения по умолчанию - быстрая операция. PostgreSQL лишь обновляет системный каталог, не трогая строки. Блокировка берётся кратковременно и не успевает зааффектить пользователей. То же справедливо для удаления столбцов и переименования таблиц (если к ним нет активных обращений).
Создание индекса с CONCURRENTLY
Обычный CREATE INDEX навешивает такой же ACCESS EXCLUSIVE до конца построения. Команда CREATE INDEX CONCURRENTLY работает в два сканирования с минимальными блокировками: она позволяет и чтение, и запись во время сборки. Плата - индекс строится дольше и потребляет больше ресурсов, но прод продолжает работать.
Безопасные изменения после PostgreSQL 11 (ADD COLUMN DEFAULT)
Начиная с PostgreSQL 11, механизм хранения изменился: ALTER TABLE ... ADD COLUMN ... DEFAULT больше не перезаписывает всю таблицу. База просто запоминает дефолтное значение в каталоге, и новые строки его получают, а старым оно подставляется при чтении. Время эксклюзивной блокировки сокращается до миллисекунд, что позволяет добавлять колонки с DEFAULT без даунтайма (с оговоркой, что NOT NULL вместе с DEFAULT тоже стала быстрой при условии отсутствия NULL в существующих данных или если даётся константное значение).
Ограничения: что всё ещё требует полной блокировки
Остаётся множество операций, которые вызывают перезапись всей таблицы:
- изменение типа столбца (даже
varchar(255)→textв общем случае); - изменение значения по умолчанию у существующей колонки без добавления новой;
- добавление
DEFAULTс пересчётом на старых версиях (<11); - переименование столбцов и таблиц с последующей ломкой кода приложения (сама по себе операция быстрая, но ломает читающие запросы).
Вывод: «онлайн‑изменение схемы» в PostgreSQL не является встроенной функцией для всех случаев, и за пределами простых действий нужны обходные стратегии.
Паттерн Expand‑Migrate‑Contract
Суть подхода: расширить, мигрировать, сузить
Вместо одномоментной замены колонки или таблицы ты эволюционируешь схему в три шага:
- Expand (расширение): добавить новые структуры, ничего не удаляя. Приложение продолжает писать по‑старому, но уже может работать с новыми полями.
- Migrate (миграция): перегнать данные из старого формата в новый (скриптами или в фоне), адаптировать код приложения на чтение и запись в новые структуры.
- Contract (сужение): после того, как старый формат больше не используется, удалить устаревшие колонки/таблицы.
Главный принцип: ни одно изменение не должно требовать одновременного деплоя схемы и кода. Приложение сначала учится читать из нового, потом писать в новое, и только потом ты подчищаешь старое.
Пример: замена колонки без даунтайма
Допустим, нужно разделить поле full_name на first_name и last_name. Бездумный ALTER TABLE ... DROP COLUMN full_name, ADD COLUMN first_name text, ADD COLUMN last_name text сломает приложение. Правильный план:
- Добавляем nullable-колонки
first_nameиlast_name(расширение). - Настраиваем триггер или фоновый воркер, который заполняет новые колонки при вставке/обновлении, а также обрабатывает существующие строки. Код приложения начинает читать из новых колонок, если они заполнены, и писать в них.
- Когда все строки смигрированы и код больше не обращается к
full_name, удаляемfull_name(сужение).
Как синхронизировать приложение и схему
Главный помощник - обратная совместимость. Новый код должен уметь читать как старую, так и новую схему. Часто помогает деплой двухфазного релиза:
- Версия 1: схема расширена, код читает из нового и пишет в старое.
- Версия 2: код полностью переведён на новые поля, запускается clean-up миграция.
Всё это время никаких rename для используемых сущностей - только добавление новых и удаление неиспользуемых.
Практический чек‑лист безопасной миграции
Категоризация изменений по риску
Перед каждым ALTER задай вопросы:
- Требует ли операция перезаписи таблицы? (смена типа, добавление
DEFAULTна старых версиях) - Держит ли она
ACCESS EXCLUSIVEдольше нескольких миллисекунд? - Может ли сломать читающие запросы приложения? (переименование, удаление) На основе этого разбей миграции на безопасные, умеренные и опасные. Для опасных применяй expand‑contract.
Обязательный lock_timeout в скриптах
Никогда не запускай DDL на боевой базе без SET lock_timeout. Пример:
BEGIN;
SET LOCAL lock_timeout = '2s';
ALTER TABLE orders ADD COLUMN tracking_id text;
COMMIT;
Если команда не может получить блокировку за 2 секунды, она упадёт с ошибкой, а не подвесит приложение на неопределённое время. Выбирай таймаут в зависимости от допустимой задержки твоего сервиса.
Контроль лага реплик и долгих транзакций
До запуска миграции убедись, что репликация не отстаёт. Долгая транзакция на мастере может задержать применение WAL на реплике, и если миграция ещё и заблокирует чтение, реплика отстанет сильнее. Проверь pg_stat_replication и заверши зависшие транзакции (pg_stat_activity) с помощью pg_terminate_backend, если они мешают.
План отката: как написать и протестировать
Каждая миграция должна иметь скрипт отката, протестированный в staging‑окружении. Для expand‑contract откат - это просто удаление добавленных колонок, если приложение ещё не начало на них завязываться. Храни DDL‑изменения в системе контроля версий и документируй шаги возврата.
Инструменты для zero‑downtime миграций
pgroll (xataio) - декларативные миграции с версионированием схемы
pgroll - опенсорсный инструмент, который позволяет описывать миграции в декларативном JSON-формате, а он сам генерирует необходимые шаги expand‑contract и управляет версиями схемы. Приложение может узнавать текущую версию и выбирать соответствующие колонки. Это снижает ручную работу и риск ошибок, особенно в больших проектах.
Другие подходы: ручные скрипты, strong_migrations
Если pgroll не внедрён, работают старые добрые ручные сценарии, организованные по принципам выше. В экосистеме Ruby on Rails популярен гем strong_migrations - он блокирует запуск опасных операций без явного подтверждения и напоминает о CONCURRENTLY и lock_timeout. Аналогичные практики можно реализовать в любом фреймворке, просто включив дисциплину.
pg_upgrade - миграция мажорной версии без простоя
pg_upgrade позволяет обновить PostgreSQL с одной мажорной версии на другую с минимальным окном простоя (обычно это время запуска нового сервера и копирования системных каталогов). При использовании репликации можно провернуть обновление с практически незаметной остановкой: поднять новый кластер как реплику, переключиться на него, а затем выполнить pg_upgrade на бывшей реплике. Детали смотри в официальной документации PostgreSQL.
Мониторинг и отладка
Какие параметры логов включить
В postgresql.conf обязательно активируй:
log_lock_waits = on- фиксирует в логах ситуации, когда транзакция ждёт блокировку дольшеdeadlock_timeout. Помогает выявить проблемные миграции.log_min_duration_statement = 200(в миллисекундах) - записывает все запросы, выполнявшиеся дольше порога. Показывает, какой DDL вызвал длительную блокировку.log_statement = 'ddl'(опционально, на время отладки) - логирует все DDL-команды.
Как находить проблемные запросы и висящие транзакции
Запрос к pg_stat_activity с фильтром по wait_event_type = 'Lock' покажет, кто и какой блокировки ждёт. Запросы в состоянии idle in transaction с длительным xact_start - кандидаты на принудительное завершение. Если миграция всё же повисла, нахождение блокирующего PID и вызов pg_cancel_backend или pg_terminate_backend могут спасти ситуацию, вернув приложение к жизни.
Распространённые ошибки и как их избежать
- Переименовывать колонку или таблицу «в лоб». Код старых релизов немедленно начнёт получать ошибки. Вместо этого - создай новую колонку и удали старую после перехода.
- Создавать индекс без
CONCURRENTLYна горячей таблице. Даже на средних объёмах это кладёт прод. Всегда добавляйCONCURRENTLY, если только таблица не заблокирована полностью на время обслуживания. - Гнать миграцию при большом лаге реплик. После завершения DDL на мастере реплике придётся нагнать, а если она отстаёт - приложение, переключённое на реплику, увидит устаревшие данные. Проверяй лаг заранее.
- Забывать про
lock_timeout. Самая частая причина ночных инцидентов. Пропиши таймаут в теле каждой DDL-миграции. - Надеяться, что «на стейдже всё прошло быстро». Продакшен отличается количеством конкурентных запросов и длительностью блокировок. Имей запас по таймаутам и следи за логами.
FAQ
Можно ли изменить тип колонки без простоя?
Напрямую - нет. Любое изменение типа требует ACCESS EXCLUSIVE и полного переписывания таблицы. Единственный способ без даунтайма - применить expand‑contract: создать новую колонку с нужным типом, постепенно заполнить её данными, перевести приложение, а затем удалить старую.
Как добавить столбец с DEFAULT и не уронить прод?
На PostgreSQL 11 и новее добавление колонки с константным значением по умолчанию выполняется быстро, без перезаписи строк. Если версия старше - придётся либо обновить базу, либо использовать обходной манёвр: добавить column без DEFAULT, а затем отдельными батчами проставить значение (что тоже может дать нагрузку, но без долгой блокировки).
Обязательно ли использовать pgroll?
Нет. pgroll удобен для крупных проектов с частыми изменениями схемы и множеством окружений, но ручные сценарии на основе expand‑contract с lock_timeout и CONCURRENTLY отлично работают. Выбор зависит от сложности системы и готовности команды внедрять новый инструмент.
Что делать, если миграция всё же заблокировала таблицу?
Если миграция уже выполняется и держит лок, найди её PID через pg_stat_activity, оцени, сколько она ещё будет работать. Если время неприемлемо - прерывай командой pg_cancel_backend (мягко) или pg_terminate_backend (жёстко). Затем проанализируй, почему сработала блокировка: возможно, забыли CONCURRENTLY или lock_timeout, и повтори с учётом исправлений.
Как проверить, что индекс создаётся конкурентно?
В тексте команды должно быть слово CONCURRENTLY. В pg_stat_activity такой запрос будет висеть в состоянии active, при этом другие операции с таблицей не блокируются. Мониторь количество сканирований (index_scan / seq_scan), но точных таймингов гарантировать нельзя - длительность зависит от загрузки и объёма данных.
Можно ли мигрировать мажорную версию PostgreSQL без простоя?
Да, но это отдельная процедура. pg_upgrade сам по себе требует остановки обоих кластеров на время копирования каталогов, но окно простоя можно сократить до нескольких минут. С помощью репликации и переключения на стендбай можно добиться околонулевого простоя для клиентов. Детали описаны в официальном руководстве по pg_upgrade.
Вывод
Zero‑downtime миграции в PostgreSQL - это не отсутствие блокировок как факт, а грамотное управление ими. Ключевые правила: знай, какой уровень блокировки вызывает твоя команда, используй lock_timeout, строй индексы через CONCURRENTLY, обновляйся до актуальных версий и осваивай паттерн expand‑migrate‑contract. Тогда даже серьёзные изменения схемы проходят незаметно для пользователей, а прод остаётся стабильным.



.svg.webp)





