Миграция баз данных: превращаем гусениц в бабочек
Опубликовано: 2022-03-11Пользователям все равно, что находится внутри программного обеспечения, которое они используют; только то, что он работает плавно, безопасно и ненавязчиво. Разработчики стремятся к тому, чтобы это произошло, и одна из проблем, которую они пытаются решить, заключается в том, как убедиться, что хранилище данных находится в состоянии, соответствующем текущей версии продукта. Программное обеспечение развивается, и его модель данных также может меняться со временем, например, для исправления ошибок проектирования. Чтобы еще больше усложнить проблему, у вас может быть несколько тестовых сред или клиентов, которые переходят на более новые версии продукта с разной скоростью. Вы не можете просто задокументировать структуру магазина и какие манипуляции необходимы для использования новой блестящей версии с единой точки зрения.
Однажды я присоединился к проекту с несколькими базами данных, структура которых обновлялась по запросу непосредственно разработчиками. Это означало, что не было очевидного способа выяснить, какие изменения необходимо применить для перехода структуры на последнюю версию, и вообще не существовало концепции управления версиями! Это было в эпоху до DevOps, и в наши дни это считалось бы полным беспорядком. Мы решили разработать инструмент, который будет использоваться для применения каждого изменения к данной базе данных. Он имел миграции и документировал изменения схемы. Это дало нам уверенность в том, что случайных изменений не будет, а состояние схемы будет предсказуемым.
В этой статье мы рассмотрим, как применять миграцию схемы реляционной базы данных и как преодолевать сопутствующие проблемы.
Прежде всего, что такое миграция базы данных? В контексте этой статьи миграция — это набор изменений, которые следует применить к базе данных. Создание или удаление таблицы, столбца или индекса — распространенные примеры миграции. Форма вашей схемы может сильно измениться со временем, особенно если разработка началась, когда требования были еще неясными. Итак, в течение нескольких вех на пути к релизу ваша модель данных будет развиваться и, возможно, станет совсем другой, чем она была в самом начале. Миграции — это всего лишь шаги к целевому состоянию.
Для начала давайте изучим, что у нас есть в нашем наборе инструментов, чтобы не изобретать заново то, что уже хорошо сделано.
Инструменты
В каждом широко используемом языке есть библиотеки, упрощающие миграцию базы данных. Например, в случае с Java популярны варианты Liquibase и Flyway. Мы будем больше использовать Liquibase в примерах, но концепции применимы к другим решениям и не привязаны к Liquibase.
Зачем использовать отдельную библиотеку миграции схемы, если некоторые ORM уже предоставляют возможность автоматического обновления схемы и приведения ее в соответствие со структурой сопоставленных классов? На практике такие автоматические миграции выполняют только простые изменения схемы, например, создают таблицы и столбцы, и не могут выполнять потенциально разрушительные действия, такие как удаление или переименование объектов базы данных. Таким образом, неавтоматические (но все же автоматизированные) решения обычно являются лучшим выбором, потому что вы вынуждены сами описывать логику миграции и знаете, что именно произойдет с вашей базой данных.
Также очень плохая идея смешивать автоматические и ручные изменения схемы, потому что вы можете создать уникальные и непредсказуемые схемы, если ручные изменения применяются в неправильном порядке или не применяются вообще, даже если они необходимы. Выбрав инструмент, используйте его для применения всех миграций схемы.
Типичные миграции базы данных
Типичные миграции включают создание последовательностей, таблиц, столбцов, первичных и внешних ключей, индексов и других объектов базы данных. Для наиболее распространенных типов изменений Liquibase предоставляет отдельные декларативные элементы для описания того, что должно быть сделано. Было бы слишком скучно читать о каждом тривиальном изменении, поддерживаемом Liquibase или другими подобными инструментами. Чтобы получить представление о том, как выглядят наборы изменений, рассмотрим следующий пример, в котором мы создаем таблицу (объявления пространств имен XML для краткости опущены):
<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog> <changeSet author="demo"> <createTable tableName="PRODUCT"> <column name="ID" type="BIGINT"> <constraints primaryKey="true" primaryKeyName="PK_PRODUCT"/> </column> <column name="CODE" type="VARCHAR(50)"> <constraints nullable="false" unique="true" uniqueConstraintName="UC_PRODUCT_CODE"/> </column> </createTable> </changeSet> </databaseChangeLog>
Как видите, журнал изменений — это набор наборов изменений, а наборы изменений состоят из изменений. Простые изменения, такие как createTable
, можно комбинировать для реализации более сложных миграций; например, предположим, вам нужно обновить код продукта для всех продуктов. Это может быть легко достигнуто с помощью следующего изменения:
<sql>UPDATE product SET code = 'new_' || code</sql>
Производительность пострадает, если у вас миллионы продуктов. Чтобы ускорить миграцию, мы можем переписать ее в следующие шаги:
- Создайте новую таблицу для продуктов с помощью
createTable
, как мы видели ранее. На этом этапе лучше создать как можно меньше ограничений. Назовем новую таблицуPRODUCT_TMP
. - Заполните
PRODUCT_TMP
с помощью SQL в формеINSERT INTO ... SELECT ...
с помощью измененияsql
. - Создайте все необходимые ограничения (
addNotNullConstraint
,addUniqueConstraint
,addForeignKeyConstraint
) и индексы (createIndex
). - Переименуйте таблицу
PRODUCT
во что-то вродеPRODUCT_BAK
. Liquibase может сделать это с помощьюrenameTable
. - Переименуйте
PRODUCT_TMP
вPRODUCT
(опять же, используяrenameTable
). - При желании удалите
PRODUCT_BAK
с помощьюdropTable
.
Конечно, лучше избегать таких миграций, но хорошо знать, как их реализовать на случай, если вы столкнетесь с одним из тех редких случаев, когда вам это нужно.
Если вы считаете, что XML, JSON или YAML слишком причудливы для задачи описания изменений, тогда просто используйте простой SQL и используйте все функции, специфичные для поставщиков баз данных. Кроме того, вы можете реализовать любую пользовательскую логику на простой Java.
То, как Liquibase освобождает вас от написания фактического SQL для конкретной базы данных, может привести к чрезмерной самоуверенности, но вы не должны забывать о причудах вашей целевой базы данных; например, когда вы создаете внешний ключ, индекс может быть создан или не создан, в зависимости от конкретной используемой системы управления базой данных. В результате вы можете оказаться в неловкой ситуации. Liquibase позволяет указать, что набор изменений следует запускать только для определенного типа базы данных, например, PostgreSQL, Oracle или MySQL. Это делает возможным использование одних и тех же наборов изменений, не зависящих от поставщика, для разных баз данных, а для других наборов изменений — использование синтаксиса и функций, зависящих от поставщика. Следующий набор изменений будет выполнен только при использовании базы данных Oracle:
<changeSet dbms="oracle" author="..."> ... </changeSet>
Помимо Oracle, Liquibase по умолчанию поддерживает несколько других баз данных.
Именование объектов базы данных
Каждый объект базы данных, который вы создаете, должен быть назван. Вы не обязаны явно указывать имя для некоторых типов объектов, например, для ограничений и индексов. Но это не значит, что у этих объектов не будет имен; их имена в любом случае будут сгенерированы базой данных. Проблема возникает, когда вам нужно сослаться на этот объект, чтобы удалить или изменить его. Так что лучше дать им явные имена. Но есть ли какие-то правила о том, какие имена давать? Ответ короткий: будьте последовательны; например, если вы решили назвать индексы так: IDX_<table>_<columns>
, то индекс для вышеупомянутого столбца CODE
должен называться IDX_PRODUCT_CODE
.
Соглашения об именах невероятно противоречивы, поэтому мы не беремся давать здесь исчерпывающие инструкции. Будьте последовательны, уважайте условности вашей команды или проекта или просто изобретайте их, если их нет.
Организация наборов изменений
Первое, что нужно решить, это где хранить наборы изменений. В основном есть два подхода:
- Сохраняйте наборы изменений вместе с кодом приложения. Это удобно, потому что вы можете одновременно фиксировать и просматривать наборы изменений и код приложения.
- Держите наборы изменений и код приложения отдельно , например, в отдельных репозиториях VCS. Такой подход подходит, когда модель данных является общей для нескольких приложений и удобнее хранить все наборы изменений в выделенном репозитории, а не разбрасывать их по нескольким репозиториям, где живет код приложения.
Где бы вы ни хранили наборы изменений, разумно разделить их на следующие категории:
- Независимые миграции, не влияющие на работающую систему. Обычно безопасно создавать новые таблицы, последовательности и т. д., если текущее развернутое приложение еще не знает о них.
- Модификации схемы, изменяющие структуру хранилища , например добавление или удаление столбцов и индексов. Эти изменения не следует применять, пока используется более старая версия приложения, поскольку это может привести к блокировкам или странному поведению из-за изменений в схеме.
- Быстрые миграции, которые вставляют или обновляют небольшие объемы данных. Если развертывается несколько приложений, наборы изменений из этой категории могут выполняться одновременно без снижения производительности базы данных.
- Потенциально медленные миграции, которые вставляют или обновляют много данных. Эти изменения лучше применять, когда другие подобные миграции не выполняются.

