MAATRIX / Блог / Как удалить колонку так, чтобы старый код не упал

Как удалить колонку так, чтобы старый код не упал

MAATRIX

Удалить ненужную колонку кажется мелочью — одна строчка ALTER TABLE ... DROP COLUMN, секунда на выполнение. Проблема не в самой команде, а в том, что в момент её выполнения на проде почти всегда работает не одна версия кода приложения, а сразу две: старая ещё не успела выключиться, новая уже стартовала. Если старая версия хоть строчкой обращается к колонке, которую вы только что физически стёрли, — вы получите лавину ошибок посреди раскатки, и разбираться с этим придётся уже в бою. Разберём, как убрать колонку без риска, растянув один шаг на три отдельных релиза.

Почему одновременное удаление кода и колонки — это риск, а не экономия времени

Соблазн понятен: если код больше не использует поле, зачем тянуть с очисткой схемы — можно в том же PR убрать чтение/запись и сразу дать DROP COLUMN. Проблема в том, что деплой кода и изменение схемы базы — это не одна атомарная операция, а два независимых события, которые физически не могут произойти одновременно на всех узлах сразу.

Разберём типичный rolling-деплой на несколько подов или серверов. Оркестратор (Kubernetes, systemd на нескольких VPS за балансировщиком, что угодно) обновляет инстансы по одному или небольшими партиями, чтобы не ронять сервис целиком. Пока катится обновление, часть трафика продолжает обслуживать старые поды с прежним кодом, часть — уже новые. Это окно может длиться от нескольких секунд до нескольких минут в зависимости от числа реплик, readiness-проб и скорости старта приложения.

Если миграция базы, удаляющая колонку, выполняется в этом же деплое (а чаще всего именно так и настроены CI/CD-пайплайны — миграция как init-контейнер или pre-deploy хук), то в момент, когда новые поды уже переключили схему, старые поды всё ещё выполняют запросы вида SELECT id, email, legacy_status FROM users или INSERT INTO orders (..., legacy_flag) VALUES (...). Колонки legacy_status и legacy_flag уже нет — и вы получаете column "legacy_status" does not exist на каждом запросе от старых инстансов, пока они не будут добиты обновлением.

Отдельно стоит зависимость от порядка операций: если DROP COLUMN выполняется до раскатки нового кода, падают вообще все инстансы — и старые, и ещё не обновившиеся новые. Даже при «мгновенном» blue-green переключении остаются фоновые воркеры, крон-задачи, реплики для аналитики на устаревшей версии кода — все они могут держать ссылку на колонку дольше, чем кажется по дашборду деплоя.

Отдельная ловушка — откат. Если после деплоя нашли баг и откатываете код на предыдущую версию, а колонка уже физически удалена вместе с этим же релизом, откатиться становится некуда: старый код снова ждёт колонку, которой больше нет. Восстанавливать её — это не git revert, а ручное ALTER TABLE ADD COLUMN и по возможности восстановление данных из бэкапа — уже не откат, а отдельный маленький инцидент.

Шаг 1: подтвердить, что код действительно не читает и не пишет колонку

Прежде чем убирать что-либо физически, нужно на сто процентов убедиться, что колонка не нужна ни одному живому клиенту базы — не «по памяти», а по факту. Опыт показывает, что «мы точно её не используем» и реальность расходятся чаще, чем хочется: колонка всплывает в отчётах BI-инструмента, в ORM-модели, которую забыли почистить, в старом cron-скрипте на соседнем сервере, о котором никто не вспомнил на ревью.

Анализ кода. Первый проход — полнотекстовый поиск по всем репозиториям, которые обращаются к этой базе, включая скрипты, ETL, аналитику, админки:

grep -rn "legacy_status" --include="*.py" --include="*.js" --include="*.go" --include="*.sql" .

