Как безобидный ADD COLUMN положил наш продакшен на 43 минуты (и почему Postgres 11+ вас не спасет)

Четверг, 14:15. До планового созвона с бизнесом оставалось сорок минут. Наш мидл-разработчик накатил в стейджинг короткий Pull Request: нужно было добавить колонку с датой активации фичи в таблицу заказов orders.

Одна строчка в DDL:

ALTER TABLE orders ADD COLUMN activated_at TIMESTAMP DEFAULT now() NOT NULL;

Код-ревью пройдено за три минуты. В стейджинге миграция применилась за 12 миллисекунд. CI позеленел. Кнопка «Merge» нажата, пошел деплой в прод.

Ровно через 18 секунд в корпоративном Slack взорвался канал #alerts-critical:

  • PagerDuty: Latency p99 > 15 000ms [CRITICAL]
  • Nginx: HTTP 504 Gateway Timeout spike: 94.2%
  • PgBouncer: server_login_retry: connection pool exhausted (max_connections = 300 reached)

Продакшен e-commerce платформы с 30 миллионами заказов и дневным оборотом в сотни тысяч долларов перестал принимать оплату и оформлять корзины. Ближайшие 43 минуты превратились в лихорадочный пожар с дропом коннектов, ручным киллом PID в pg_stat_activity и холодным потом на спине.

В этой статье я расскажу:

  1. Почему миф «в PostgreSQL 11+ добавление колонок с DEFAULT стало мгновенным» убивает базы данных.
  2. Что такое Head-of-Line Blocking в очереди блокировок Postgres и почему медленный SELECT в связке с быстрым DDL гарантированно роняет сервис.
  3. Как правильно писать zero-downtime миграции (готовые SQL-паттерны).
  4. Как мы навсегда решили эту проблему у себя, написав открытый AST-гейткипер LockGuard, который блокирует такие PR еще на этапе CI/CD.

1. Анатомия катастрофы: Что на самом деле произошло в ядре Postgres?

В Postgres 11 действительно появилась отличная оптимизация: если вы добавляете колонку с константным значением (например, DEFAULT 'active'), Postgres не перезаписывает таблицу на диске, а просто записывает метаданные в системный каталог pg_attribute.

Но дьявол кроется в двух вещах: типе выражения и очереди блокировок.

Ловушка №1: Volatile-функции

Разработчик написал: DEFAULT now().Функция now() (или gen_random_uuid(), clock_timestamp(), random()) в терминах PostgreSQL является volatile. Это значит, что для каждой строки таблицы значение должно вычисляться заново!

Оптимизация каталога здесь отключается. PostgreSQL обязан:

  • Заблокировать таблицу.
  • Прочитать 30 миллионов строк с диска в память.
  • Вычислить now() для каждого кортежа (tuple).
  • Переписать страницу данных на диск.
  • Перестроить сопутствующие структуры.

На нашей таблице в 45 ГБ эта операция в фоне занимала около 8 минут. Но даже если бы это длилось всего 3 секунды, нас убило не это. Нас убила очередь блокировок.

Ловушка №2: Очередь блокировок и Head-of-Line Blocking

Любой ALTER TABLE требует эксклюзивной блокировки самого высокого уровня — ACCESS EXCLUSIVE.

ACCESS EXCLUSIVE конфликтует абсолютно со всеми другими операциями: не только с INSERT, UPDATE или DELETE, но и с обычным безобидным SELECT.

Как устроена очередь в диспетчере блокировок PostgreSQL:1. Пользовательский трафик: SELECT * FROM orders WHERE user_id = 42 (длится 4 секунды).2. Миграция: ALTER TABLE orders ADD COLUMN ... запрашивает ACCESS EXCLUSIVE. Диспетчер локов Postgres: «Таблица занята SELECT'ом. ALTER TABLE встает ждать в очередь».3. Новые входящие SELECT'ы от API (250 запросов в секунду). Диспетчер локов: «Стоп! В очереди УЖЕ ЖДЕТ запрос с ACCESS EXCLUSIVE. Ни один новый SELECT/INSERT/UPDATE не будет запущен, пока не пройдет DDL!»

Это фундаментальный принцип планировщика Postgres: запрос с исключительной блокировкой имеет приоритет ожидания, чтобы транзакции DDL не голодали вечно.

Но на продакшене с постоянным потоком запросов происходит следующее:

  1. ALTER TABLE встает в очередь за одним «зазевавшимся» аналитическим SELECT.
  2. Все следующие тысячи клиентских запросов встают за ним.
  3. PgBouncer забивает весь пул max_connections за 4 секунды.
  4. Все микросервисы получают Connection refused или отваливаются по таймауту.
  5. Сервер приложений захлебывается, Nginx плюется 504 ошибками.

