Почему даже крошечное изменение схемы может разрушить вашу production-базу данных
У вас есть приложение, работающее в production. Оно обслуживает тысячи пользователей каждую минуту. Однажды утром вы решаете добавить один столбец в таблицу базы данных. Всего один столбец. Изменение выглядит безобидным на бумаге. Но через несколько мгновений после запуска миграции пользователи начинают видеть ошибки. Запросы уходят в тайм-аут. Регистрация новых пользователей перестаёт работать. Ваша команда в спешке откатывает изменения.
Этот сценарий разыгрывается гораздо чаще, чем ожидает большинство инженеров. Изменение схемы, которое кажется тривиальным на ноутбуке разработчика, может поставить production-систему на колени. Понимание причин этого необходимо каждому, кто разворачивает изменения базы данных вместе с кодом приложения.
Фундаментальная разница между кодом и схемой
Когда вы меняете код приложения, эффект относительно локализован. Новая версия заменяет старую. Если что-то пошло не так, вы можете развернуть предыдущую версию и восстановить нормальную работу. Риск реален, но путь восстановления прямолинеен.
Изменения схемы базы данных работают иначе. Когда вы изменяете структуру таблицы, вы модифицируете фундамент, от которого зависит каждый работающий экземпляр приложения. Не существует чистого «переключения» между старой и новой схемой. Старая схема исчезает в тот момент, когда миграция завершается. Если что-то ломается, откат изменения схемы может быть сложнее и рискованнее, чем само исходное изменение.
Эта асимметрия является коренной причиной многих production-инцидентов, которые восходят к, казалось бы, незначительным модификациям базы данных.
Небольшое добавление столбца, вызывающее большие проблемы
Рассмотрим конкретный пример. У вас есть таблица users со столбцом email, определённым как varchar(255). Вы решаете увеличить лимит до varchar(500). Это изменение типа одного столбца. Насколько плохо это может быть?
Во время миграции базе данных может потребоваться заблокировать таблицу для реструктуризации столбца. Пока удерживается блокировка, ни одно приложение не может читать или писать в таблицу users. Если ваше приложение обрабатывает сотни запросов в секунду, даже несколько секунд блокировки таблицы могут вызвать каскад тайм-аутов и неудачных запросов. Пользователи видят ошибки. Срабатывают оповещения мониторинга. Команда паникует.
Теперь рассмотрим добавление нового столбца phone_number в ту же таблицу. Миграция добавляет столбец с ограничением NOT NULL и без значения по умолчанию. Экземпляры приложения, работающие со старым кодом, не знают о существовании этого столбца. Когда они выполняют оператор INSERT, который опускает новый столбец, база данных отклоняет запрос. Внезапно регистрация новых пользователей перестаёт работать на всех экземплярах, всё ещё выполняющих старый код. Изменение заключалось в добавлении одного столбца. Последствием стал полный отказ регистрации.
Вот SQL, который вызвал бы описанный выше сбой:
-- Опасно: блокирует всю таблицу users, блокируя все чтения и записи
ALTER TABLE users ADD COLUMN phone_number VARCHAR(20) NOT NULL;
-- Более безопасная альтернатива: сначала добавить столбец без NOT NULL,
-- затем выполнить обратное заполнение, затем добавить ограничение с тайм-аутом блокировки
ALTER TABLE users ADD COLUMN phone_number VARCHAR(20);
-- Обратное заполнение пакетами (код приложения обрабатывает отсутствующие значения)
UPDATE users SET phone_number = 'unknown' WHERE phone_number IS NULL;
-- Добавить NOT NULL с тайм-аутом блокировки, чтобы избежать бесконечной блокировки
SET lock_timeout = '5s';
ALTER TABLE users ALTER COLUMN phone_number SET NOT NULL;
Первый оператор блокирует таблицу на всё время выполнения операции. На большой таблице это может занять минуты, вызывая каскадные тайм-ауты во всех экземплярах приложения.
Изменения типов, которые незаметно ломают запросы
Некоторые изменения схемы выглядят безопасными, но незаметно меняют поведение запросов. Изменение столбца первичного ключа с INT на BIGINT — распространённый пример. Приложение приближается к лимиту целых чисел, поэтому изменение необходимо. Но в процессе конвертации запросы, полагающиеся на индекс для этого столбца, могут замедлиться или вовсе перестать использовать индекс. База данных может быть вынуждена переписать всю таблицу и все её индексы. Для большой таблицы это может занять минуты или часы.
Даже после завершения конвертации код приложения может содержать предположения о типе данных. Код, который форматирует ID для отображения, передаёт его во внешний API или использует в арифметических операциях, может незаметно сломаться. Изменение схемы было корректным, но предположения, заложенные в коде приложения, — нет.
Удаление неиспользуемых столбцов тоже рискованно
Удаление столбца, который кажется неиспользуемым в основном приложении, выглядит как безопасная чистка. Но у баз данных редко бывает только один потребитель. Пакетное задание, выполняющееся каждую ночь, может читать этот столбец для отчётов. Унаследованный сервис, о котором никто не помнит, может выполнять к нему запросы. У команды data science может быть скрипт, который вытягивает его для анализа.
В тот момент, когда вы удаляете столбец, все эти потребители ломаются. Ночной отчёт не выполняется. Унаследованный сервис начинает выдавать ошибки. Конвейер data science перестаёт выдавать результаты. То, что выглядело как операция по очистке, превратилось в инцидент с участием нескольких команд.
Почему изменения схемы являются ломающими изменениями
В коде приложения ломающее изменение обычно очевидно: вы удаляете функцию, меняете сигнатуру метода или изменяете формат ответа API. В базах данных ломающие изменения обнаружить сложнее, потому что база данных — это общий ресурс со множеством невидимых потребителей.
Одна таблица базы данных может использоваться:
- Основным приложением
- Фоновыми обработчиками задач
- Инструментами для отчётности
- Конвейерами аналитики данных
- Унаследованными сервисами
- Ad-hoc запросами от эксплуатационных команд
- Сторонними интеграциями
У каждого потребителя есть свои предположения о схеме. Изменение, безопасное для основного приложения, может сломать скрипт для отчётности, который запускается раз в месяц. Поскольку этот скрипт выполняется редко, поломка может оставаться незамеченной в течение недель.
Основной принцип
Не существует по-настоящему маленьких изменений схемы. Каждая модификация структуры базы данных — это координированная операция, требующая планирования, тестирования и тщательного выполнения. Размер изменения в строках кода миграции не коррелирует с размером потенциального воздействия.
Практический чек-лист перед любым изменением схемы
Прежде чем запускать миграцию в production, проверьте следующие пункты:
- Знаете ли вы каждое приложение, сервис и скрипт, которые обращаются к этой таблице?
- Можете ли вы выполнить миграцию без блокировки таблицы для записи?
- Ломает ли это изменение существующие запросы или предположения приложения?
- Могут ли старый и новый код приложения сосуществовать с новой схемой?
- Есть ли у вас протестированный план отката, который не требует потери данных?
- Проверили ли вы наличие долго выполняющихся транзакций, которые могут конфликтовать с миграцией?
- Есть ли панель мониторинга, которая покажет ошибки запросов сразу после миграции?
Что это означает для вашего процесса развёртывания
Изменения схемы базы данных требуют иной стратегии развёртывания, чем изменения кода приложения. Они должны быть обратимыми, обратно совместимыми, где это возможно, и протестированными на реалистичных объёмах данных. Также они должны быть скоординированы со всеми командами, которые зависят от базы данных.
Относитесь к каждому изменению схемы как к высокорискованной операции, независимо от того, насколько маленьким оно выглядит. Столбец, который вы добавляете сегодня, может вызвать сбой завтра. Тип, который вы меняете, может сломать отчёт на следующей неделе. Таблица, которую вы удаляете, может оказаться той, от которой зависит скрипт коллеги.
Планируйте развёртывание базы данных с той же тщательностью, с какой вы бы отнеслись к критическому изменению инфраструктуры. Потому что именно это они и есть.