Отдельно проверьте ORM-слой: в Django/SQLAlchemy/ActiveRecord/Prisma колонка может быть объявлена в модели, но нигде явно не упоминаться по имени в бизнес-коде — модель сама подставит её в SELECT * или в сериализацию объекта. Ищите объявление поля в схемах моделей отдельно от текстового поиска по имени колонки в запросах:

grep -rn "legacy_status" --include="models.py" --include="*.prisma" --include="schema.rb" .

Проверьте также миграции самой ORM — Django migrations/, Prisma schema.prisma, Rails schema.rb — и убедитесь, что колонка убрана из декларативной схемы, а не только из явных запросов. Если к базе ходит несколько независимых приложений (монолит плюс пара микросервисов, плюс скрипт для выгрузки в аналитику), grep нужно гонять по каждому репозиторию отдельно, а не только по тому, где вы работаете.

Мониторинг реальных запросов. Анализ кода даёт хорошую гипотезу, но не доказательство — код мог остаться в ветке, которая давно не деплоится, или обращение может идти из места, до которого grep не дотянулся (динамически собранный SQL, ORM с ленивой загрузкой полей, внешний BI-инструмент с прямым доступом к базе). Нужно подтвердить со стороны самой СУБД.

В PostgreSQL полезно включить логирование конкретных запросов через log_min_duration_statement для аудита, но для точечной проверки использования колонки удобнее pg_stat_statements — он покажет тексты выполняемых запросов без включения полного логирования:

SELECT query, calls, last_exec_time
FROM pg_stat_statements
WHERE query ILIKE '%legacy_status%'
ORDER BY last_exec_time DESC
LIMIT 20;

Если pg_stat_statements не настроен заранее, включите его в postgresql.conf (shared_preload_libraries = 'pg_stat_statements', перезапуск сервера) и наблюдайте с этого момента — ретроспективно он ничего не покажет.

Для MySQL аналогичную роль играет performance_schema со статистикой по подготовленным запросам, либо временное включение general query log на непиковое время:

SET GLOBAL general_log = 'ON';
SET GLOBAL log_output = 'TABLE';
-- через несколько часов
SELECT argument FROM mysql.general_log WHERE argument LIKE '%legacy_status%';
SET GLOBAL general_log = 'OFF';

Общий log в проде — вещь тяжёлая по дисковому вводу-выводу, включайте на ограниченное время (часы, не дни) и на низкий трафик, либо используйте выборочный аудит через прокси (например, лог на уровне PgBouncer/ProxySQL) вместо лога всей СУБД.

Отдельно проверьте read-реплики и аналитические витрины — колонка может быть не нужна в основном приложении, но использоваться в BI-дашборде или экспортном скрипте, который читает прямо с реплики в обход основного кода.

Только когда и статический анализ, и данные реального трафика за разумный период (минимум одна полная неделя — с учётом недельных и ежемесячных задач вроде биллинга или отчётов, которые бегают не каждый день) не показывают обращений к колонке, переходите к следующему шагу.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Шаг 2: перестать использовать колонку в коде, оставив её в базе

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

Конкретно это значит:

  • Убрать поле из SELECT-списков (не полагаться на SELECT *, если ещё не убрали — самое время).
  • Убрать поле из INSERT/UPDATE в коде записи.
  • Убрать поле из ORM-модели (или явно пометить как deprecated/ignored, если ORM поддерживает мягкое исключение поля без немедленного удаления из схемы миграций).
  • Проверить сериализаторы API — поле могло уходить наружу в JSON-ответах, даже если бизнес-логика его не читает.

Если раскатка идёт rolling-обновлением на несколько подов/серверов, этот деплой безопасен в обе стороны: старая версия (ещё читающая колонку) и новая версия (уже её не трогающая) могут одновременно работать против одной и той же схемы без единой ошибки, потому что колонка физически существует всё это время. Ровно ради этого и разносится изменение кода и изменение схемы на разные релизы.

Шаг 3: период наблюдения — сколько ждать и что проверять

