Миграция схемы базы данных без даунтайма для postgresql на примере django

Привет, Хабр!

Хочу поделиться опытом написания миграций для postgres и django. Речь в основном пойдёт про postgres, django же здесь хорошо дополняет, так как из коробки имеет автоматическую миграцию схемы данных по изменениям модельки, то есть имеет довольно полный список рабочих операций по изменению схемы. Django можно заменить на любой любимый фрэймворк/библиотеку — подходы скорее всего будут похожи.

Не буду описывать как я к этому пришёл, но сейчас читая документацию ловлю на мысли, что нужно было с большей внимательностью и осознанием делать это раньше, поэтому очень рекомендую.

Перед тем как пойти дальше позволю себе сделать следующие предположения.

Можно разделить логику работы с базой данных большинства приложений на 3 части:


  1. Миграции — изменение схемы базы данных (таблиц), предположим мы всегда запускаем их в один поток.
  2. Бизнес логика — непосредственная работа с данными (в пользовательских таблицах), работает с одними и теми же данными постоянно и конкурентно.
  3. Миграции данных — не изменяют схемы данных, работают по сути как бизнес логика, по умолчанию, когда будем говорить про бизнес логику, будем также подразумевать и миграции данных.

Даунтайм — это состояние, когда часть нашей бизнес логики не доступна/падает/грузится на заметное для пользователя время, предположим это пару секунд.

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

Основное требования при выкатке:


  1. у нас одна рабочая база.
  2. у нас несколько машинок, где крутится бизнес логика.
  3. машинки с бизнес логикой спрятаны за балансером.
  4. наше приложение хорошо работает до, во время и после накатки миграции (старый код работает корректно со старой и новой схемой базы).
  5. наше приложение хорошо работает до, во время и после обновления кода на машинках (старый и новый код работает корректно с текущей схемой базы).

Если имеется большое количество изменений и выкатка перестаёт удовлетворять этим условиям, то она делится на нужное количество меньших выкаток, удовлетворяющих этим условиям, иначе у нас идёт даунтайм.

Прямой порядок выкатки:


  1. залили миграцию;
  2. убрали из балансера одну машинку, обновили машинку и перезапустили, вернули машинку в балансер;
  3. повторили предыдущий шаг до обновления всех машинок.

Обратный порядок выкатки актуален для удаления таблиц и колонок в таблице, когда мы автоматически создаём миграции по изменённой схеме и валидируем наличие всех миграций на CI:


  1. убрали из балансера одну машинку, обновили машинку и перезапустили, вернули машинку в балансер;
  2. повторили предыдущий шаг до обновления всех машинок;
  3. залили миграцию.

Postgres отличная база данных, мы можем писать приложение, которое в сотни и тысячи потоков будет писать и читать одни и те же данных, и с большой вероятностью быть уверенными, что наши данные будут оставаться валидными и не будут повреждены, в общем полный ACID. Postgres реализует несколько механизмов по достижению этого, один из них это блокировки.

У postgres есть несколько типов блокировок, подробнее можно посмотреть здесь, в рамках темы я затрону только блокировки на уровне таблицы и записи.


Блокировки на уровне таблицы

На уровне таблицы postgres имеет несколько видов блокировок, основная особенность, что у них есть конфликты, то есть две операции с конфликтующими блокировками не могут выполняться одновременно:


ACCESS SHARE ROW SHARE ROW EXCLUSIVE SHARE UPDATE EXCLUSIVE SHARE SHARE ROW EXCLUSIVE EXCLUSIVE ACCESS EXCLUSIVE
ACCESS SHARE X
ROW SHARE X X
ROW EXCLUSIVE X X X X
SHARE UPDATE EXCLUSIVE X X X X X
SHARE X X X X X
SHARE ROW EXCLUSIVE X X X X X X
EXCLUSIVE X X X X X X X
ACCESS EXCLUSIVE X X X X X X X X

Например, ALTER TABLE tablename ADD COLUMN newcolumn integer и SELECT COUNT(*) FROM tablename должны быть строго выполнены одна за одной, иначе мы не можем узнать какие колонки возвращать в COUNT(*).

В django миграциях (полный список ниже) присутствуют следующие операции и соответствующие им блокировки:


блокировки операции
ACCESS EXCLUSIVE CREATE SEQUENCE, DROP SEQUENCE, CREATE TABLE, DROP TABLE, ALTER TABLE, DROP INDEX
SHARE CREATE INDEX
SHARE UPDATE EXCLUSIVE CREATE INDEX CONCURRENTLY, DROP INDEX CONCURRENTLY, ALTER TABLE VALIDATE CONSTRAINT

Из замечаний не все ALTER TABLE имеют ACCESS EXCLUSIVE блокировку, также в django миграциях отсутствуют CREATE INDEX CONCURRENTLY и ALTER TABLE VALIDATE CONSTRAINT, но они понадобятся для более безопасной альтернативы стандартным операциям чуть позже.

Если миграции выполняются в один поток последовательно, то тут всё выглядит хорошо, так как миграция не будет конфликтовать с другой миграцией, но наша бизнес логика будет работать как раз во время миграции и конфликтовать.


блокировки операции конфликтует с блокировками конфликтует с операциями
ACCESS SHARE SELECT ACCESS EXCLUSIVE ALTER TABLE, DROP INDEX
ROW SHARE SELECT FOR UPDATE ACCESS EXCLUSIVE, EXCLUSIVE ALTER TABLE, DROP INDEX
ROW EXCLUSIVE INSERT, UPDATE, DELETE ACCESS EXCLUSIVE, EXCLUSIVE, SHARE ROW EXCLUSIVE, SHARE ALTER TABLE, DROP INDEX, CREATE INDEX

Тут можно резюмировать два пункта:


  1. если существует альтернатива с более лёгкой блокировкой — можно использовать её, как CREATE INDEX и CREATE INDEX CONCURRENTLY.
  2. большинство миграций по изменению схемы данных конфликтуют с бизнес логикой, причём конфликтуют с ACCESS EXCLUSIVE, то есть мы даже не сможем сделать SELECT пока держим эту блокировку и потенциально здесь нас ожидает даунтайм, за исключением случая, когда данная операция не отработает моментально и наш даунтайм составит пару секунд.

Тут должен быть выбор, либо мы всегда избегаем ACCESS EXCLUSIVE, то есть создаём новую таблички и копируем туда данные — надёжно, но долго для большого объёма данных, либо делаем ACCESS EXCLUSIVE максимально быстрым и делаем дополнительные предостережения от даунтайма — потенциально опасно, но быстро.


Блокировки на уровне записи

На уровне записи тоже есть свои блокировки https://www.postgresql.org/docs/current/static/explicit-locking.html#LOCKING-ROWS, они также конфликтует между собой, но затрагивают только нашу бизнес логику:


FOR KEY SHARE FOR SHARE FOR NO KEY UPDATE FOR UPDATE
FOR KEY SHARE X
FOR SHARE X X
FOR NO KEY UPDATE X X X
FOR UPDATE X X X X

Здесь основной пункт в миграциях данных, то есть, если мы сделаем в миграции данных UPDATE на всю табличку, то остальная бизнес логика, которая обновляет данные, будет ждать освобождения блокировки и может превысить наш порог даунтайма, поэтому лучше для миграций данных делать обновления частями. Также стоит заметить, что при использовании более сложных sql запросов для миграций данных разбиение на части может быстрее работать, так как может использовать более оптимальный план и индексы.


Очерёдность выполнения операций

Ещё одним важным знанием является то как операции будут выполняться, когда и как они берут и освобождают блокировки:

image

Здесь можно выделить следующие пункты:


  1. время выполнения операции — для миграции это время удерживания блокировки, если тяжёлая блокировка удерживается долго — у нас будет даунтайм, например это может быть при CREATE INDEX или ALTER TABLE ADD COLUMN SET DEFAULT (в postgres 11 с этим получше).
  2. время ожидания конфликтующих блокировок — то есть миграция ждёт пока отработают все конфликтующие запросы, а в это время новые запросы будут ждать нашей миграции, здесь очень опасными могут быть медленные запросы как просто не оптимальные, так и аналитические, поэтому медленный запросов не должно быть во время миграции.
  3. количество запросов в секунду — если у нас много запросов долго отрабатывают, то свободные соединения быстро могут закончиться и вместо одного проблематичного места у нас в даунтайм может уйти вся база (останется только лимит соединений для суперюзера), здесь нужно избегать медленных запросов, уменьшать количество запросов, например, запускать миграции во время минимальной нагрузки, разделять критические компоненты на разные сервисы со своими базами.
  4. много операций миграций в одной транзакции — чем больше в одной транзакции операций, тем дольше удерживается тяжёлая блокировка, поэтому тяжёлые операции лучше разделять, никаких ALTER TABLE VALIDATE CONSTRAINT или миграций данных в одной транзакции с тяжёлой блокировкой.


Таймауты

У postgres есть такие настройки как lock_timeout и statement_timeout, которые могут обезопасить запуск миграций, как от плохо написанной миграции, так и от плохих условий в которых миграция может запускаться. Могут устанавливаться как глобально, так и для текущего соединения.

SET lock_timeout TO '2s' позволит избежать даунтайма при ожидании медленных запросов/транзакций перед миграцией: https://www.postgresql.org/docs/current/static/runtime-config-client.html#GUC-LOCK-TIMEOUT.

SET statement_timeout TO '2s' позволит избежать даунтайма при запуске тяжёлой миграции с тяжёлой блокировкой: https://www.postgresql.org/docs/current/static/runtime-config-client.html#GUC-STATEMENT-TIMEOUT.


Дэдлоки

Дэдлоки в миграциях не про даунтайм, но приятного мало, когда миграция написана, отлично работает на тестовом окружении, но ловит дэдлок при накатке на прод. Основными источниками проблем могут быть большое количество операций в одной транзакции и Foreign Key, так как создаёт блокировки в обоих таблицах, поэтому лучше разделять операции миграций, чем атомарнее — тем лучше.


Хранение записей

Postgres хранит значения разного типа по-разному: если типы хранятся по разному, то конвертация между ними потребует полной перезаписи всех значений, к счастью некоторые типы хранятся одинаково и не требуют перезаписи при измении. Например, строки хранятся одинаково независимо от размера и уменьшение/увеличения размерности строки не потребует перезаписи, но уменьшение требует проверки, что все строки не превышают меньшего размера. Другие типы также могут храниться подобным образом и иметь схожие особенности.


Multiversion Concurrency Control (MVCC)

Согласно документации, консистентность в postgres основана на мультиверсионности данных, то есть каждая транзакция и операция видит свою версию данных. Эта особенность отлично справляется с конкурентным доступом, а также даёт интересный эффект, когда изменение схемы как добавление и удаление колонок меняет только схему, если нет дополнительных операций по изменению данных, индексов или констрэйнтов, после чего операции вставки и обновления на низком уровне будут создавать новые записи со всеми нужными значениями, удаление будет помечать соответствующую запись удалённой. За очистку оставшегося мусора отвечает VACUUM или AUTO VACUUM.

У нас теперь есть представление от чего может зависеть даунтайм и как его можно избегать, но перед тем как применить знания можно глянуть, что из коробки даёт django (https://github.com/django/django/blob/2.1.2/django/db/backends/base/schema.py и https://github.com/django/django/blob/2.1.2/django/db/backends/postgresql/schema.py):


операция
1 CREATE SEQUENCE
2 DROP SEQUENCE
3 CREATE TABLE
4 DROP TABLE
5 ALTER TABLE RENAME TO
6 ALTER TABLE SET TABLESPACE
7 ALTER TABLE ADD COLUMN [SET DEFAULT] [SET NOT NULL] [PRIMARY KEY] [UNIQUE]
8 ALTER TABLE ALTER COLUMN [TYPE] [SET NOT NULL|DROP NOT NULL] [SET DEFAULT|DROP DEFAULT]
9 ALTER TABLE DROP COLUMN
10 ALTER TABLE RENAME COLUMN
11 ALTER TABLE ADD CONSTRAINT CHECK
12 ALTER TABLE DROP CONSTRAINT CHECK
13 ALTER TABLE ADD CONSTRAINT FOREIGN KEY
14 ALTER TABLE DROP CONSTRAINT FOREIGN KEY
15 ALTER TABLE ADD CONSTRAINT PRIMARY KEY
16 ALTER TABLE DROP CONSTRAINT PRIMARY KEY
17 ALTER TABLE ADD CONSTRAINT UNIQUE
18 ALTER TABLE DROP CONSTRAINT UNIQUE
19 CREATE INDEX
20 DROP INDEX

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

Безопасными будем называть миграции с SHARE UPDATE EXCLUSIVE блокировкой или ACCESS EXCLUSIVE, которая отрабатывает моментально.
Опасными будем называть миграции c SHARE и ACCESS EXCLUSIVE блокировками, которые занимают значительное время.

Заранее оставлю полезную ссылку на документацию с замечательными примерами.


Создание и удаление таблицы

CREATE SEQUENCE, DROP SEQUENCE, CREATE TABLE, DROP TABLE можно назвать безопасными, так как бизнес логика либо ещё, либо уже не работает с мигрируемой таблицей, поведение удаления таблицы с FOREIGN KEY будет чуть позже.


Тяжело поддерживаемые операции на рабочих таблицах

ALTER TABLE RENAME TO — не могу назвать безопасной, так как тяжело писать логику, которая работает с такой таблицей до и после миграции.

ALTER TABLE SET TABLESPACE — небезопасная, так как физически перемещает табличку, а это на большом объёме может быть долго.

С другой стороны эти операции скорее крайне редки, как альтернативу можно предложить создание новой таблицы и копирование данных в неё.


Создание и удаление колонки

ALTER TABLE ADD COLUMN, ALTER TABLE DROP COLUMN — можно назвать безопасными (создание именно без DEFAULT/NOT NULL/PRIMARY KEY/UNIQUE), так как бизнес логика либо ещё, либо уже не работает с мигрируемой колонкой, поведение удаления колонки с FOREIGN KEY, другими констрэйнтами и индексами будет позже.

ALTER TABLE ADD COLUMN SET DEFAULT, ALTER TABLE ADD COLUMN SET NOT NULL, ALTER TABLE ADD COLUMN PRIMARY KEY, ALTER TABLE ADD COLUMN UNIQUE — небезопасные операции, так как добавляют колонку и не отпуская блокировки обновляют данные дефолтами или создают констрэйнты, в качестве альтернативы создание nullable колонки и дальнейшее изменение.

Стоит упомянуть более быстрый SET DEFAULT в postgres 11, его можно рассматривать как безопасный, но он не становится сильно полезным в django, так как django использует SET DEFAULT только для заполнения колонки и потом делает DROP DEFAULT, а в промежуток между миграцией и обновлением машинок с бизнес логикой, могут создаться записи, у которых default будет отсутствовать, то есть потом всё равно делать миграцию данных.


Тяжело поддерживаемые операции на рабочей таблице

ALTER TABLE RENAME COLUMN — также не могу назвать безопасной, так как тяжело писать логику, которая работает с такой колонкой до и после миграции. Скорее эта операция тоже не будет частой, как альтернативу можно предложить создание новой колонки и копирование данных в неё.


Изменение колонки

ALTER TABLE ALTER COLUMN TYPE — операция может быть как опасной, так и безопасной. Безопасной в случае, если postgres меняет только схему, а данные уже хранятся в нужном формате и не нужны дополнительные проверки типа, например:


  • изменение типа из varchar(LESS) в varchar(MORE);
  • изменение типа из varchar(ANY) в text;
  • изменение типа из numeric(LESS, SAME) в numeric(MORE, SAME).

ALTER TABLE ALTER COLUMN SET NOT NULL — опасная, так как внутри проходит по данным и проверяет нет ли NULL, к счастью этот констрэйнт можно заменить другим CHECK IS NOT NULL. Тут стоит отметить, что данная замена приведёт к другой схеме, но с идентичными свойствами.

ALTER TABLE ALTER COLUMN DROP NOT NULL, ALTER TABLE ALTER COLUMN SET DEFAULT, ALTER TABLE ALTER COLUMN DROP DEFAULT — безопасные операции.


Создание и удаление индексов и констрэйнтов

ALTER TABLE ADD CONSTRAINT CHECK и ALTER TABLE ADD CONSTRAINT FOREIGN KEY — небезопасные операции, но их можно объявить как NOT VALID и потом сделать ALTER TABLE VALIDATE CONSTRAINT.

ALTER TABLE ADD CONSTRAINT PRIMARY KEY и ALTER TABLE ADD CONSTRAINT UNIQUE небезопасные, так как внутри создают уникальный индекс, но можно создать уникальный индекс как CONCURRENTLY, потом создать соответствующий констрэйнт используя уже готовый индекс, через USING INDEX.

CREATE INDEX — небезопасная операция, но индекс можно создать как CONCURRENTLY.

ALTER TABLE DROP CONSTRAINT CHECK, ALTER TABLE DROP CONSTRAINT FOREIGN KEY, ALTER TABLE DROP CONSTRAINT PRIMARY KEY, ALTER TABLE DROP CONSTRAINT UNIQUE, DROP INDEX — безопасные операции.

Стоит отметить, что ALTER TABLE ADD CONSTRAINT FOREIGN KEY и ALTER TABLE DROP CONSTRAINT FOREIGN KEY делают блокировку сразу двух таблиц.


Применяем знания в django

Django имеет операцию в миграциях для выполнения любого SQL: https://docs.djangoproject.com/en/2.1/ref/migration-operations/#django.db.migrations.operations.RunSQL. Через него можно задавать нужные таймауты и применять альтернативные операции для миграций, с указанием state_operations — миграции, которую мы заменяем.

Это работает хорошо для своего кода, хотя требует дополнительной писанины, но можно оставить грязную работу на db backend, например, https://github.com/tbicr/django-pg-zero-downtime-migrations/blob/master/django_zero_downtime_migrations_postgres_backend/schema.py собирают описанные практики и заменят небезопасные операции безопасными аналогами, причём это будет работать и для сторонних библиотек.

Данные практики позволили мне получить идентичную схему, создаваемую django из коробки, за исключением замены констрэйнта CHECK IS NOT NULL вместо NOT NULL и имён некоторых констрэйнтов (например для ALTER TABLE ADD COLUMN UNIQUE и альтернатива). Ещё одним компромиссом может быть отсутствие транзакционности для альтернативных операций миграций, особенно где фигурирует CREATE INDEX CONCURRENTLY и ALTER TABLE VALIDATE CONSTRAINT.

Если не выходить за рамки postgres, то вариантов изменения схемы данных может быть много, причём они могут разнообразно сочетаться под конкретные условия:


  • использование jsonb как schamaless решение
  • возможность сходить в даунтайм
  • требование делать миграции без даунтайма

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

© Habrahabr.ru