Эти наборы миграций должны выполняться последовательно до развертывания более новой версии приложения. Этот подход становится еще более практичным, если система состоит из нескольких отдельных приложений, и некоторые из них используют одну и ту же базу данных. В противном случае стоит отделить только те наборы изменений, которые можно применить, не затрагивая запущенные приложения, а остальные наборы изменений можно применять вместе.
Для более простых приложений полный набор необходимых миграций может быть применен при запуске приложения. В этом случае все наборы изменений попадают в одну категорию и запускаются при каждой инициализации приложения.
На каком бы этапе ни было выбрано применение миграции, стоит отметить, что использование одной и той же базы данных для нескольких приложений может привести к блокировкам при применении миграции. Liquibase (как и многие другие подобные решения) использует две специальные таблицы для записи своих метаданных: DATABASECHANGELOG
и DATABASECHANGELOGLOCK
. Первый используется для хранения информации о примененных наборах изменений, а второй — для предотвращения одновременных миграций в рамках одной и той же схемы базы данных. Таким образом, если несколько приложений по какой-то причине должны использовать одну и ту же схему базы данных, лучше использовать нестандартные имена для таблиц метаданных, чтобы избежать блокировок.
Теперь, когда структура высокого уровня ясна, вам нужно решить, как организовать наборы изменений в каждой категории.
Это сильно зависит от конкретных требований приложения, но обычно разумны следующие моменты:
- Храните журналы изменений, сгруппированные по выпускам вашего продукта. Создайте новый каталог для каждого выпуска и поместите в него соответствующие файлы журнала изменений. Иметь корневой журнал изменений и включать журналы изменений, соответствующие выпускам. В журналы изменений выпуска включите другие журналы изменений, входящие в этот выпуск.
- Имейте соглашение об именах для файлов журналов изменений и идентификаторов наборов изменений — и, конечно же, следуйте ему.
- Избегайте наборов изменений с большим количеством изменений. Предпочитайте несколько наборов изменений одному длинному набору изменений.
- Если вы используете хранимые процедуры и вам необходимо их обновить, рассмотрите возможность использования
runOnChange="true"
изменений, в который добавлена эта хранимая процедура. В противном случае при каждом обновлении вам потребуется создавать новый набор изменений с новой версией хранимой процедуры. Требования различаются, но часто допустимо не отслеживать такую историю. - Рассмотрите возможность удаления избыточных изменений перед объединением ветвей функций. Иногда случается, что в ветке функций (особенно в долгоживущей) более поздние наборы изменений уточняют изменения, сделанные в более ранних наборах изменений. Например, вы можете создать таблицу, а затем решить добавить в нее дополнительные столбцы. Стоит добавить эти столбцы в начальное изменение
createTable
, если эта ветка функций еще не была объединена с основной веткой. - Используйте те же журналы изменений для создания тестовой базы данных. Если вы попытаетесь сделать это, то вскоре обнаружите, что не все наборы изменений применимы к тестовой среде или что для этой конкретной тестовой среды необходимы дополнительные наборы изменений. С Liquibase эта проблема легко решается с помощью контекстов . Просто добавьте атрибут
context="test"
в наборы изменений, которые необходимо выполнять только с тестами, а затем инициализируйте Liquibase с включеннымtest
контекстом.
Откат
Как и другие подобные решения, Liquibase поддерживает миграцию схемы «вверх» и «вниз». Но имейте в виду: отменить миграцию может быть непросто, и это не всегда стоит затраченных усилий. Если вы решили поддерживать отмену миграций для своего приложения, будьте последовательны и делайте это для каждого набора изменений, который необходимо отменить. В Liquibase отмена набора изменений выполняется путем добавления тега rollback
, который содержит изменения, необходимые для выполнения отката. Рассмотрим следующий пример:
<changeSet author="..."> <createTable tableName="PRODUCT"> <column name="ID" type="BIGINT"> <constraints primaryKey="true" primaryKeyName="PK_PRODUCT"/> </column> <column name="CODE" type="VARCHAR(50)"> <constraints nullable="false" unique="true" uniqueConstraintName="UC_PRODUCT_CODE"/> </column> </createTable> <rollback> <dropTable tableName="PRODUCT"/> </rollback> </changeSet>
Явный откат здесь излишен, потому что Liquibase будет выполнять те же действия отката. Liquibase может автоматически откатывать большинство поддерживаемых типов изменений, например, createTable
, addColumn
или createIndex
.
Исправление прошлого
Никто не идеален, и все мы совершаем ошибки. Некоторые из них могут быть обнаружены слишком поздно, когда испорченные изменения уже были применены. Давайте посмотрим, что можно сделать, чтобы спасти положение.
Вручную обновите базу данных
Это включает в себя возню с DATABASECHANGELOG
и вашей базой данных следующими способами:
- Если вы хотите исправить плохие наборы изменений и выполнить их снова:
- Удалите из
DATABASECHANGELOG
строки, соответствующие наборам изменений. - Удалите все побочные эффекты, которые были введены наборами изменений; например, восстановить таблицу, если она была удалена.
- Исправьте плохие наборы изменений.
- Запустите миграцию снова.
- Удалите из
- Если вы хотите исправить неверные наборы изменений, но не применяете их снова:
- Обновите
DATABASECHANGELOG
, установив для поляMD5SUM
значениеNULL
для тех строк, которые соответствуют неправильным наборам изменений. - Вручную исправить то, что было сделано неправильно в базе данных. Например, если был добавлен столбец с неправильным типом, выполните запрос для изменения его типа.
- Исправьте плохие наборы изменений.
- Запустите миграцию снова. Liquibase рассчитает новую контрольную сумму и сохранит ее в
MD5SUM
. Исправленные наборы изменений не будут запускаться снова.
- Обновите
Очевидно, что эти трюки легко проделывать во время разработки, но становится намного сложнее, если изменения применяются к нескольким базам данных.
Написать корректирующие наборы изменений
На практике такой подход обычно более приемлем. Вы можете задаться вопросом, почему бы просто не отредактировать исходный набор изменений? Правда в том, что это зависит от того, что нужно изменить. Liquibase вычисляет контрольную сумму для каждого набора изменений и отказывается применять новые изменения, если контрольная сумма является новой хотя бы для одного из ранее примененных наборов изменений. Это поведение можно настроить для каждого набора изменений, указав runOnChange="true"
. На контрольную сумму не влияет изменение предварительных условий или необязательных атрибутов набора изменений ( context
, runOnChange
и т. д.).
Теперь вам может быть интересно, как вы в конечном итоге исправляете наборы изменений с ошибками?
- Если вы хотите, чтобы эти изменения по-прежнему применялись к новым схемам, просто добавьте корректирующие наборы изменений. Например, если был добавлен столбец с неправильным типом, измените его тип в новом наборе изменений.
- Если вы хотите сделать вид, что этих плохих наборов изменений никогда не существовало, сделайте следующее:
- Удалите наборы изменений или добавьте атрибут
context
со значением, гарантирующим, что вы больше никогда не будете пытаться применять миграции с таким контекстом, например,context="graveyard-changesets-never-run"
. - Добавьте новые наборы изменений, которые либо вернут то, что было сделано неправильно, либо исправят это. Эти изменения следует применять только в том случае, если были применены плохие изменения. Этого можно добиться с помощью предварительных условий, таких как
changeSetExecuted
. Не забудьте добавить комментарий, объясняющий, почему вы это делаете. - Добавьте новые наборы изменений, которые правильно изменят схему.
- Удалите наборы изменений или добавьте атрибут
Как видите, исправить прошлое возможно, хотя и не всегда просто.
Смягчение растущих болей
По мере того, как ваше приложение становится старше, его журнал изменений также растет, накапливая каждое изменение схемы по пути. Это по дизайну, и в этом нет ничего плохого. Длинные журналы изменений можно сократить за счет регулярного сокращения миграций, например, после выпуска каждой версии продукта. В некоторых случаях это ускорит инициализацию новой схемы.
Сжатие не всегда тривиально и может вызвать регрессию, не принося особых преимуществ. Еще один отличный вариант — использовать исходную базу данных, чтобы избежать выполнения всех наборов изменений. Он хорошо подходит для тестовых сред, если вам нужно как можно быстрее подготовить базу данных, возможно, даже с некоторыми тестовыми данными. Вы можете думать об этом как о форме раздавливания наборов изменений: в какой-то момент (например, после выпуска другой версии) вы делаете дамп схемы. После восстановления дампа вы применяете миграции как обычно. Будут применены только новые изменения, потому что старые уже применялись до создания дампа; поэтому они были восстановлены из дампа.
Заключение
Мы намеренно избегали более глубокого погружения в возможности Liquibase, чтобы представить статью, которая будет короткой и по существу, сосредоточенной на развитии схем в целом. Надеюсь, стало понятно, какие преимущества и проблемы несет автоматизированное применение миграции схемы базы данных и насколько хорошо все это вписывается в культуру DevOps. Важно не превращать даже хорошие идеи в догму. Требования различаются, и наши решения, как инженеров баз данных, должны способствовать продвижению продукта, а не просто следовать рекомендациям кого-то в Интернете.