После того как код перестал использовать колонку, не спешите её удалять — держите паузу и наблюдайте. Практический ориентир — от одной до двух недель в проде, но конкретная цифра у вас может быть другой: ориентируйтесь на полный цикл ваших релизов (если откатываетесь редко и релизы копятся — период стоит увеличить) и на то, есть ли отложенные консьюмеры данных, которые обращаются к базе не в реальном времени, а пакетно раз в месяц.

Что проверять в период наблюдения:

  • Повторный прогон анализа реальных запросов тем же способом, что и на шаге 1 — теперь уже подтверждая, что новых обращений к колонке не появилось после деплоя.
  • Логи ошибок приложения на предмет column does not exist — если такие ошибки вдруг появились, значит где-то остался код, который вы пропустили при анализе, и его нужно найти и убрать до перехода к следующему шагу.
  • Возможность отката. Пока колонка физически на месте, откат кода на предыдущую версию безопасен в любой момент — это и есть главная страховка всей схемы. Если за время наблюдения понадобилось откатить релиз (по причинам, не связанным с колонкой), убедитесь, что откат не восстановил обращения к полю где-то ещё.
  • Свежие бэкапы. Ничего специально делать не нужно — обычный график бэкапов покрывает и эту колонку, пока она существует. Но стоит зафиксировать, с какого именно бэкапа колонка гарантированно ещё содержит валидные данные, на случай если понадобится восстановить её позже не из-за отката кода, а из-за внезапно всплывшей потребности в исторических данных.

Staging, близкий к продовому трафику, полезен для ускоренной проверки — прогнать полный цикл релизов/кронов. Но он не заменяет наблюдение на реальном трафике: синтетические сценарии почти никогда не покрывают длинный хвост редких запросов боевой системы.

Шаг 4: физическое удаление отдельным релизом

Когда наблюдение закончилось и обращений к колонке нет, удаляйте её отдельным релизом, не смешанным с другими изменениями кода — так проще откатить именно эту операцию, если что-то пойдёт не так, и проще понять из истории деплоев, что именно вызвало проблему, если она всё же возникнет.

Для PostgreSQL:

ALTER TABLE users DROP COLUMN legacy_status;

DROP COLUMN в PostgreSQL физически не переписывает таблицу сразу: колонка помечается удалённой на уровне метаданных, а место освобождается постепенно при VACUUM и обновлении строк. Тем не менее операция требует ACCESS EXCLUSIVE блокировку, пусть и на короткое время, — на горячей таблице с длинными транзакциями это может создать очередь блокировок. Проверьте, что нет зависших долгих транзакций (SELECT pid, now() - xact_start AS duration, query FROM pg_stat_activity WHERE state != 'idle' ORDER BY duration DESC;) и выполняйте операцию с коротким lock_timeout:

SET lock_timeout = '3s';
ALTER TABLE users DROP COLUMN legacy_status;

Если lock_timeout не даёт захватить блокировку — значит на таблице идёт активная долгая транзакция, и лучше повторить попытку позже, а не наращивать таймаут в надежде, что «в этот раз получится».

Для MySQL (InnoDB) DROP COLUMN в современных версиях тоже поддерживает INSTANT-алгоритм для части случаев, но не для всех — проверяйте план через ALGORITHM=INSTANT:

ALTER TABLE users DROP COLUMN legacy_status, ALGORITHM=INSTANT;

Если INSTANT недоступен (например, колонка участвует в индексе), СУБД предложит другой алгоритм или откажет — тогда операция потребует полного копирования таблицы, и для больших таблиц имеет смысл делать это через gh-ost или pt-online-schema-change, чтобы не держать блокировку на всё время перестройки.

После удаления колонки почистите сопутствующие артефакты: индексы, которые её использовали, права/гранты на уровне колонки, если такие были заданы явно, упоминания в документации по схеме и в дата-каталоге, если он у вас ведётся отдельно.