Вот так «мгновенная» миграция положила весь бизнес на 43 минуты.

2. Три смертных греха миграций в PostgreSQL (и их безопасные альтернативы)

Грех №1: ADD COLUMN с volatile DEFAULT

Как делают обычно (опасно):

ALTER TABLE orders ADD COLUMN created_at TIMESTAMP DEFAULT now() NOT NULL;

Уровень блокировки: ACCESS EXCLUSIVE + полный rewrite таблицы на диске.

Как делать по-взрослому (Zero-Downtime паттерн):

-- Шаг 1: Добавляем колонку как nullable без default (мгновенный апдейт каталога, 1 мс) ALTER TABLE orders ADD COLUMN created_at TIMESTAMP; -- Шаг 2: Выставляем дефолт только для будущих вставок (тоже мгновенно) ALTER TABLE orders ALTER COLUMN created_at SET DEFAULT now(); -- Шаг 3: Фоново бэкфиллим старые строки батчами по 5000 строк (вне транзакции) -- UPDATE orders SET created_at = now() WHERE id BETWEEN 1 AND 5000; -- Шаг 4: Когда NULL не осталось — навешиваем NOT NULL ALTER TABLE orders ALTER COLUMN created_at SET NOT NULL;

Грех №2: CREATE INDEX без модификатора CONCURRENTLY

Как делают обычно (опасно):

CREATE INDEX idx_orders_user_id ON orders (user_id);

Уровень блокировки: SHARE. Блокирует любые операции записи (INSERT, UPDATE, DELETE) во всю таблицу до тех пор, пока индекс не построится целиком.

Как делать правильно:

-- Важно: CONCURRENTLY нельзя запускать внутри транзакционного блока BEGIN...COMMIT CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders (user_id);

Postgres построит индекс в два прохода, не блокируя ни чтение, ни запись.

Грех №3: Добавление Foreign Key напрямую

Как делают обычно (опасно):

ALTER TABLE order_items ADD CONSTRAINT fk_order_items_product FOREIGN KEY (product_id) REFERENCES products(id);

Уровень блокировки: ACCESS EXCLUSIVE. Сканирует всю таблицу под строгим локом.

Как делать правильно (двухфазная валидация):

-- Шаг 1: Добавляем ограничение с флагом NOT VALID (мгновенный лок на доли миллисекунды) ALTER TABLE order_items ADD CONSTRAINT fk_order_items_product FOREIGN KEY (product_id) REFERENCES products(id) NOT VALID; -- Шаг 2: Валидируем существующие данные отдельно (берет слабый SHARE UPDATE EXCLUSIVE, запись и чтение работают!) ALTER TABLE order_items VALIDATE CONSTRAINT fk_order_items_product;

Главный золотой закон: SET lock_timeout

Каждая миграция в вашей базе данных без исключения должна начинаться строкой:

SET lock_timeout = '3s';

Если в течение 3 секунд миграция не смогла получить блокировку, она немедленно завершится с ошибкой, а не встанет во главе очереди, блокируя собой сотни запросов клиентов. Лучше пусть упадет деплой в CI, чем ляжет весь продакшен!

3. Как мы автоматизировали это через LockGuard

Код-ревью — это человеческий фактор. На 50-й строчке диффа миграций человеческий глаз замыливается. Мы написали легковесный гейткипер LockGuard (на чистом Node.js, с нулевым количеством внешних зависимостей).

Он парсит SQL миграции в AST прямо в GitHub Actions PR, сопоставляет DDL с матрицей блокировок Postgres и при наличии опасных локов завершается с ошибкой, блокируя кнопку Merge в GitHub. А в комментарии к PR бот сразу отдает разработчику готовый безопасный код замены.

Проверить свои миграции локально можно прямо сейчас через npx:

npx lockguard check ./migrations

А подключить к себе в GitHub Action — одной строкой в workflow:

- uses: Mars444456/lockguard@v1 with: migrations_dir: './migrations'

Заключение и чек-лист для проверки

Базы данных падают не от недостатка знаний, а от усталости и спешки перед релизом. Сделайте себе подарок к следующему релизу — добавьте в ваш CI автоматический гейткипер блокировок.

  • [ ] Стоит ли в начале миграции SET lock_timeout = '3s';?
  • [ ] Создаются ли индексы строго через CONCURRENTLY?
  • [ ] Добавляются ли внешние ключи через NOT VALID + VALIDATE?
  • [ ] Добавляются ли новые колонки без volatile-выражений в DEFAULT?
  • [ ] Проверен ли PR автоматическим гейткипером?

Полезные ссылки:

  • Репозиторий проекта на GitHub: github.com/Mars444456/lockguard
  • Telegram автора для связи и бесплатного аудита ваших миграций: @marswwu

Теги:

Хабы: