Миграция схемы базы данных без даунтайма для postgresql на примере django
- четверг, 11 октября 2018 г. в 00:22:20
Привет, Хабр!
Хочу поделиться опытом написания миграций для postgres и django. Речь в основном пойдёт про postgres, django же здесь хорошо дополняет, так как из коробки имеет автоматическую миграцию схемы данных по изменениям модельки, то есть имеет довольно полный список рабочих операций по изменению схемы. Django можно заменить на любой любимый фрэймворк/библиотеку — подходы скорее всего будут похожи.
Не буду описывать как я к этому пришёл, но сейчас читая документацию ловлю на мысли, что нужно было с большей внимательностью и осознанием делать это раньше, поэтому очень рекомендую.
Перед тем как пойти дальше позволю себе сделать следующие предположения.
Можно разделить логику работы с базой данных большинства приложений на 3 части:
Даунтайм — это состояние, когда часть нашей бизнес логики не доступна/падает/грузится на заметное для пользователя время, предположим это пару секунд.
Отсутствие даунтайма может быть критическим для бизнеса условием, которого любыми усилиями нужно придерживаться.
Основное требования при выкатке:
Если имеется большое количество изменений и выкатка перестаёт удовлетворять этим условиям, то она делится на нужное количество меньших выкаток, удовлетворяющих этим условиям, иначе у нас идёт даунтайм.
Прямой порядок выкатки:
Обратный порядок выкатки актуален для удаления таблиц и колонок в таблице, когда мы автоматически создаём миграции по изменённой схеме и валидируем наличие всех миграций на CI:
Postgres отличная база данных, мы можем писать приложение, которое в сотни и тысячи потоков будет писать и читать одни и те же данных, и с большой вероятностью быть уверенными, что наши данные будут оставаться валидными и не будут повреждены, в общем полный ACID. Postgres реализует несколько механизмов по достижению этого, один из них это блокировки.
У postgres есть несколько типов блокировок, подробнее можно посмотреть здесь: https://www.postgresql.org/docs/current/static/explicit-locking.html, в рамках темы я затрону только блокировки на уровне таблицы и записи.
На уровне таблицы postgres имеет несколько видов блокировок https://www.postgresql.org/docs/current/static/explicit-locking.html#LOCKING-TABLES, основная особенность, что у них есть конфликты, то есть две операции с конфликтующими блокировками не могут выполняться одновременно:
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 |
Тут можно резюмировать два пункта:
CREATE INDEX
и CREATE INDEX CONCURRENTLY
.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 запросов для миграций данных разбиение на части может быстрее работать, так как может использовать более оптимальный план и индексы.
Ещё одним важным знанием является то как операции будут выполняться, когда и как они берут и освобождают блокировки:
Здесь можно выделить следующие пункты:
CREATE INDEX
или ALTER TABLE ADD COLUMN SET DEFAULT
(в postgres 11 с этим получше).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 хранит значения разного типа по-разному: https://www.postgresql.org/docs/current/static/storage-toast.html#STORAGE-TOAST-ONDISK, если типы хранятся по разному, то конвертация между ними потребует полной перезаписи всех значений, к счастью некоторые типы хранятся одинаково и не требуют перезаписи при измении. Например, строки хранятся одинаково независимо от размера и уменьшение/увеличения размерности строки не потребует перезаписи, но уменьшение требует проверки, что все строки не превышают меньшего размера. Другие типы также могут храниться подобным образом и иметь схожие особенности.
Согласно документации https://www.postgresql.org/docs/current/static/mvcc-intro.html, консистентность в 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
блокировками, которые занимают значительное время.
Заранее оставлю полезную ссылку на документацию https://www.postgresql.org/docs/current/static/sql-altertable.html с замечательными примерами.
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 имеет операцию в миграциях для выполнения любого 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, то вариантов изменения схемы данных может быть много, причём они могут разнообразно сочетаться под конкретные условия:
В любом случае надеюсь что материал оказался полезен либо для повышения аптайма, либо для расширения сознания.