Во время любого деплоя есть окно, когда старый и новый код работают с одной и той же базой данных. При rolling deploy это окно измеряется минутами. На одном процессе - это те несколько секунд между тем, как старый процесс закончил последний запрос, и тем, как новый принял первый. В любом случае окно существует, и из этого факта следует каждая безопасная миграция, а каждая небезопасная его игнорирует.
Отсюда короткое правило: миграция должна оставлять базу данных доступной для чтения и записи той версии кода, которая уже работает. Добавление этому условию удовлетворяет. Удаление удовлетворяет, когда на удаляемое больше ничего не ссылается. Переименование и смена типа не удовлетворяют никогда, поэтому их выполняют как последовательность добавлений и удалений. Остаток статьи - эта последовательность подробно, плюс поведение блокировок, которое превращает «мгновенный» ALTER TABLE в тридцатисекундный простой, и то, как заполнить миллионы строк, не держа транзакцию открытой час.
Окно перекрытия и что оно запрещает#
Представьте две версии, работающие бок о бок пять минут. Старый код выбирает email; новый выбирает email_address. В период перекрытия любая отсутствующая колонка кладёт половину ваших запросов. Это и есть весь механизм отказа, и им объясняется каждый из этих запретов:
- Никаких переименований. Старый код не знает нового имени.
- Никаких смен типа, меняющих смысл. Старый код пишет старый тип.
- Ничего не удалять из того, что старый код всё ещё выбирает, включая колонки, которыми он не пользуется, если он делает
SELECT *в строгий маппер строк. - Никаких колонок `NOT NULL` без значения по умолчанию, потому что старый код вставляет строки без неё.
- Никаких ограничений, которые нарушили бы существующие строки или старые записи.
И два запрета, которые касаются механики самой миграции, а не кода:
- Никаких операций, долго удерживающих сильную блокировку, потому что всё остальное встанет в очередь за ней.
- Никаких одиночных транзакций, затрагивающих каждую строку, из-за того, что это делает с репликацией, блокировками и bloat.
Expand, migrate, contract#
У паттерна три фазы и обычно четыре-пять деплоев. Для переименования колонки это выглядит как много церемоний, и так оно и есть - вплоть до первого раза, когда вы сделаете это одним шагом в шесть вечера.
- Expand. Добавьте новую колонку, таблицу или индекс. Допускающую NULL, со значением по умолчанию, если оно нужно. Старый код её игнорирует, потому что не знает о ней; новый код может использовать.
- Dual write. Выкатите код, который пишет и в старую, и в новую форму, но всё ещё читает старую. Пока ничего не зависит от того, что новые данные полны.
- Backfill. Копируйте существующие строки пакетами, пока всё продолжает работать. Новые строки уже верны благодаря шагу 2, так что backfill нужен только для истории.
- Переключение чтения. Выкатите код, который читает новую форму. Продолжайте писать в обе. Этот шаг можно откатить дёшево, и именно поэтому он выделен в отдельный деплой.
- Contract. В следующем релизе, когда вы уверены, перестаньте писать старую форму и удалите её.
Промежуток между шагами 4 и 5 - и есть суть всей затеи. Если чтение новой колонки пойдёт не так, вы откатываете один деплой, а старая колонка на месте, актуальна и корректна. Склейте эти два шага - и для отката понадобится восстановление из backup.
Переименование, деплой за деплоем#
Классический разобранный пример. users.email становится users.email_address в таблице на два миллиона строк, без простоя.
-- Deploy 1, expand. Instant on PostgreSQL 11 and later:-- a constant default is stored in the catalogue, not written to every row.ALTER TABLE users ADD COLUMN email_address text;// Deploy 2, dual write. Every path that writes an email writes both.await db.query( `UPDATE users SET email = $1, email_address = $1 WHERE id = $2`, [email, id],);-- Deploy 3, backfill, run outside the deploy in batches (see below),-- then check it actually finished:SELECT count(*) FROM usersWHERE email IS DISTINCT FROM email_address;-- expect 0-- Deploy 4, switch reads. Code change only, no DDL.-- Deploy 5, contract, in a later release:ALTER TABLE users DROP COLUMN email;Пять шагов, четыре из которых - обычный деплой. Единственный по-настоящему необратимый - последний, и к тому времени новая колонка уже неделю работает в продакшене. DROP COLUMN в PostgreSQL - это изменение каталога, и оно возвращается сразу, хотя место не освобождается, пока строки не будут переписаны, - в статье vacuum и bloat в Postgres объяснено, почему таблица не уменьшается.
Та же схема покрывает разбиение одной колонки на две, смену типа, перенос поля в другую таблицу и выделение справочной таблицы. Отличается только середина.
Блокировки: что на самом деле берёт каждая операция#
Вот то, на чём попадаются и опытные люди. ALTER TABLE ADD COLUMN выполняется мгновенно, но всё равно берёт блокировку ACCESS EXCLUSIVE, которая конфликтует со всем, включая обычный SELECT. Если получить её сразу не выходит - потому что отчёт уже сорок секунд работает или простаивающая транзакция держит блокировку на чтение, - она ждёт. А пока она ждёт, каждый запрос, пришедший после неё, встаёт в очередь за ней, потому что запросы на блокировки упорядочены. Миграция в одну миллисекунду может поэтому остановить ваш сайт на столько, сколько работает самый медленный запрос перед ней.
Решение - две строки, и им место в начале каждой миграции:
SET lock_timeout = '3s';SET statement_timeout = '30s';Теперь миграция либо получит блокировку в течение трёх секунд, либо чисто откажет, ничего с собой не унеся, и вы повторите. Миграция, которая упала и была повторена, - не событие. Миграция, которая ждёт, - инцидент.
| Операция | Блокировка | Практическая цена |
|---|---|---|
ADD COLUMN (без значения по умолчанию или с константой) | ACCESS EXCLUSIVE, мгновенно | Безопасно с lock timeout |
ADD COLUMN с volatile-значением по умолчанию | ACCESS EXCLUSIVE, полная перезапись | Избегайте: добавьте с NULL, затем backfill |
DROP COLUMN | ACCESS EXCLUSIVE, мгновенно | Безопасно, место освободится позже |
RENAME COLUMN / RENAME TABLE | ACCESS EXCLUSIVE, мгновенно | Безопасно для базы, губительно для старого кода |
ALTER COLUMN TYPE | ACCESS EXCLUSIVE, полная перезапись | Расширение varchar или переход на text бесплатны |
SET NOT NULL | ACCESS EXCLUSIVE, полное сканирование | Используйте путь через CHECK ... NOT VALID ниже |
CREATE INDEX | Блокирует запись на всё время построения | Используйте CONCURRENTLY |
CREATE INDEX CONCURRENTLY | Допускает чтение и запись | Два прохода, медленнее, нельзя в транзакции |
ADD FOREIGN KEY | Блокирует обе таблицы на время сканирования | Используйте NOT VALID, затем VALIDATE |
VALIDATE CONSTRAINT | Допускает чтение и запись | Ради этого и существует NOT VALID |
Ещё две полезные привычки. По возможности держите каждую миграцию в пределах одной таблицы, чтобы отказ был небольшим. И никогда не позволяйте миграции сидеть внутри длинной транзакции, которая заодно выполняет работу приложения: миграция, которая держит ACCESS EXCLUSIVE, ожидая медленный SELECT в той же транзакции, - худшее от обоих миров.
Backfill без длинной транзакции#
Один UPDATE users SET email_address = email на два миллиона строк - это одна транзакция, которая пишет два миллиона новых версий строк, порождает гигабайты write-ahead log, держит блокировки всё своё время, не даёт VACUUM ничего чистить и отодвигает ваши реплики назад. Если она упадёт на 90 процентах, всё откатится, и начинать придётся заново.
Вместо этого разбейте на пакеты. Идите по первичному ключу, фиксируйте каждый пакет и делайте короткую паузу, чтобы обычный трафик и autovacuum могли поработать.
-- One batch. Run in a loop from a script, not from psql by hand.WITH batch AS ( SELECT id FROM users WHERE email_address IS NULL AND email IS NOT NULL ORDER BY id LIMIT 5000 FOR UPDATE SKIP LOCKED)UPDATE users uSET email_address = u.emailFROM batch bWHERE u.id = b.id;# The loop, with a pause between batches$ while true; do rows=$(psql -qtAX -f backfill_batch.sql) [ "$rows" = "UPDATE 0" ] && break sleep 0.2 doneЧто делает backfill скучным вместо захватывающего:
- Размер пакета от 1 000 до 10 000 строк. Достаточно большой, чтобы быть эффективным, и достаточно маленький, чтобы каждая транзакция занимала миллисекунды.
- Сделайте его возобновляемым. Условие
WHEREдолжно описывать ещё не выполненную работу, чтобы остановка и повторный запуск скрипта ничего не стоили. - Сделайте его идемпотентным. Запуск дважды должен быть безвреден.
- `FOR UPDATE SKIP LOCKED` означает, что строка, которую в данный момент пишет приложение, пропускается, а не ожидается, и подхватывается на одном из следующих проходов.
- Следите за отставанием репликации и диском, пока он работает. Если отставание растёт, увеличьте паузу, а не размер пакета.
- Выполните `ANALYZE` для таблицы после этого, чтобы планировщик узнал статистику новой колонки до того, как её начнут использовать новые запросы.
Backfill большой таблицы - не то, что стоит запускать во время деплоя. Запустите его, дайте поработать час или сутки и проверьте число оставшихся строк, прежде чем выкатывать код, который от него зависит.
Индексы и ограничения: конкурентные варианты#
Новые индексы - самая частая миграция после новых колонок, и вариант по умолчанию для живой системы неверен. CREATE INDEX держит блокировку, которая запрещает запись на всё время построения, а для большой таблицы это минуты.
-- Outside a transaction. Most migration tools wrap statements in one,-- so this usually needs an explicit escape hatch.CREATE INDEX CONCURRENTLY idx_users_email_address ON users (email_address);-- If it fails, it leaves an invalid index behind. Find it:SELECT indexrelid::regclass AS index, indisvalidFROM pg_index WHERE NOT indisvalid;-- Drop it and try again:DROP INDEX CONCURRENTLY idx_users_email_address;CONCURRENTLY делает два прохода по таблице и в сумме медленнее, и это тот обмен, который вам нужен. Его нельзя выполнять внутри блока транзакции, поэтому в Alembic он идёт в autocommit_block, в Django - это AddIndexConcurrently с atomic = False, а в Rails нужен disable_ddl_transaction! с algorithm: :concurrently. Ошибка здесь - самая частая причина, по которой инструмент миграций наотрез отказывается принимать команду. Какой индекс создавать в первую очередь - в статье индексы Postgres простыми словами, а помог ли он - в EXPLAIN ANALYZE.
У ограничений тот же запасной выход. Добавление NOT NULL или внешнего ключа обычно сканирует всю таблицу под сильной блокировкой; если разбить его на два шага, сканирование происходит под блокировкой, допускающей чтение и запись:
-- Step 1: brief strong lock, no scanALTER TABLE users ADD CONSTRAINT users_email_address_not_null CHECK (email_address IS NOT NULL) NOT VALID;-- Step 2: the scan, without blocking trafficALTER TABLE users VALIDATE CONSTRAINT users_email_address_not_null;-- Step 3: PostgreSQL 12 and later can now use that constraint-- to set NOT NULL without scanning againALTER TABLE users ALTER COLUMN email_address SET NOT NULL;ALTER TABLE users DROP CONSTRAINT users_email_address_not_null;Та же пара NOT VALID и VALIDATE работает и для внешних ключей, и это разница между деплоем и простоем на любой таблице с более чем несколькими миллионами строк.
MongoDB: тот же паттерн без DDL#
Соблазнительно думать, что база без схемы снимает проблему. Она убирает ALTER TABLE, но не окно перекрытия. Две формы документов всё равно существуют одновременно, и старый код по-прежнему должен с этим справляться.
// Expand: nothing to do, the field simply starts appearing.// Dual write:db.users.updateOne({ _id: id }, { $set: { email: e, emailAddress: e } })// Backfill in batches, resumable by _id:db.users.updateMany( { emailAddress: { $exists: false }, email: { $exists: true } }, [{ $set: { emailAddress: "$email" } }],)// Contract, later:db.users.updateMany({}, { $unset: { email: "" } })Три замечания, специфичных для MongoDB. Оператор обновления $rename существует, и применить его за один проход - это ровно та ошибка, о которой вся эта статья: старый код останется читать поле, которого больше нет. Построение индексов начиная с версии 4.2 использует единый тип построения, который удерживает эксклюзивную блокировку лишь ненадолго в начале и в конце, поэтому создание индекса на живой коллекции стало гораздо менее опасным, чем раньше, но всё равно стоит памяти и I/O, так что делайте это в непиковое время. И если вы используете schema validation, сначала ужесточайте её через validationLevel: "moderate", чтобы новое правило применялось к документам, которых вы касаетесь, а не отклоняло разом все старые. В статье индексы MongoDB и проектирование схемы больше о решениях по форме данных, а в PostgreSQL или MongoDB - о том, какую из двух баз вам стоило выбрать.
Как сделать это повторяемым#
Паттерн хорош настолько, насколько хорош процесс вокруг него.
- Используйте инструмент миграций, один файл на изменение, в системе контроля версий. Alembic, миграции Django, Flyway, Liquibase, golang-migrate - что бы ни использовал ваш стек. Вручную набранный SQL в продакшене - это не миграция, а анекдот.
- Только вперёд. Пишите down-миграцию, если инструмент настаивает, но не планируйте ею пользоваться. Восстановление после неудачной миграции означает откат кода и выкатку новой миграции вперёд, потому что down-миграция, удаляющая колонку, удаляет и данные, записанные с тех пор.
- Не доверяйте down-миграции. Та, что удаляет колонку, стирает каждое значение, записанное в неё после деплоя, а это именно те данные, которые вы хотели спасти откатом.
- Один запуск миграций за раз. Если два экземпляра деплоятся одновременно, два исполнителя могут столкнуться. Некоторые инструменты берут блокировку автоматически; если ваш нет, возьмите её сами через
SELECT pg_advisory_lock(...)вокруг запуска. - Репетируйте на копии продакшена. Восстановите свежий дамп в тестовую базу, выполните миграцию и засеките время. Команда, занимающая 200 мс на 5 000 строк вашего ноутбука, может занять четыре минуты на настоящей таблице, и именно это число вам нужно, прежде чем что-либо планировать. В статье staging и production на одном аккаунте описан дешёвый способ держать такую копию под рукой, а в pg_dump и pg_restore - как доставить туда данные.
- Сделайте backup перед шагом contract. Expand обратим, удаление - нет. На RE:NODE слоты для backup входят в каждый тариф, вы можете сделать backup по требованию или поставить его на вкладку Schedules с выражением cron, а восстановление - это кнопка, а не разговор с поддержкой, - но backup, который никто не восстанавливал, - это гипотеза, так что проверьте, что он работает, в день, когда ничего не случилось.
- Выкатывайте код и миграции отдельными шагами. В тарифах для приложений здесь deploy-on-push перезапускает только уже работавший сервер и записывает одну строку на каждый деплой, так что описанная последовательность выглядит как четыре-пять чётко разделённых событий, а не как одна загадка. Сторона приложения той же задачи - слив соединений, health check, перекрытие старого и нового процессов - разобрана в статье деплой без простоя на небольшом сервере.
Пять скучных деплоев дешевле одного интересного. Ещё ни один человек не пожалел о лишнем релизе.
FAQ#
Почему нельзя просто переименовать колонку?
Потому что работающий сейчас код не знает нового имени. Само переименование мгновенно и безопасно для базы; ломается работающее приложение - в те секунды или минуты, когда старая и новая версии перекрываются. Добавьте, пишите в обе, сделайте backfill, переключите, удалите.
Моя миграция мгновенная, почему же сайт завис?
Потому что ей пришлось ждать блокировку. ALTER TABLE берёт блокировку, конфликтующую со всем, и пока она ждёт, каждый новый запрос встаёт в очередь за ней. Задайте lock_timeout в несколько секунд в начале миграции, чтобы она упала и была повторена, а не блокировала трафик.
Как добавить колонку NOT NULL в большую таблицу?
Добавьте её допускающей NULL, выкатите код, который всегда её заполняет, заполните старые строки пакетами, затем добавьте ограничение CHECK (col IS NOT NULL) NOT VALID, проверьте его и установите NOT NULL. В PostgreSQL 11 и новее колонку с константным значением по умолчанию можно добавить сразу, не переписывая таблицу.
Можно ли выполнять миграцию в пик нагрузки?
Небольшие - да, с lock timeout. Всё, что переписывает таблицу, строит индекс или заполняет миллионы строк, стоит запускать, когда база спокойна, - не потому, что оно упадёт, а потому, что оно конкурирует с вашими пользователями за те же CPU и диск.
Нужно ли это и для MongoDB?
Да. ALTER TABLE там нет, но окно перекрытия то же самое: две формы документа существуют одновременно, и старый код должен терпеть обе. Разница в том, что MongoDB молча позволит вам обойтись без дисциплины, а вы узнаете об этом потом.
Что делать, если миграция выполнилась наполовину?
Остановитесь, проверьте, в каком состоянии база, и идите вперёд. Сделайте миграцию возобновляемой и идемпотентной, чтобы её можно было запустить снова, и предпочитайте исправлять вперёд новой миграцией, а не запускать down-миграцию, которая с готовностью удалит данные, записанные за это время.




Комментарии
Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.