Переименование столбцов, разделение таблиц и изменение ограничений без простоев

У вас есть таблица users с колонкой full_name. Команда решает, что она должна называться display_name. Если переименовать её напрямую, все приложения, которые всё ещё читают full_name, сломаются в момент применения изменений в production. Колонка исчезнет, запросы упадут, а пользователи увидят ошибки.

Это не гипотетическая проблема. Команды переименовывают столбцы, разделяют таблицы на две и меняют ограничения каждый спринт. Наивный подход — изменить схему и исправить код позже — приводит к инцидентам в production, которых можно было избежать. Решение — паттерн expand-contract, который работает для всех трёх сценариев.

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

Паттерн expand-contract состоит из трёх фаз. Сначала вы расширяете схему, добавляя новые структуры рядом со старыми. Затем вы мигрируете приложения и данные на новые структуры. Наконец, вы сжимаете схему, удаляя старые структуры, когда от них больше ничего не зависит.

Диаграмма ниже иллюстрирует трёхфазный паттерн expand-contract и его применение к переименованию столбца, разделению таблицы и изменению ограничения.

flowchart TD A[Начало] --> B[Фаза 1: Расширение] B --> C[Добавить новый столбец/таблицу/ограничение] C --> D[Запись и в старую, и в новую структуру] D --> E[Фаза 2: Миграция] E --> F[Обратное заполнение существующих данных] F --> G[Обновить приложения для чтения из новой структуры] G --> H{Все потребители мигрированы?} H -- Нет --> G H -- Да --> I[Фаза 3: Сжатие] I --> J[Проверить отсутствие зависимостей от старой структуры] J --> K[Удалить старый столбец/таблицу/ограничение] K --> L[Готово]

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

Переименование столбца без поломок

Давайте разберём переименование full_name в display_name. На фазе расширения вы добавляете новый столбец display_name в таблицу users. Вы не удаляете full_name. Новая версия вашего приложения начинает записывать данные в оба столбца. Каждая вставка или обновление записывает одно и то же значение в full_name для старых потребителей и в display_name для новых.

Вот SQL-команды для каждой фазы переименования:

-- Фаза 1: Расширение - добавляем новый столбец
ALTER TABLE users ADD COLUMN display_name VARCHAR(255);

-- Пример двойной записи (логика приложения, не только SQL)
-- При вставке или обновлении пользователя записываем в оба столбца:
INSERT INTO users (full_name, display_name) VALUES ('Alice', 'Alice');
UPDATE users SET full_name = 'Bob', display_name = 'Bob' WHERE id = 42;

-- Фаза 2: Миграция - обратное заполнение существующих данных
UPDATE users SET display_name = full_name WHERE display_name IS NULL;

-- Фаза 3: Сжатие - удаляем старый столбец
ALTER TABLE users DROP COLUMN full_name;

Эта последовательность гарантирует, что ни в один момент база данных не отклонит запрос и не потеряет данные.

После того как столбец создан и приложение пишет в оба, вы запускаете обратное заполнение. Это пакетный процесс, который копирует все существующие значения из full_name в display_name для каждой строки. Вы проверяете совпадение количества записей и выборочно проверяете случайные записи, чтобы убедиться, что ничего не потеряно.

Теперь наступает переключение. Все приложения, которые читают имена пользователей, должны быть обновлены для чтения из display_name вместо full_name. Это может происходить постепенно. Некоторые сервисы переключаются первыми, другие — позже. В этот период оба столбца остаются заполненными, поэтому любой сервис, всё ещё читающий full_name, продолжает работать.

Как только каждое приложение и каждый запрос мигрированы, вы переходите к фазе сжатия. Вы удаляете столбец full_name. Весь процесс может занять дни или недели в зависимости от того, сколько сервисов нужно обновить, но никогда не наступает момент, когда пользователи не могут получить доступ к приложению.

Разделение одной таблицы на две

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

Фаза расширения создаёт таблицу payments. Новая версия приложения начинает записывать платёжные данные в оба места. Каждый раз при создании или обновлении заказа приложение записывает платёжные данные в таблицу orders для старых потребителей и в таблицу payments для новой структуры. Это называется двойной записью, и это самая сложная часть, которую нужно правильно реализовать. Обе записи должны либо успешно выполниться, либо обе откатиться. Частичные записи приведут к повреждению данных.

Обратное заполнение здесь критично. Вам нужно скопировать все существующие платёжные данные из orders в payments. Запускайте это пакетами, чтобы не блокировать таблицу слишком долго. После каждого пакета проверяйте, что количество записей и общие суммы платежей совпадают между двумя таблицами. Если они не совпадают, остановитесь и проведите расследование, прежде чем продолжать.

После того как обратное заполнение проверено и все приложения обновлены для чтения платёжных данных из payments вместо orders, вы переходите к фазе сжатия. Вы удаляете столбцы с платёжными данными из orders. Этот шаг требует уверенности, что ни один запрос, отчёт или устаревший сервис больше не обращается к этим столбцам. Проверьте логи базы данных, логи приложений и мониторинг запросов перед удалением чего-либо.

Изменение nullable-ограничения на NOT NULL

Этот сценарий выглядит просто, но часто застаёт команды врасплох. В вашей таблице users есть столбец email, который допускает NULL. Бизнес теперь требует, чтобы у каждого пользователя был email. Если вы измените столбец на NOT NULL напрямую, база данных отклонит изменение, потому что существующие строки с NULL-email нарушают ограничение.

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

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

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

Практический чек-лист для изменений схемы

Перед выполнением любой миграции схемы в production пройдитесь по этому чек-листу:

  • Может ли старая схема по-прежнему обслуживать запросы после изменения?
  • Существует ли путь двойной записи для всех новых данных?
  • Протестирован ли скрипт обратного заполнения на копии production-данных?
  • Убедились ли вы, что все потребители мигрировали перед удалением чего-либо?
  • Есть ли у вас план отката, если фаза сжатия выявит пропущенную зависимость?

Вывод

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