Как оформить это в CI/CD, чтобы схема и код не разъезжались случайно

Практическая проблема трёхшаговой схемы в том, что она требует дисциплины между релизами — легко забыть про третий шаг и оставить мёртвую колонку в базе навсегда, или, наоборот, поторопиться с DROP раньше срока.

Практические меры:

  • Заводите миграцию удаления колонки как отдельный файл/PR сразу, но с комментарием "не мержить раньше <дата>" — так она не потеряется, но и не проскочит раньше времени.
  • Ведите трекер технического долга (тикет, вики, файл DEPRECATED_COLUMNS.md в репозитории миграций) со списком колонок в режиме наблюдения и датой, когда оно завершится.
  • В код-ревью проверяйте: если PR одновременно убирает использование поля в коде и удаляет колонку в той же миграции — повод остановить мерж и разнести на два релиза.
  • Линтер миграций (кастомный CI-чек для Flyway/Liquibase) может автоматически ловить DROP COLUMN в одном PR с изменениями кода того же сервиса и требовать подтверждения, что это осознанное решение.

Тот же принцип разноса на несколько релизов работает и в обратную сторону — при добавлении обязательной колонки: сначала добавить колонку с дефолтом или nullable, выкатить код, который её начинает писать, и только потом переключать на NOT NULL. О похожем сценарии — когда безобидный на вид ADD COLUMN неожиданно заблокировал таблицу — разбор конкретного инцидента: миграция добавила колонку и заблокировала таблицу.

Если несколько версий кода одновременно работают против одной схемы не только во время деплоя, а по архитектуре (canary-раскатка с длительным сосуществованием версий), период наблюдения стоит увеличивать пропорционально — см. основы canary deploy.

Полезно держать под рукой и общий регламент работы с изменениями схемы на проде — кто согласовывает DROP-операции, кто подтверждает завершение наблюдения: см. регламент работы с боевой базой данных.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Можно ли просто переименовать колонку вместо трёхшаговой схемы?

Нет, для старого кода это то же самое изменение схемы: он обращается к прежнему имени и получит ошибку. Переименование безопасно проводить по той же логике (добавить новую колонку, синхронизировать данные, переключить код, удалить старую), а не одной операцией RENAME COLUMN.

Что если старый код не читает колонку, а только пишет в неё (поле аудита)?

Риск тот же — INSERT/UPDATE со ссылкой на несуществующую колонку упадёт так же, как и SELECT. Проверять нужно оба направления по логам и по коду.

Обязательно ли ждать 1–2 недели, если приложение маленькое и деплоится редко?

Срок — ориентир, а не жёсткое правило. Период наблюдения должен перекрывать регулярные фоновые процессы (недельные и месячные джобы) и типичное время между релизами, чтобы успеть заметить проблему и откатиться до физического удаления.

Что делать, если колонка используется во внешнем сервисе, который вы не контролируете?

Это отдельный случай — прямой доступ третьей стороны к вашей схеме сам по себе рискованная практика. Согласуйте с владельцем внешнего сервиса миграцию на API или на реплику с контролируемой схемой — трёхшаговая схема тут не спасает, потому что вы не контролируете момент, когда внешний код обновится.

Нужно ли удалять колонку вообще, если место на диске не критично?

Формально можно оставить её висеть неопределённо долго. Но мёртвые колонки усложняют чтение схемы новым разработчикам и повышают шанс, что кто-то снова начнёт ей пользоваться. Разумно держать удаление в бэклоге с конкретной датой, а не откладывать бессрочно.

Как быть с колонками, у которых NOT NULL и они участвуют в индексах или внешних ключах?

Сначала уберите зависимые объекты (внешние ключи, составные индексы, constraint-ы) отдельными миграциями, и только потом саму колонку — иначе DROP COLUMN либо откажет, либо каскадно снесёт индекс, о котором вы могли забыть.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →