Про безопасные миграции написано уже тысячу раз. Мы все наизусть знаем и про expand/contract, и про обратную совместимость, и про то, что схему нельзя ломать под трафиком. А потом всё равно наступаем на эти грабли.
Вот свежий случай. Была задача на полчаса, надо было переименовать две перепутанные колонки. Прод целиком не лёг, но за одну выкатку я собрал сразу два инцидента. Причём один прилетел изнутри сервиса, а второй снаружи, оттуда, откуда я вообще не ждал.
Дальше расскажу, как так вышло.
С чего всё началось
У нас есть таблица-связка seller_item_identifier_order_item, дальше буду звать её siioi. Она цепляет позицию FBS-заказа к её маркировке, то есть к IMEI или серийному номеру. В ней две ссылки:
order_item_id, которая должна вести на позицию заказа,seller_item_identifier_id, которая должна вести на маркировку.
А исторически в эти колонки клали всё наоборот, и имена по факту врали. Работать-то оно работало, но любой, кто читал таблицу по названиям колонок, получал совсем не то, что ожидал.
Чинить мы решили по-честному, через expand/contract. Сначала добавляем правильные колонки и какое-то время живём на двух наборах сразу, а потом, когда всё устаканится, убираем старые. Ничего экзотического тут нет.
Фаза 1 прошла без сюрпризов

Мы добавили колонки real_order_item_id и real_seller_item_identifier_id, забэкфилили в них данные и перевели код на новые имена. А ещё повесили триггер, потому что во время rolling deploy в кластере какое-то время живут одновременно и старые поды, которые пишут в старые колонки, и новые, которые пишут в real_*. Чтобы обе пары всегда оставались в синхроне, и нужен был триггер:
CREATE TRIGGER siioi_sync_columns
BEFORE INSERT ON public.seller_item_identifier_order_item
FOR EACH ROW EXECUTE FUNCTION sync_siioi_columns();Триггер двунаправленный, так что стоило заполнить одну пару колонок, и вторая подтягивалась сама. Прод пожил несколько дней, данные сошлись, всё было идеально. Тут мы и расслабились.
Фаза 2 и та самая «формальность»
Осталось убрать строительные леса, и на это ушло два changeset'а:
-- 1. снимаем триггер
DROP TRIGGER IF EXISTS siioi_sync_columns ON public.seller_item_identifier_order_item;
DROP FUNCTION IF EXISTS sync_siioi_columns();
-- 2. удаляем старые колонки
ALTER TABLE public.seller_item_identifier_order_item
DROP COLUMN IF EXISTS order_item_id,
DROP COLUMN IF EXISTS seller_item_identifier_id;Казалось бы, что тут может пойти не так. А пошло не так вообще всё.
Инцидент №1, deadlock и пятисотки

Сначала всё шло по обычному пайплайну. Прогнали миграцию на dev, потом на stage и pre-prod, где данных примерно столько же, сколько на проде. Везде зелено, триггер снялся, колонки удалились, придраться не к чему. В прод выкатывались спокойно, как к формальности.
А на проде всё посыпалось прямо во время выката. Миграция упала с ошибкой посреди деплоя, в логах deadlock, и сама выкатка откатилась назад. Вот только откатилось не всё: первый changeset, который снимал триггер и функцию, успел закоммититься, поэтому обратно они не вернулись. А старые колонки так и остались с ограничением NOT NULL.
И тут захлопнулась ловушка. Приложение давно пишет только в новые real_*, синхронизировать их со старыми больше некому, а база на каждый INSERT ругается, что старые NOT NULL колонки нельзя оставлять пустыми. Через семь минут после старта легла ручка создания идентификаторов: 100% пятисоток, продавцы не могут добавить маркировку, а без неё не собрать заказ.
Откуда взялся сам deadlock, спросите вы. А вот из-за этого с виду безобидного ALTER:

