Как безобидный 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 и холодным потом на спине.
В этой статье я расскажу:
- Почему миф «в PostgreSQL 11+ добавление колонок с DEFAULT стало мгновенным» убивает базы данных.
- Что такое Head-of-Line Blocking в очереди блокировок Postgres и почему медленный SELECT в связке с быстрым DDL гарантированно роняет сервис.
- Как правильно писать zero-downtime миграции (готовые SQL-паттерны).
- Как мы навсегда решили эту проблему у себя, написав открытый 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 не голодали вечно.
Но на продакшене с постоянным потоком запросов происходит следующее:
- ALTER TABLE встает в очередь за одним «зазевавшимся» аналитическим SELECT.
- Все следующие тысячи клиентских запросов встают за ним.
- PgBouncer забивает весь пул max_connections за 4 секунды.
- Все микросервисы получают Connection refused или отваливаются по таймауту.
- Сервер приложений захлебывается, 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
Теги:
Хабы: