Переименовать поле без остановки: приём с двойной записью
Однажды кто-то предлагает переименовать email в email_address, потому что так понятнее, или qty в quantity, потому что старое имя — наследие первого прототипа. Звучит как пятиминутная задача: ALTER TABLE ... RENAME COLUMN, и дело сделано. На проде с несколькими инстансами приложения и постепенной раскаткой эта команда способна за секунды устроить каскад пятисоток. Ниже — рабочая схема, которая разводит переименование поля во времени на безопасные шаги, вместо одной мгновенной и необратимой операции.
Содержание
Почему RENAME COLUMN — ловушка на живом проде
Технически RENAME COLUMN в PostgreSQL и MySQL — операция быстрая, она меняет только метаданные каталога и не трогает данные на диске. Проблема не в цене операции, а в том, что она меняет контракт таблицы мгновенно и для всех разом.
В реальности код, который обращается к базе, не обновляется атомарно вместе со схемой. Даже при аккуратном деплое есть окно, когда одновременно работают:
- старые поды/процессы приложения, которые ещё не перезапустились после раскатки и продолжают писать
SELECT email FROM users; - новые поды, которые уже ждут
email_address; - фоновые джобы, крон-скрипты, ETL-пайплайны, админка с ручными SQL-запросами — всё, что обновляется не одновременно с основным сервисом;
- реплики для чтения, если у вас есть отдельные читающие сервисы или BI-инструменты, смотрящие в ту же схему.
В момент коммита RENAME COLUMN весь код, ожидающий старое имя, начинает падать с ошибкой вида column "email" does not exist (PostgreSQL) или Unknown column 'email' in 'field list' (MySQL). Это не гипотетический риск — это гарантированное поведение при любой раскатке, которая не является одним синхронным рестартом всех потребителей базы разом. А если у вас несколько сервисов на разных языках и в разных репозиториях, синхронный рестарт всего сразу — сам по себе источник простоя, которого вы пытались избежать.
Отдельно стоит миграционный инструмент: Flyway, Liquibase, Django/Rails-миграции обычно запускаются один раз перед раскаткой новых подов, но между применением миграции и полным обновлением всех инстансов проходит время — от секунд до минут при постепенном rollout. Этого времени достаточно, чтобы получить всплеск ошибок в логах и алертах.
Схема двойной записи: план из шести шагов
Смысл приёма в том, чтобы разбить одну рискованную операцию на последовательность маленьких, каждая из которых обратима и не требует синхронного обновления всего парка серверов. Кратко порядок действий:
| Шаг | Что делаем | Откатываемость |
|---|---|---|
| 1. Добавить колонку | ADD COLUMN с новым именем, без NOT NULL | Полностью безопасно, можно удалить в любой момент |
| 2. Включить двойную запись | Приложение пишет и в старую, и в новую колонку | Легко выключить фичефлагом |
| 3. Бэкофил | Разово копируем старые строки из старой колонки в новую | Идемпотентно, можно перезапускать |
| 4. Переключить чтение | Код начинает читать новую колонку | Откат — вернуть чтение на старую |
| 5. Наблюдение | Смотрим на ошибки, сверяем данные, ждём период стабильности | — |
| 6. Убрать старое | Выключить двойную запись, удалить старую колонку | Необратимо — делаем последним |
Ключевая идея: на каждом шаге и старый, и новый код одновременно работают корректно. Пока не наступил шаг 6, откатить деплой приложения к предыдущей версии — не авария, а рутинная операция.
Если вы уже писали статьи о переезде между базами данных, здесь работает похожий принцип — данные какое-то время параллельно живут в двух местах, — но применительно не к переезду между инстансами, а к смене имени поля внутри одной и той же базы.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверШаги 1–2: новая колонка и двойная запись
Добавляем колонку без ограничений — это должно быть быстрой операцией метаданных, а не блокирующей перезаписью таблицы:
-- PostgreSQL 11+ и MySQL 8+: ADD COLUMN с NULL по умолчанию — операция метаданных
ALTER TABLE users ADD COLUMN email_address text;
Важно: не добавляйте NOT NULL и DEFAULT со сложным вычислением на этом шаге — в старых версиях СУБД (PostgreSQL до 11, MySQL с ALGORITHM=COPY) это может вызвать полную перезапись таблицы с блокировкой. На современных версиях DEFAULT с константой — тоже операция метаданных, но лучше держать шаг максимально простым и добавлять ограничения позже, когда данные уже согласованы.
Дальше — двойная запись на уровне приложения. Логика простая: любой INSERT и UPDATE, трогающий это поле, пишет значение в обе колонки.
# псевдокод, ORM-агностично
def save_user(user, email: str):
user.email = email
user.email_address = email # временно, пока идёт миграция
db.session.commit()
Если у вас несколько мест записи (основной сервис, воркер очереди, скрипт импорта, админка) — двойную запись нужно добавить во все, иначе часть строк получит обновление только в старой колонке. Это самая частая причина, по которой приём не срабатывает с первого раза: не забытая база, а забытый код-путь.
Альтернатива на уровне БД — триггер, который сам зеркалит запись между колонками:
CREATE OR REPLACE FUNCTION sync_email_columns() RETURNS trigger AS $$
BEGIN
IF NEW.email IS DISTINCT FROM OLD.email THEN
NEW.email_address := NEW.email;
END IF;
IF NEW.email_address IS DISTINCT FROM OLD.email_address THEN
NEW.email := NEW.email_address;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_email_columns
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_email_columns();
Плюс триггера — не нужно искать все места в коде, которые пишут в таблицу. Минус — ещё один невидимый механизм на проде, о котором легко забыть и который добавляет накладные расходы на каждую запись. Для таблицы с редкими записями и понятным числом код-путей чаще проще и прозрачнее держать двойную запись в приложении; для таблицы, в которую пишут из десятка разных мест, включая внешние интеграции, — триггер надёжнее.
Шаг 3: бэкофил существующих строк
Двойная запись покрывает новые и изменяемые строки, но старые строки, которые никто не трогает, так и останутся с NULL в новой колонке. Нужен разовый проход по таблице, который скопирует значения.
Не делайте это одним UPDATE без условий на большой таблице — это одна долгая транзакция, которая держит блокировки и создаёт всплеск нагрузки на диск и репликацию:
-- Плохо на таблице с миллионами строк: одна огромная транзакция
UPDATE users SET email_address = email WHERE email_address IS NULL;
Правильнее — батчами, с паузами между итерациями:
-- Батч по 5000 строк за раз, повторять пока не останется NULL
UPDATE users
SET email_address = email
WHERE id IN (
SELECT id FROM users
WHERE email_address IS NULL
ORDER BY id
LIMIT 5000
);
Оборачиваем это в скрипт (bash, python — не принципиально), который крутит запрос в цикле, проверяет ROW_COUNT/rowcount, останавливается при нуле обновлённых строк и делает паузу между итерациями, чтобы не забивать диск и не раздувать WAL/binlog:
while :; do
affected=$(psql -tAc "
WITH batch AS (
SELECT id FROM users WHERE email_address IS NULL ORDER BY id LIMIT 5000
)
UPDATE users SET email_address = email
WHERE id IN (SELECT id FROM batch)
RETURNING id
" | wc -l)
echo "updated: $affected"
[ "$affected" -eq 0 ] && break
sleep 0.5
done
Во время бэкофила стоит смотреть на репликацию (лаг реплик), на pg_stat_activity / SHOW PROCESSLIST — не выстроилась ли очередь блокировок, и на нагрузку на диск. Если таблица действительно огромная (сотни миллионов строк), проход может занять часы — это нормально, батч-копирование специально спроектировано так, чтобы растянуться во времени и не создавать пиковую нагрузку, в отличие от инструментов вроде pt-online-schema-change или gh-ost, которые решают похожую задачу через теневую таблицу и годятся скорее для смены типа колонки, чем для этого сценария.
Шаги 4–5: переключаем чтение и проверяем
Когда бэкофил завершён и двойная запись покрывает все новые строки, можно переключать чтение с email на email_address. Это тоже стоит делать управляемо — через конфиг-флаг или canary-раскатку на часть трафика, а не одним общим релизом на всех сразу:
if feature_flags.get("read_new_email_column"):
value = user.email_address
else:
value = user.email
Перед полным переключением полезно свериться, что данные в обеих колонках совпадают — расхождение обычно значит, что где-то остался код-путь без двойной записи:
SELECT count(*) FROM users
WHERE email IS DISTINCT FROM email_address;
Ненулевой результат — сигнал остановиться и найти забытое место записи, прежде чем переключать чтение дальше. После переключения держите период наблюдения — от нескольких дней до недели в зависимости от того, насколько активно используется поле и как быстро у вас проявляются баги в проде. Смотрите на логи ошибок, метрики 5xx, отчёты пользователей. Если что-то пошло не так, откат — это просто вернуть флаг чтения в исходное положение, база при этом не трогается.
Отдельно стоит проверить: если на колонку есть уникальный индекс или внешний ключ, их тоже нужно завести на новой колонке — обычно через CREATE UNIQUE INDEX CONCURRENTLY в PostgreSQL, чтобы не держать таблицу заблокированной на время построения индекса.
CREATE UNIQUE INDEX CONCURRENTLY idx_users_email_address
ON users (email_address);
Шаг 6: убираем двойную запись и старую колонку
Только когда чтение полностью переключено, период наблюдения прошёл без сюрпризов и вы уверены, что все код-пути обновлены, — убираете двойную запись из кода и деплоите. После этого деплоя старая колонка уже никем не пишется и не читается, она просто занимает место.
Прежде чем удалять — сделайте свежий бэкап (или убедитесь, что регулярный бэкап покрывает текущее состояние), потому что DROP COLUMN на уровне данных обратим только восстановлением из копии:
ALTER TABLE users DROP COLUMN email;
В PostgreSQL это тоже операция метаданных — данные физически не удаляются сразу, место освобождается постепенно через VACUUM. В MySQL с InnoDB и ALGORITHM=INSTANT (доступно с 8.0.29 для простого DROP COLUMN в большинстве случаев) операция тоже быстрая; на более старых версиях или при недоступности INSTANT может потребоваться копирование таблицы — стоит проверить план выполнения на копии прод-базы перед тем, как запускать на боевой.
Не спешите с этим шагом. Разница между «подождать лишнюю неделю» и «удалить слишком рано» в том, что первое стоит немного терпения, а второе может стоить данных и внепланового восстановления из бэкапа.
Частые грабли и как их избежать
Самая частая проблема — не техническая, а организационная: забытый код-путь. Двойную запись легко добавить в основной сервис и забыть про воркер, ночной импорт из CSV, скрипт для тех. поддержки, который пишет в базу напрямую. Перед переключением чтения стоит явно выписать все места, где происходит запись в таблицу, а не полагаться на память.
Вторая — ORM и приложения, которые кешируют схему таблицы при старте. После ADD COLUMN или DROP COLUMN некоторым фреймворкам требуется рестарт, чтобы подхватить новую структуру, иначе получите либо игнорирование новой колонки, либо ошибки о несуществующей.
Третья — долгие транзакции, держащие блокировку в момент ALTER TABLE ADD COLUMN. Сама операция быстрая, но ей всё равно нужна кратковременная эксклюзивная блокировка, и если в этот момент есть долгая открытая транзакция на той же таблице, запрос на ALTER встаёт в очередь — а следом в очередь встают и все более новые запросы к таблице, даже обычные SELECT. На проде разумно ставить lock_timeout перед ALTER и быть готовым повторить попытку:
SET lock_timeout = '3s';
ALTER TABLE users ADD COLUMN email_address text;
Четвёртая — логическая репликация. Если она настроена (например, для доставки изменений в аналитическое хранилище), триггер на основной таблице не реплицируется автоматически как отдельная сущность — учитывайте это при выборе между триггером и записью на уровне приложения. Подробнее о том, как безобидная на вид миграция с добавлением колонки может неожиданно заблокировать таблицу, разобрано в статье про блокировку при добавлении колонки.
Пятая — соблазн сократить путь и просто выполнить RENAME COLUMN ночью, когда трафика меньше. Риск это снижает, но не убирает: и ночью есть фоновые задачи, реплики и мониторинг, а окно рассинхронизации между применением миграции и обновлением всех потребителей остаётся тем же самым — просто с меньшей аудиторией пострадавших. Общие принципы работы с боевой базой стоит держать под рукой как регламент — например, в статье про работу с боевой базой данных, а план на случай отката продумать заранее — по аналогии с общим планом отката миграции.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Сколько времени держать двойную запись перед удалением старой колонки?
Единого числа нет — ориентируйтесь на активность использования поля и скорость обнаружения багов в команде. Для активной таблицы разумный минимум — несколько дней после полного переключения чтения, для редко меняющихся данных лучше выдержать неделю-две, особенно при наличии отложенных пакетных джобов.
Можно ли пропустить бэкофил и просто подождать, пока строки обновятся сами?
Технически можно, если таблица активно перезаписывается целиком. Но обычно часть строк не меняется месяцами (архивные заказы, неактивные пользователи), и без явного бэкофила данные в новой колонке для них не появятся никогда.
Что делать, если на колонку есть NOT NULL ограничение?
Добавляйте новую колонку без NOT NULL, проводите бэкофил, и только после того как убедитесь, что новых NULL не появляется (двойная запись работает везде), накладывайте ограничение — в PostgreSQL через ADD CONSTRAINT ... CHECK (...) NOT VALID, затем VALIDATE CONSTRAINT отдельным шагом, чтобы не держать блокировку на всё время проверки.
Работает ли эта схема одинаково в PostgreSQL и MySQL?
Общая логика шагов одна и та же, детали блокировок и стоимость операций отличаются: в PostgreSQL 11+ ADD COLUMN с простым DEFAULT — метаданные, в MySQL 8 многое зависит от ALGORITHM (INSTANT/INPLACE/COPY) и версии. Перед миграцией стоит проверить план на копии прод-базы того же объёма.
А что если таблица настолько большая, что даже батч-бэкофил создаёт заметную нагрузку?
Увеличивайте паузы между батчами, уменьшайте размер батча и запускайте в окно минимальной нагрузки. Если это регулярная проблема на очень больших таблицах, стоит заранее проверить, что миграция вообще прошла корректно — методику сверки разбирали в статье как проверить, что миграция прошла успешно.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →