Как безобидный 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 и холодным потом на спине.
В этой истории переплелись слепая вера в документацию, коварство встроенных функций и то, как на самом деле устроена очередь ожидания в ядре базы данных. Ниже я подробно разберу, почему так происходит на живых проектах, как обходить эти грабли на практике и как мы научили робота отлавливать подобные миграции еще до того, как они попадут в мастер.
Анатомия катастрофы: что стряслось под капотом Postgres
Начиная с одиннадцатой версии, в PostgreSQL появилась отличная оптимизация: если вы добавляете новую колонку с постоянным значением вроде строки 'active' или числа 0, сервер не перелопачивает терабайты на диске, а просто делает карандашную пометку в системном каталоге pg_attribute. Но стоит передать функцию, как магия моментально рассеивается.
Первая ловушка: коварство изменчивых функций
Разработчик указал DEFAULT now(). В терминах PostgreSQL функция now() (точно так же, как gen_random_uuid(), clock_timestamp() или random()) считается изменчивой — volatile. Для базы это означает строгий приказ: значение нельзя вычислить один раз и размножить, его необходимо рассчитывать заново для каждой отдельной записи.
Оптимизация каталога здесь отключается напрочь. Движок базы данных вынужден запереть таблицу наглухо, поднять с накопителя в оперативную память все тридцать миллионов строк, для каждой по-честному вычислить текущее время, физически перезаписать каждый блок данных обратно на накопитель и заново перестроить сопутствующие структуры.
На нашей таблице объемом в 45 гигабайт такой титанический труд занял бы минут восемь. Однако самое поразительное заключается в том, что продакшен рухнул вовсе не от нагрузки на процессор или диск. Его прикончила банальная очередь перед закрытой дверью.
Вторая ловушка: очередь блокировок и эффект застрявшего грузовика
Любая операция изменения структуры таблицы через ALTER TABLE требует самого строгого уровня изоляции — ACCESS EXCLUSIVE. Эта блокировка настолько ревнива, что не уживается ни с кем на свете: она не позволяет ни вставлять новые данные, ни изменять старые, ни даже просто читать строки через самый обычный SELECT.
Чтобы понять, как устроен этот коллапс, представьте себе кассу в небольшом круглосуточном магазине.
Сначала к кассиру подходит обычный неторопливый покупатель с кучей мелочи — в нашем случае это был фоновый аналитический запрос на чтение, который спокойно шуршал по базе около четырех секунд.
В этот момент в магазин заходит суровый грузчик со стремянкой и объявляет: «Всем стоять, сейчас меняем вывеску на потолке!» — это наша миграция ALTER TABLE, которой требуется полный эксклюзивный доступ ко всему залу. Кассир говорит грузчику: «Подожди пару секунд, я отпущу вот этого клиента с мелочью».
Казалось бы, никакой трагедии нет, подождать пару секунд совсем не трудно. Но правила в магазине непреклонны: раз пришел человек с эксклюзивным правом ремонта, больше ни одного нового покупателя с улицы в двери не пускают, пока ремонт не завершится.
В результате ремонтник смиренно ждет первого зазевавшегося клиента, а за его спиной на улице каждую секунду скапливаются сотни обычных покупателей, которые просто пришли за хлебом. Через четыре секунды крыльцо забито до отказа, толпа перегораживает тротуар, витрину выносят, а работа магазина полностью парализована.
В реляционных базах это называется эффектом Head-of-Line Blocking. Планировщик Postgres намеренно отдает приоритет ожидающему ALTER TABLE, чтобы системная миграция не голодала вечно за непрерывным потоком клиентских чтений. Но когда в живой продакшен летит по 250 клиентских запросов в секунду, пул соединений пулера PgBouncer переполняется за четыре секунды, прикладные сервисы захлебываются, а балансировщик Nginx начинает осыпать пользователей пятисотыми ошибками.
Вот так минутное изменение положило бизнес на сорок с лишним минут.
Три опасных сценария миграций и их бескровные альтернативы
1. Добавление колонки со значением по умолчанию
В типовом коде часто можно встретить попытку сделать все одним махом:
ALTER TABLE orders ADD COLUMN created_at TIMESTAMP DEFAULT now() NOT NULL;
Такая конструкция мгновенно забирает блокировку ACCESS EXCLUSIVE и уходит в долгую перезапись таблицы на диске.
Чтобы не устраивать простой, эту операцию аккуратно делят на несколько легких шагов. Сначала колонка добавляется без каких-либо дефолтов и ограничений, что обновляет лишь системный каталог за долю миллисекунды. Следом выставляется значение по умолчанию, но только для будущих вставок, что опять же происходит мгновенно. После этого старые накопившиеся строки спокойно обновляются в фоне небольшими порциями, не мешая пользователям сайта. И только когда в таблице не осталось пустых ячеек, на колонку безболезненно навешивается запрет пустых значений.
-- Шаг 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 idx_orders_user_id ON orders (user_id);
В таком виде команда захватывает блокировку уровня SHARE. Читать из таблицы клиенты по-прежнему смогут, но любая попытка добавить заказ, изменить профиль или списать деньги встанет колом до тех пор, пока индекс не достроится до самого конца.
Решается эта проблема использованием специального фонового режима, при котором Postgres строит структуру в два тихих прохода, вообще не блокируя пользовательскую запись:
-- Важно: CONCURRENTLY нельзя запускать внутри транзакционного блока BEGIN...COMMIT
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders (user_id);
3. Навешивание внешнего ключа на боевую базу
Прямолинейное объявление внешнего ключа кажется невинным ровно до тех пор, пока в таблице не накопились миллионы записей:
ALTER TABLE order_items ADD CONSTRAINT fk_order_items_product
FOREIGN KEY (product_id) REFERENCES products(id);
Здесь сервер снова берет строгую блокировку ACCESS EXCLUSIVE и принимается дотошно сверять каждую строчку таблицы с родительскими записями, замораживая все живые операции.
Вместо этого ограничение разбивают на две фазы. Сперва ключ объявляют с указанием отложить валидацию накопившихся данных, что занимает долю миллисекунды и начинает проверять только свежие входящие запросы. А затем отдельной командой запускается фоновая валидация старых строк, требующая лишь слабого замка SHARE UPDATE 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 = '3s';
Если по какой-то причине миграция не может получить доступ к таблице за отведенные три секунды, она обязана с грохотом упасть и откатиться, освободив дорогу живым клиентам. Гораздо лучше получить красное уведомление о неудавшемся релизе в сборочном пайплайне, чем положить весь бизнес перед созвоном с руководством.
Как мы поручили эту заботу роботу
Полагаться на человеческую внимательность при код-ревью бессмысленно: на сороковой строчке миграций глаз неизбежно замыливается даже у самого опытного тимлида.
Чтобы навсегда закрыть этот вопрос, мы написали легковесный гейткипер LockGuard на чистом Node.js без единой внешней зависимости. Он встраивается в GitHub Actions, разбирает пришедший SQL-код в синтаксическое дерево (AST) прямо в процессе сборки Pull Request, сопоставляет запросы с матрицей конфликтов ядра Postgres и при обнаружении опасных блокировок блокирует кнопку слияния веток. Более того, в комментарии к коду робот сразу прикладывает готовый безопасный паттерн замены.
Проверить миграции локально на своем компьютере можно одной командой:
npx lockguard check ./migrations
А подключить проверку в свой рабочий репозиторий GitHub — парой строчек в конфигурационном файле:
- uses: Mars444456/lockguard@v1
with:
migrations_dir: './migrations'
Вместо заключения
Базы данных падают не от недостатка теоретических знаний, а от банальной спешки и усталости перед релизом. Защитить свой сервис от внезапных ночных аварий на самом деле не так уж сложно. Достаточно приучить команду всегда выставлять короткий трехсекундный таймаут, собирать индексы исключительно в фоновом режиме, делить валидацию тяжелых ключей на два этапа и не пихать изменчивые функции в значения по умолчанию. А еще лучше — один раз настроить беспристрастную автоматическую проверку в CI/CD и спать по ночам спокойно.
Полезные ссылки:
Исходный код проекта: github.com/Mars444456/lockguard
Telegram автора для вопросов и бесплатного аудита миграций: @marswwu
Теги:
postgresql, postgres, миграции, access exclusive, блокировки баз данных, devops, pgbouncer, highload, zero downtime, ci cd
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.