ALTER TABLE ...
DROP COLUMN order_item_id,
DROP COLUMN seller_item_identifier_id;Дело в том, что обе колонки несут на себе внешние ключи. И когда мы удаляем их одним стейтментом, Postgres берёт AccessExclusiveLock не только на саму siioi, но сразу и на обе таблицы, на которые ссылаются эти ключи. А в это время под трафиком крутятся обычные SELECT с джойнами, и они держат блокировки на тех же таблицах, только в другом порядке. Вот вам и встречка, в которой кто-то должен уступить, а уступает, как водится, миграция.
А почему тогда на dev и stage всё прошло гладко? Дело не в данных, их на стендах хватало. Просто deadlock рождается только из конкуренции за блокировки, а на тестовых стендах нет нагрузки и нет встречных запросов, так что опасное окно не успевает возникнуть. Тесты зелёные, прод красный, классика.
Чинили мы по сути тем, что доделали то, что должно было оказаться в миграции с самого начала:
-- сначала снимаем обязательность
ALTER TABLE ... ALTER COLUMN order_item_id DROP NOT NULL,
ALTER COLUMN seller_item_identifier_id DROP NOT NULL;
-- и только потом дропаем колонки, причём по одной, а не пачкой
ALTER TABLE ... DROP COLUMN order_item_id;
ALTER TABLE ... DROP COLUMN seller_item_identifier_id;В сумме где-то час импакта. А урок такой:
В contract-фазе сначала снимайте ограничения через
DROP NOT NULLи только потом убирайте механизмы вроде триггера. Тогда даже если какой-то шаг упадёт, запись всё равно продолжит работать. По-хорошему каждое промежуточное состояние миграции должно быть рабочим само по себе, потому что любой шаг может не доехать до конца.
И есть второй урок, поменьше. Любой DDL под трафиком стоит резать на мелкие атомарные стейтменты, а multi-column DROP по колонкам с внешними ключами лучше не делать вообще.
Инцидент №2, или как о вашу схему спотыкается тот, о ком вы не знали

Пока мы тушили первый пожар, прилетел второй, на этот раз из дата-инженерии:
column siioi.seller_item_identifier_id does not existОказалось, что DWH-DAG ходит в нашу боевую базу напрямую и джойнит ровно по тем колонкам, которые мы только что удалили. В результате витрина в ClickHouse встала, а аналитика поехала на устаревших данных.
И вот это, честно говоря, бьёт больнее, чем deadlock. Со стороны сервиса у нас всё было чисто: код переведён, тесты зелёные, миграция применена. Но штука в том, что схема базы вообще-то не приватная. Её читали снаружи, в обход любого нашего API, и для DWH имена колонок были полноценным контрактом. А наш «ренейм на полчаса» оказался для них ломающим изменением, про которое мы даже не предупредили, потому что про этого потребителя попросту не знали.
Любой, кто читает вашу таблицу напрямую, по факту становится клиентом вашего API, подписывались вы на это или нет. И ренейм колонки для него такой же breaking change, как ломающее изменение в REST или Kafka, так что и анонсировать его надо так же.
А как тогда правильно
«Не давайте читать базу напрямую» звучит красиво, а на практике где-нибудь да читают. Поэтому самое важное это вообще знать, кто к тебе ходит. У нас посыпалось не из-за кривой миграции, а из-за того, что про потребителя мы не знали, а контракт, о котором ты не в курсе, защитить нельзя. Так что минимум, который окупается всегда, это видимость зависимостей: каталог данных, lineage или хоть строчка в вики «сюда напрямую ходит DWH».
А дальше прямой доступ стоит спрятать за стабильным слоем. Дёшево это view на реплике с зафиксированными именами полей: переименовывай физические колонки сколько хочешь, снаружи ничего не меняется, тот же expand/contract со стороны чтения. По-хорошему же такие данные лучше отдавать не из боевой базы, а потоком доменных событий через outbox, который у нас и так есть, тогда схема развязана с потребителем полностью. Ну а если прямой доступ остаётся, относимся к нему как к публичному API: data-контракт, период депрекейта, предупреждение. Тихий DROP COLUMN тут не вариант.
Что в сухом остатке

Самое любопытное, что оба инцидента породил не сам рефакторинг, а уборка после него, тот самый contract-шаг, на который обычно не закладывают время.
Добавлять почти всегда безопасно, а беда начинается там, где что-то перестаёт существовать. И ломается сразу с двух сторон: изнутри, если промежуточные состояния миграции невалидны или DDL дерётся с трафиком за блокировки, и снаружи, если у удаляемого нашёлся читатель, которого ты не посчитал. Зелёный CI не ловит ни то, ни другое, потому что для deadlock нужна нагрузка, а скрытый потребитель живёт в чужом репозитории.
Проблема стара как мир. Но мы всё равно на неё наступаем, потому что «удалить пару колонок» звучит безопасно ровно до того момента, пока ты их не удалил.

























