RE:NODE

Базы данных13 мин чтения

Миграции схемы без простоя: expand and contract

Добавлять безопасно, ломает переименование. Паттерн expand and contract, поведение блокировок в Postgres, backfill пакетами и порядок, который работает.

Обновлено

0 прочтений

Во время любого деплоя есть окно, когда старый и новый код работают с одной и той же базой данных. При rolling deploy это окно измеряется минутами. На одном процессе - это те несколько секунд между тем, как старый процесс закончил последний запрос, и тем, как новый принял первый. В любом случае окно существует, и из этого факта следует каждая безопасная миграция, а каждая небезопасная его игнорирует.

Отсюда короткое правило: миграция должна оставлять базу данных доступной для чтения и записи той версии кода, которая уже работает. Добавление этому условию удовлетворяет. Удаление удовлетворяет, когда на удаляемое больше ничего не ссылается. Переименование и смена типа не удовлетворяют никогда, поэтому их выполняют как последовательность добавлений и удалений. Остаток статьи - эта последовательность подробно, плюс поведение блокировок, которое превращает «мгновенный» ALTER TABLE в тридцатисекундный простой, и то, как заполнить миллионы строк, не держа транзакцию открытой час.

Окно перекрытия и что оно запрещает#

Представьте две версии, работающие бок о бок пять минут. Старый код выбирает email; новый выбирает email_address. В период перекрытия любая отсутствующая колонка кладёт половину ваших запросов. Это и есть весь механизм отказа, и им объясняется каждый из этих запретов:

  • Никаких переименований. Старый код не знает нового имени.
  • Никаких смен типа, меняющих смысл. Старый код пишет старый тип.
  • Ничего не удалять из того, что старый код всё ещё выбирает, включая колонки, которыми он не пользуется, если он делает SELECT * в строгий маппер строк.
  • Никаких колонок `NOT NULL` без значения по умолчанию, потому что старый код вставляет строки без неё.
  • Никаких ограничений, которые нарушили бы существующие строки или старые записи.

И два запрета, которые касаются механики самой миграции, а не кода:

  • Никаких операций, долго удерживающих сильную блокировку, потому что всё остальное встанет в очередь за ней.
  • Никаких одиночных транзакций, затрагивающих каждую строку, из-за того, что это делает с репликацией, блокировками и bloat.
старый код не затронутновые записи уже верныпроверить ноль расхожденийчерез релизДеплой 1новая колонкаДеплой 2пишем в обеBackfillпакетамиДеплой 3читаем новуюДеплой 4удаляем старую
Одно переименование, пять безопасных шагов

Expand, migrate, contract#

У паттерна три фазы и обычно четыре-пять деплоев. Для переименования колонки это выглядит как много церемоний, и так оно и есть - вплоть до первого раза, когда вы сделаете это одним шагом в шесть вечера.

  1. Expand. Добавьте новую колонку, таблицу или индекс. Допускающую NULL, со значением по умолчанию, если оно нужно. Старый код её игнорирует, потому что не знает о ней; новый код может использовать.
  2. Dual write. Выкатите код, который пишет и в старую, и в новую форму, но всё ещё читает старую. Пока ничего не зависит от того, что новые данные полны.
  3. Backfill. Копируйте существующие строки пакетами, пока всё продолжает работать. Новые строки уже верны благодаря шагу 2, так что backfill нужен только для истории.
  4. Переключение чтения. Выкатите код, который читает новую форму. Продолжайте писать в обе. Этот шаг можно откатить дёшево, и именно поэтому он выделен в отдельный деплой.
  5. Contract. В следующем релизе, когда вы уверены, перестаньте писать старую форму и удалите её.

Промежуток между шагами 4 и 5 - и есть суть всей затеи. Если чтение новой колонки пойдёт не так, вы откатываете один деплой, а старая колонка на месте, актуальна и корректна. Склейте эти два шага - и для отката понадобится восстановление из backup.

Переименование, деплой за деплоем#

Классический разобранный пример. users.email становится users.email_address в таблице на два миллиона строк, без простоя.

sql
-- 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;
javascript
// 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],);
sql
-- 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
sql
-- 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. Если получить её сразу не выходит - потому что отчёт уже сорок секунд работает или простаивающая транзакция держит блокировку на чтение, - она ждёт. А пока она ждёт, каждый запрос, пришедший после неё, встаёт в очередь за ней, потому что запросы на блокировки упорядочены. Миграция в одну миллисекунду может поэтому остановить ваш сайт на столько, сколько работает самый медленный запрос перед ней.

Решение - две строки, и им место в начале каждой миграции:

sql
SET lock_timeout = '3s';SET statement_timeout = '30s';

Теперь миграция либо получит блокировку в течение трёх секунд, либо чисто откажет, ничего с собой не унеся, и вы повторите. Миграция, которая упала и была повторена, - не событие. Миграция, которая ждёт, - инцидент.

ОперацияБлокировкаПрактическая цена
ADD COLUMN (без значения по умолчанию или с константой)ACCESS EXCLUSIVE, мгновенноБезопасно с lock timeout
ADD COLUMN с volatile-значением по умолчаниюACCESS EXCLUSIVE, полная перезаписьИзбегайте: добавьте с NULL, затем backfill
DROP COLUMNACCESS EXCLUSIVE, мгновенноБезопасно, место освободится позже
RENAME COLUMN / RENAME TABLEACCESS EXCLUSIVE, мгновенноБезопасно для базы, губительно для старого кода
ALTER COLUMN TYPEACCESS EXCLUSIVE, полная перезаписьРасширение varchar или переход на text бесплатны
SET NOT NULLACCESS 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 могли поработать.

sql
-- 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;
bash
# 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 держит блокировку, которая запрещает запись на всё время построения, а для большой таблицы это минуты.

sql
-- 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 или внешнего ключа обычно сканирует всю таблицу под сильной блокировкой; если разбить его на два шага, сканирование происходит под блокировкой, допускающей чтение и запись:

sql
-- 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, но не окно перекрытия. Две формы документов всё равно существуют одновременно, и старый код по-прежнему должен с этим справляться.

javascript
// 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. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000