Пожалуй, почти каждый Spring разработчик сталкивается в своей практике с версионированием баз данных. На эту тему есть отличный доклад на Joker 2023 от Александра Шустанова, в котором спикер сравнивает 2 самых популярных инструмента для миграций БД: Flyway и Liquibase. Редакция Spring АйО приводит транскрипт доклада, для тех, у кого нет 45 минут для просмотра видео.

Что такое версионирование баз данных? 

Версионирование баз данных — это меры и процедуры для поддержания консистентности данных и схемы на протяжении всего жизненного цикла приложения. По мере изменения требований к приложению, меняются и требования к данным, и в этот момент необходимо прибегнуть к их версионированию. 

Существуют различные подходы к версионированию.  Это можно сделать вручную, подключившись к базе данных перед релизом выполнив, например, команду CREATE TABLE через консоль или специальный клиент. Мы можем также использовать автоматические средства генерации, например, Hibernate auto-ddl. Однако, эти подходы эффективно работают только в ограниченный период жизни продукта. На деле же большинство разработчиков используют такие инструменты как Flyway и Liquibase. Следует отметить, что данные инструменты предназначены в основном для версионирования реляционных баз данных, хотя многие принципы и подходы, описанные в данной статье, могут быть в той же мере применены и к нереляционным базам данных. 

Помимо того, что эти инструменты являются java библиотеками, каждый из них обладает своим интерфейсом командной строки (command line interface — CLI). Надо также отметить, что стандартный Spring Boot Starter поддерживает эти инструменты “из коробки”, то есть для того, чтобы они работали в Spring приложении, достаточно всего лишь включить зависимость Liquibase или Flyway, и Spring автоматически подключит и настроит нужный вам инструмент. 

Liquibase и Flyway оперируют понятием миграции. Миграция — это примитив версионирования баз данных, некая операция, которая переводит базу из одного состояния в другое.  

Версионирование баз данных с помощью специализированных средств дает определенные преимущества перед другими способами:  

  • Контроль (мы точно знаем, какие изменения у нас будут выполнены).

  • Консистентность (мы точно знаем, что у нас на разных средах будут выполнены одни и те же изменения — с некоторым оговорками, конечно).

  • Автоматизация (минимизируется влияние человеческого фактора, но при этом мы можем в процессе версионирования запускать те или иные миграции в соответствии с какими-то условиями).

  • Ну и, собственно, само версионирование — то есть тот факт, что мы можем отслеживать все произошедшие изменения, видеть их лог и даже в некоторых случаях откатывать эти изменения обратно, тоже с оговорками. 

Как рождаются и умирают миграции

Давайте спросим себя, в какой момент времени рождаются эти самые миграции? Миграции — это , по сути, некие скрипты, и по факту они являются программным кодом, и поэтому у них такое же время жизни и такой же цикл разработки, как у любого программного кода, то есть сначала мы их разрабатываем, потом их по-хорошему надо протестировать, а затем они переходят на непосредственное выполнение в какой-то среде, например, в облаке. Естественно, нам необходимо начинать писать эти миграции, когда перед нами встает задача что-то в приложении поменять.  

Это могут быть какие-то изменения в требованиях к функциональности приложения, исправление дефекта, устранение проблем с производительностью или определенная оптимизация, и во всех этих случаях разработчику надо написать соответствующую миграцию. Разработчик, скорее всего, сначала создаст новую ветку в системе контроля версий, что-то поменяет в коде, и, возможно, попытается запустить приложение. И это приложение у него не запустится, потому что база данных разошлась с тем, что ожидалось в приложении. И тогда разработчик начнет писать миграционный скрипт. И первый вопрос, который у него появится — это вопрос о местонахождении и правильном названии для файла скрипта. Поскольку для того, чтобы инструмент версионирования мог выполнить миграцию, файл со скриптом должен находиться в определенном месте.  

Подход, предлагаемый Flyway 

Flyway миграции должны помещаться в специальные файлы, которые именуются специальным образом. У них в имени файла прежде всего ставится префикс в виде буквы V, потом числовой номер, далее разделитель и текстовое описание: V12__description.sql 

Формат миграционного скрипта может быть либо SQL, либо Java; как правило, формат Java не используется, поэтому мы будем рассматривать примеры с форматом SQL.  У Flyway есть специальное свойство (property), которое называется flyway.locations. При разработке на Spring это свойство будет называться spring.flyway.locations. И в это свойство мы передаем местоположение, откуда Flyway должен забирать миграционные скрипты. 

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

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

Подход, предлагаемый Liquibase  

Liquibase работает на уровне так называемых changelog’ов (журналов изменений). У Liquibase тоже есть специальное свойство, которое называется liquibase.change-log или spring.liquibase.change-log. В этом свойстве и должен указываться файл типа changelog, который должен использовать Liquibase:  

spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.yaml 

Changelog — это файл определенного формата, причем в отличии от Flyway, Liquibase предоставляет нам больше опций: SQL, JSON, XML или YAML. Как правило, SQL практически не используется, разработчики в основном работают с JSON, XML или YAML. Файл типа changelog сам по себе является набором так называемых changeset’ов (наборов изменений). Собственно, changeset — это и есть одна миграция в терминах Liquibase. У каждого changeset обязательно должен быть какой-то идентификатор и автор — это его уникальная визитная карточка. Каждый changeset состоит из набора изменений (change), каждое из которых представляет собой операцию, которая так или иначе модифицирует базу данных. Эти изменения тоже могут описываться с помощью XML, YAML или JSON, но иногда их также пишут в SQL, и в этом случае они представляют собой обычный SQL скрипт.  

Помимо того, что changelog является набором каких-то changeset’ов, он еще может ссылаться на другие changelog’и, включать (include) их в себя и даже включать в себя целые директории и иерархии директорий с файлами типа changelog внутри. Такой подход дает нам гораздо больше вариативности в плане организации миграционных скриптов в нашем приложении. 

Схема сбора миграционных скриптов Liquibase
Схема сбора миграционных скриптов Liquibase
databaseChangeLog:
    - changeSet: 
        id: "create department" 
        author: "alexandr.shustanov" 
        changes: 
          - createTable: 
          catalogName: department 
          columns:
            - column: 
            name: address 
            type: varchar(255) 

В приведенном выше примере присутствует changelog, который имеет один changeset с идентификатором CREATE DEPARTMENT, создающим таблицу. В дальнейшем мы можем добавить в этот changelog набор директорий либо одну директорию updates, и директиву includeAll для директории updates. Теперь Liquibase будет знать, в какой папке искать changelog скрипты и обойдет ее рекурсивно, собрав скрипты из всех поддиректорий. 

databaseChangeLog: 
    - changeSet: 
        id: "create department" 
        author: "alexandr.shustanov" 
        changes: 
          - createTable: 
          catalogName: department 
          columns: 
            - column: 
            name: address 
            type: varchar(255) 
    - includeAll: 
        relativeToChangelogFile: true 
        path: updates 

Какие есть требования к именованию миграционных скриптов в Liquibase? На самом деле, полет фантазии в плане выбора называний никак не ограничен, как и в том, как именно разбивать изменения по файлам: можно собрать все изменения в один файл или заводить отдельный файл на каждое изменение; можно разбивать по релизам или по реализованной функциональности, а можно, например, разбивать по датам, как показано на рисунке.

Итак, мы дошли до стадии, когда первая версия нашего  changelog-скрипта написана и запускается. Возможно, мы даже залили его в ветку main в нашем git-репозитории, а также развернули наше приложение, включая данный скрипт, на каком-то тестовом сервере. На каждом из этих этапов может возникнуть некоторое количество проблем. Во-первых, может обнаружиться ошибка в самом changelog-скрипте. Во-вторых, у нас могут быть проблемы со слиянием коммитов, и их также надо будет разрешать. Требования к приложению также могут со временем поменяться. Как же мы будем подходить к решению всех этих проблем? 

Первое, что мы можем сделать в этой ситуации, это откорректировать наш миграционный скрипт и снова запустить приложение. При этом у нас появится исключение checksum mismatch. Почему такое происходит? Как Liquibase, так и Flyway имеют специальные системные таблицы, в которые они записывают лог всех выполненных миграций. В лог заносится время, когда была выполнена миграция, описание миграции, контрольная сумма, из-за которой как раз и возникает проблема. Если при следующем запуске у нас контрольная сумма поменялась, то оба этих инструмента сообщат нам об этом. Таким образом они заботятся о системности, о единообразии единожды написанного скрипта и постоянстве получаемых результатов. 

Что делать в данном случае? Самое простое решение - удалить локальную базу. Тогда при следующем запуске уже исправленного скрипта восстановится схема, и приложение заработает, но ранее накопленные тестовые данные будут потеряны, если у нас не осталось бэкапов.  

Как этого избежать? Можно попробовать вместо удаления данных вручную откатить изменения в базе данных от нашего неправильного скрипта. Кроме того, чтобы избежать повторного появления исключения checksum mismatch, необходимо удалить запись о прошедшей миграции из системной таблицы. Тогда Liquibase или Flyway, они уже не будут знать о том, что миграция была выполнена, и выполнят ее заново. Однако, иногда такой ручной откат сделать бывает тяжело, и проще сделать вручную изменения в базе, соответствующие результату работы уже исправленного скрипта. 

И в данном случае, чтобы все заработало, нам придется напрямую в таблице с логами править контрольную сумму, чтобы Flyway или Liquibase увидели, что она сошлась. 

Однако, и при выполнении этих действий мы не застрахованы от ошибок. Более того, невидимые расхождения в базе данных имеют свойство накапливаться даже вне зависимости от того, редактировали мы какой-то скрипт или не редактировали. Например, исходный код ожидает, что какая-нибудь колонка будет not null, а соответствующее ограничение (constraint) со стороны базы данных мы поставить забыли, или наоборот. Бывает, что мы сначала создали некий индекс, а потом этот индекс удалить забыли, и приложение на первый взгляд работает, но при этом у нас происходит падение производительности, или какие-то данные не вставляются, если это был индекс типа  unique.  

Такие проблемы, на самом деле, возникают довольно часто. И поэтому возникает вопрос: как таких проблем избежать?  

Автоматическая генерация скриптов 

Если не избежать, то хотя бы минимизировать фактор расхождения схемы базы данных и логической схемы в приложении позволяет автоматическая генерация миграционных скриптов. Есть несколько инструментов, которые позволяют это делать. Это, во-первых, сам Liquibase, который умеет это делать с помощью command line интерфейса diff-changelog. Кроме того, существует Hibernate плагин, который подключается через Maven или Gradle, а также плагин для IntelliJ IDEA - JPA Buddy, который наша компания полностью разрабатывала до недавнего времени.  

Комментарий автора

JPA Buddy нельзя легально приобрести на территории РФ, однако, вы можете получить функциональность автоматической генерации миграционных скриптов с помощью Amplicode. Подробнее можно ознакомиться с этой функциональностью на этом видео.

Эти инструменты работают с помощью так называемых снимков схемы (snapshots) и действуют похожим образом. Представим, что у нас есть какая-то база данных (DB 1), и мы с этой базы данных с помощью специальной команды Liquibase делаем snapshot. 

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

Далее мы берем другую базу данных, делаем снимок с нее, и Liquibase находит разницу между этими двумя снимками (diff), включая информацию о том, какие объекты присутствуют в одной базе данных, но отсутствуют в другой, где поменялся тип колонки, и т. д.  

На основе вот этого diff Liquibase сможет сгенерировать набор изменений (changes), которые базу данных 1 приведут к состоянию базы данных 2. Но возникает еще вопрос — а что такое эти база данных 1 и база данных 2? Для нашего случая можно представить, что база данных 1 — это база данных из нашей feature branch, а база данных 2 — это база данных из ветки main. И что касается получения снимка базы данных из feature branch, здесь следует упомянуть два существующих подхода к разработке приложений. Один называется DB first, а другой — Model first

DB first означает, что разработка идет от описания базы данных. То есть, база данных первична, и вся разработка осуществляется с точки зрения данных, которые мы берем из базы, как-то их меняем и кладем обратно. А подход Model first говорит нам, первична модель в нашем приложении.  

Соответственно, если мы применяем подход Model first, то для нас первична модель, и инструменты, которые следуют этому подходу, например, Hibernate Liquibase Plugin или JPA Buddy, делают снимки именно с модели, которая у нас описана в коде, через JPA сущности. И уже описания сущностей данные инструменты приводят к некоему описанию табличного представления. А далее между этими представлениями мы ищем различия и описываем изменения через миграции. Но давайте вспомним, что у нас находится в базе. 

Сравнение двух моделей 

Поскольку в базе мы периодически делаем какие-то ручные правки, переключаемся между ветками и осуществляем слияния коммитов, результат сравнения может оказаться не тем, который мы хотим получить. Идеальным вариантом было бы — сделать снимки с двух разных моделей, где одна модель — это модель из feature branch, в которой идет разработка, а другая — это модель из ветки main. И сравнения проводим уже на их основе. 

По факту, такие инструменты позволяют быстрее и точнее генерировать наши миграционные скрипты. Инструмент гораздо более внимателен, чем человек и не может “забыть” добавить индекс.  

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

Например, переименование колонки. Если мы переименуем поле в нашей какой-нибудь сущности клиента, например name поменяем на firstName, то все инструменты автоматической генерации будут это рассматривать не как переименование колонки, а как удаление одной колонки и создание другой. 

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

Тестирование миграций 

Flyway 

Миграции, как и любой программный код, необходимо тестировать. Еще несколько лет назад мы просто использовали любую in-memory базу данных, например, H2. Схема внутри этой базы данных накатывалась из модели, иногда писались какие-то простые скрипты, но про выполнение всех миграционных скриптов речь не шла. 

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

@SpringBootTest 
@AutoConfigureEmbeddedDatabase(
  type = AutoConfigureEmbeddedDatabase.DatabaseType.POSTGRES
) 
class FlywayMigrationTestingApplicationTests { 
  @Test 
  void testMigrations() { 
    //do nothing 
  } 
} 

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

А дальше нам необходимо поменять свойство spring.flyway.locations для наших тестов и запустить приложение. Код теста в этом случае примет такой вид: 

@SpringBootTest(properties = { 
  "spring.flyway.locations=classpath:db/migration, classpath:db/migration/sampledata"
}) 
@AutoConfigureEmbeddedDatabase(
  type = AutoConfigureEmbeddedDatabase.DatabaseType.POSTGRES
)
class FlywayMigrationTestingApplicationTests { 
  @Test 
  void testMigrations() { 
    //do nothing 
  } 
} 

При этом будут запускаться дополнительные тестовые миграции, которые будут заполнять тестовые данные. При этом мы увидим следующий лог: 

Migrating schema "public" to version "1 - create customer table" 

Migrating schema "public" to version "1.1 - create sample customers" 

Migrating schema "public" to version "2 - split name to first and last" 

Migrating schema "public" to version "2.1 - add another customers" 

Liquibase 

А как обстоят дела в Liquibase? Давайте рассмотрим простейший скрипт: 

<changeSet id="create-customer" author="alexander.shustanov">
  <createSequence incrementBy="1" sequenceName="customer_seq" startValue="1"/> 
  <createTable tableName="customer">
    <column name="id" type="BIGINT"/> 
    <column name="first_name" type="VARCHAR(255)"/> 
    <column name="last_name" type="VARCHAR(255)"/> 
  </createTable> 
</changeSet> 

Скрипт создает таблицу Customer. Сначала мы создаем для нее sequence, из которой будет генерироваться идентификатор id. Команда Liquibase, рекомендует рядом с этим changeset создавать еще один и указывать у него специальный атрибут - контекст (context). 

<changeSet id="create-customer::sample-data" author="alexander.shustanov"
           context="test">
  <insert tableName="customer">
    <column name="id" valueComputed="nextval('customer_seq')"/> 
    <column name="first_name">John</column> 
    <column name="last_name">Doe</column> 
  </insert> 
</changeSet> 

Здесь написано context=”test”, но на самом деле вместо test можно написать другое название, о чем мы поговорим позже. Главное на данный момент то, что мы создали еще одну миграцию и явно указали, что это тестовые данные, и в рамках этой миграции мы эти тестовые данные вставляем в таблицу. При заполнении таблицы customer, мы берем значение идентификатора из sequence, а поля firstName и lastName заполняем конкретными значениями. Это отлично работает для случая с одной записью, но если мы захотим ввести, например, 50 записей, то этот способ не очень удобен.  

Как обойти эту проблему? У Liquibase есть инструмент, который называется loadData. Это change специального формата, в рамках которого мы можем перегнать данные из csv файла в указанную таблицу.  

<changeSet id="create-customer::sample-data" author="alexander.shustanov" 
           context="test"> 
  <loadData tableName="customer" file="test-customers.csv" 
            relativeToChangelogFile="true"/> 
</changeSet> 

При этом исходный файл у нас выглядит примерно вот так:  

Как вы можете видеть, для идентификатора мы вписываем вычисляемое выражение, в данном случае на PostgreSQL, которое инкрементирует sequence, а для firstName и lastName значения вводятся в явном виде. Таким образом мы можем очень существенно сократить наш код.  

Управляем запуском миграций: Context и Labels 

Настало время поговорить про тот самый контекст, которым мы помечали нашу тестовую миграцию. Контексты и лейблы (labels) — это специальная функциональность Liquibase, которая позволяет нам управлять запусками миграции, явно указывая, какие миграции и где мы хотим запускать.  

Как это работает? У каждого changeset мы можем указать контекст в виде логического выражения. В дальнейшем при запуске миграции, если мы это делаем через командную строку, мы можем перечислить нужные нам контексты через запятую, как показано на рисунке ниже: 

Если мы не используем командную строку для запуска приложения, можно в качестве альтернативы добавить специальное свойство, например,  liquibase.context, в котором также перечислить контексты через запятую. Соответственно, в зависимости от того, что мы туда введем, у нас по-разному будет вычисляться указанное в атрибуте context логическое выражение, и какие-то changeset’ы будут выполнены, а какие-то нет. Соответственно, если мы хотим запустить тестовые миграции, то мы просто добавим дополнительные контексты с другими названиями.  

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

Для чего нужно два почти одинаковых инструмента, которые выполняют одну и ту же задачу, но с разных сторон? команда Liquibase на этот вопрос отвечает, что контексты нужно использовать, когда вся мощь управления логикой подключения или выключения тех или иных миграций должна лежать на стороне разработчика. А лейблы используются если управление находится на стороне деплоймента.  

Управление запуском миграций во Flyway

Что нам здесь может предложить Flyway? Flyway, на самом деле, нам не может предложить ничего нового, кроме как разложить наши скрипты по разным папкам, и тогда, в зависимости от того, что мы передадим в свойстве spring.flyway.location, у нас будут запускаться те или иные скрипты. 

Zero downtime deployment

Теперь мы перейдем к теме деплоймента. Это тоже достаточно обширная тема, но в данной статье мы затронем лишь наиболее интересные моменты. Одним из таких моментов является zero downtime deployment, что можно перевести как деплоймент с нулевым простоем.  

Сейчас это особенно актуально в связи с тем, в настоящее время в приложениях часто используются микросервисы, и для их развертывания используется так называемый подход Blue-Green (синий-зеленый). Допустим, у нас есть база данных, с ней работают какие-то микросервисы, к которым пользователи или даже другой сервис обращаются через балансировщик загрузки (load balancer). И мы хотим задеплоить новое обновление для этой системы. Обычно обновляют не все сервисы сразу, чтобы избежать простоя, а только часть. Это и называется состояние Green. В этом случае в течение определенного времени у нас будет одновременно работать старая версия (Blue), и одновременно с ней работает новая версия (Green).  

Но тут возникает проблема: эти две версии должны работать с одной и той же базой данных. Как же это выполняется? 

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

 

Эта база данных полностью соответствует потребностям старых сервисам версии Blue и отлично с ними работает. И тут мы хотим запустить часть новых сервисов версии Green.

В этом случае оказывается, что могут возникать какие-то конфликты между новыми сервисами и старой базой данных, которая может быть с ними несовместима. Чтобы этого не случилось, происходит первая фаза миграции. Первая фаза миграции — это миграция, которая переводит базу данных в некое промежуточное состояние, которое работает и с Green, и с Blue.  

Это промежуточное состояние, как правило, в чем-то будет избыточным, но самое важное в нем то, что и Blue, и Green могут работать с базой данных одновременно.

Далее мы удостоверяемся в том, что все работает, как нужно, и понимаем, что Blue мы можем уже отключить. Соответственно, мы отключаем Blue и хотим дальше от этих вот избыточных данных избавиться. На этом этапе мы запускаем вторую фазу миграции. 

Это и есть двухфазовая миграция, которая позволяет нам обновлять систему не полностью, а по частям. И Liquibase может помочь нам в этом с помощью уже упомянутых контекстов.  

<changeSet id="delete-obsolete-columns" author="alexander.shustanov" 
           context="phase2"> 
... 
</changeSet> 

Иначе говоря, мы можем создать контекст, который называется phase2, и на этапе CI, когда у нас произошло обновление всех наших сервисов уже в состоянии Green, можем нажать на кнопку и запустить Liquibase update с контекстом phase2, таким образом накатив изменения второго этапа на базу данных.  

Rollback 

Liquibase 

Следующий интересный инструмент — это Rollback (откат изменений). Случается, что мы уже задеплоили Green сервисы и потом поняли, что все-таки где-то был баг, и нам нужно вернуться обратно. Мы хотим состояние нашей базы откатить. И у Liquibase есть на этот случай коробочное решение, которое так и называется: Rollback.  

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

Rollback автоматически работает для некоторого вида изменений. То есть, например, если мы создаем новую таблицу, то очень логично, что откатить ее можно с помощью операции DROP. Если мы, например, переименовываем колонку, то мы ее так же можем переименовать обратно. Но если у нас есть изменения, связанные с удалением данных или колонок, либо со вставкой данных, то автоматический rollback работать уже не будет, его придется писать самому. И Liquibase позволяет сделать это примерно следующим образом:

<changeSet id="insert_default_admin" author="alexandr.shustanov"> 
  <insert tableName="users"> 
    <column name="username">admin</column> 
    <column name="password">{noop}admin</column> 
  </insert> 
  <rollback> 
    <delete tableName="users"> 
      <where>username = admin</where> 
    </delete> 
  </rollback> 
</changeSet> 

Следует отметить, автоматический rollback работает только для XML, YAML и JSON, но не для SQL . 

В приведенном выше примере есть у нас используется миграция, которая создает нам дефолтного пользователя admin/admin. И мы к этой миграции сразу же пишем rollback, который этого пользователя удалит обратно.

Flyway 

У Flyway тоже есть похожая функциональность, которая называется Undo, но так как Flyway работает с SQL, автоматических откатов в нем не существует. Вместо этого нам нужно будет написать еще один миграционный скрипт — обратный (Undo) скрипт. Отличается он от оригинального только тем, префиксом в его имени вместо буквы V будет буква U. 

К сожалению, эта функциональность доступна только в платной версии.  

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

Liquibase Dry Run

Liquibase dry run — это еще одна интересная функциональность, доступная в Liquibase. Скорее всего, вы не будете ей пользоваться очень часто. В некоторых компаниях миграционные скрипты на базу данных накатывает специально обученный человек, который называется Database Administrator. И если вы принесете ему Flyway миграционный скрипт, написанный на SQL, то этот язык будет для него знаком, он его прочитает, поймет, что там происходит, заставит нас исправить возможные проблемы, например, с безопасностью, но в конечном счете он его выполнит.  

В случае Liquibase, если мы принесем ему, например, что-то на XML, то, скорее всего, он вообще откажется на это смотреть, к тому же он даже не будет знать, как это запускать. Ему нужен скрипт на SQL. И в Liquibase есть для этого специальное решение, которое называется dry run или update-sql. 

liquibase update-sql --changelog-file=changelog.xml --jdbcConnection=... 

Мы можем указать наш changelog , указать подключение к базе данных и выполнить команду update-sql из приведенного выше примера, и Liquibase сделает так называемый сухой прогон: сгенерирует SQL скрипты для наших миграций, но выполнять их не будет. И потом мы эти SQL скрипты сможем отнести нашему Database администратору.  

Продуктовые кейсы 

После рассмотрения таких этапов, как разработка, тестирование и внедрение, давайте теперь посмотрим на некоторые интересные случаи использования из реальной рабочей практики моих коллег, которые, как правило, связаны с программными продуктами. И первый такой случай связан с клиентом клиент, который очень долго не обновлял свой программный продукт.  

Коробочный продукт 

Представьте себе, что вы поставляете какое-то коробочное решение, и оно работает на серверах у некоего вашего клиента. У вас выходят новые версии, а клиент их до поры, до времени игнорирует, а потом в какой-то момент говорит: “Я хочу обновиться на версию 2.48 с версии 1.26.” Проблема заключается в том, что у нас каждый переход между версиями предполагает выполнение определенного количества миграций.  

Когда мои коллеги сняли дамп базы клиента и попробовали в тестовом режиме проделать требуемое обновление (а никакого Blue-Green деплоймента там не было), то оказалось, что весь процесс занимает 8 часов. 

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

Этот общий миграционный скрипт пропускал изменения, которые уже становились неактуальными, ведь в процессе эволюции продукта мы создаем какие-то новые колонки, какие-то, наоборот, удаляем, и в результате какие-то данные у нас массово могут удаляться, и, соответственно, проделывать все эти операции полностью уже не нужно. И когда был написан такой скрипт, необходимый для перехода с одной версии на другую с пропуском всех промежуточных версий, выяснилось, что на его выполнение нужно всего 15 минут. 

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

Поддержка нескольких БД 

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

<changeSet id="create-user-postgres" author="alexander.shustanov" 
           dbms="postgresql">
  <createTable tableName="users">
    <column name="name" type="VARCHAR(255)"/>
    <column name="created_at" type="timestamp with time zone"/> 
  </createTable> 
</changeSet> 
<changeSet id="create-user-mssql" author="alexander.shustanov" 
           dbms="mssql"> 
  <createTable tableName="users"> 
    <column name="name" type="VARCHAR(255)"/> 
    <column name="created_at" type="datetime offset"/>
  </createTable> 
</changeSet> 

В данном случае у нас разные эти типы timestamp с временной зоной у PostgreSQL и у MSSQL, соответственно, они по-разному называются в скриптах, поэтому приходится писать два разных скрипта. Но на самом деле в Liquibase это можно делать и немножко по-другому, объявив специальные property. У каждого property будет свой параметр dbms, а значения будут отличаться.  

<property name="datetime_zoned_type" 
          value="timestamp with time zone" 
          dbms="postgresql"/> 

<property name="datetime_zoned_type" 
          value="datetime offset" 
          dbms="mssql" />  

<changeSet id="create-user" author="alexander.shustanov"> 
  <createTable tableName="users"> 
    <column name="name" type="VARCHAR(255)"/> 
    <column name="created_at" type="${datetime_zoned_type}"/> 
  </createTable> 
</changeSet> 

В результате, в одной базе данных будет взято одно property, а в другой базе данных будет взято другое property. Такой способ, конечно, не будет работать во всех случаях, потому что где-то мы будем функции по-другому создавать, используя триггеры и т. д., так что все-таки придется что-то писать вручную и дублировать скрипты, но вот для таких простых случаев, как разные типы, это точно решается через property.  

Flyway нам в данном случае предлагает использовать все то же многострадальное свойство spring.flyway.location, разбивать наши скрипты по разным папкам в зависимости от того, в какой базе данных они будут выполняться; при этом, если у нас еще есть какая-то разбивка по релизам или добавляются тестовые данные, то в итоге все выглядит довольно громоздко: 

Repeatable скрипты 

Liquibase

Иногда возникает необходимость какие-то скрипты запускать по несколько раз. Например, PostgreSQL может в некоторых случаях удалять views, если меняются некоторые колонки в таблице. В данном случае скрипт создания view нам в идеале следует выполнять каждый раз:  

<changeSet id="create-person-view" author="alexander.shustanov" 
           runAlways="true"> 
  <createView viewName="...">...</createView> 
</changeSet> 

Кроме того, иногда мы хотим в одном месте описать какую-то хранимую процедуру, которую мы далее в коде вызываем, и эта хранимая процедура у нас время от времени меняется, но мы не хотим вот каждый раз отражать это в логе хранимых процедур. В этом случае мы можем в одном и том же месте менять миграционный скрипт, при этом добавив в него оператор (атрибут) runOnChange.  

<changeSet id="create-serialization-procedure" author="alexander.shustanov" 
           runOnChange="true"> 
  <sql>...</sql> 
</changeSet> 

В данном случае Liquibase будет заново выполнять скрипт, если увидит, что поменялась его контрольная сумма. Для такого случая идеально подходит функциональность создания хранимой процедуры. 

Flyway

У Flyway тоже существует подобная функциональность, и она там тоже называется repeatable скрипты. Эти скрипты отличаются от остальных префиксом R в имени. Такой скрипт будет выполняться каждый раз при его изменении.  

Пример имени repeatable скрипта: R__create_person_view.sql 

Миграции на Java 

Flyway

В начале статьи мы упоминали, что миграции теоретически могут быть написаны на Java. Пример для Flyway приводится ниже: 

package db.migration;  

public class V2__JavaMigration extends BaseJavaMigration { 
  @Override 
  public void migrate(Context context) throws Exception {
    try (PreparedStatement statement = Context 
         .getConnection() 
         .prepareStatement("INSERT INTO test_user (name) VALUES ('Obelix')")) { 
      statement.execute(); 
    } 
  } 
} 

Здесь мы должны в определенный пакет, в соответствии со свойством spring.flyway.location создать Java класс, назвать его определенным образом, и этот класс фактически имеет один метод, который принимает JDBC connection. Дальше можем через этот JDBC connection загрузить какие-то данные из базы, обработать их определенным образом и снова положить в базу.  

Liquibase

А Liquibase здесь нам предлагает что-то поинтереснее. Мы можем описывать custom changes в обобщенном виде, которые мы в будущем можем переиспользовать. В нашем примере custom change выполняет операцию XOR по какой-то колонке. Мы ему должны передать таблицу, колонку и XOR ключ.  

public class XorColumn implements CustomTaskChange { 
  private String table; 
  private String column;
  private String xorKey;
  
  @Override 
  public ValidationErrors validate(Database database) {
    ValidationErrors validationErrors = new ValidationErrors("xorColumn"); 
    validationErrors.checkRequiredField("table", table); 
    validationErrors.checkRequiredField("column", column); 
    validationErrors.checkRequiredField("xorKey", xorKey); 
    return validationErrors; 
  }
  
  @Override 
  public void execute(Database database) throws CustomChangeException {
    // todo 
  } 
... 
} 

Для краткости мы исключаем из примера геттеры и сеттеры. Далее мы можем провалидировать, что нужные данные попали в change, и дальше этот change передается на выполнение.  

И, соответственно, вот так будет выглядеть вызов вот этого change внутри нашего миграционного скрипта: 

<changeSet id="obfuscate-salary" author="alexandr.shustanov"> 
  <customChange class="liquibasemigrationtesting.XorColumn"> 
    <param name="table" value="customer"/> 
    <param name="column" value="salary"/> 
    <param name="xorKey" value="cSwqZGJ3ZWBOQmcvVClVQWZJQ2xmNA=="/> 
  </customChange> 
</changeSet> 

Выводы 

Здесь мы подходим к концу. Мы обсудили в основном два инструмента, Liquibase и Flyway, но больше фокусировались на Liquibase, у которого гораздо больше возможностей, он очень гибкий, он на самом деле очень расширяемый. Пример с custom changes — это лишь вершина айсберга его расширяемости. Если мы посмотрим на Hibernate JPA плагин, JPA Buddy или Amplicode, то поймем, что они его расширяют гораздо сильнее. Liquibase изначально построен на плагинной системе с помощью технологии Java ServiceLoader, который предлагает огромное количество возможностей.  

Но, с другой стороны, считается, что Flyway более понятный, что разработчики больше любят писать на SQL, хотя лично я не могу с этим согласиться. Он, как минимум, гораздо более понятный для database администраторов. Другим его преимуществом является то, что для его запуска вообще ничего не нужно настраивать: кладем в единственную директорию единственный скрипт, и все работает. 

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Ждем всех, присоединяйтесь!

Комментарии (9)


  1. oragraf
    11.07.2024 13:11
    +1

    У этих тулзовин есть недостаток, который может кто-то и не сочтет недостатком. Нет возможности при выполнении changeset выбрать подключение к бд. Если бд содержит несколько схем/пользователей и накатывать надо согласованно от владельцев объектов - тогда ой. Есть костыль - воспользоваться пользователем с правами dba(create any/alter any). Но это дыра, которую надо грамотно закрыть суметь архитектору


    1. dph
      11.07.2024 13:11

      Угу. При этом иметь dba с доступом ко всем схемам - это почти всегда плохая идея.
      Но обеспечение безопасности работы с СУБД - тем очень отдельной статьи, там очень много неприятностей (


  1. dph
    11.07.2024 13:11
    +3

    К сожалению, тема миграции для БД раскрыта очень поверхностно. Ни один из указанных инструментов не позволяет мигрировать данные (а это необходимо для обеспечения совместимости при выкладки без останова), нет описания безопасных миграций (а очень немногие из реальных операций на СУБД безопасны), нет связи с поведением в системе (а это также нужно для обновления без останова).
    В серьезных проектах использование liquibase крайне неудобно, flyway с миграциями на java может использоваться, если добавить довольно много собственного кода. Но, обычно, нужно делать собственное решение.


    1. oragraf
      11.07.2024 13:11

      Ликвибейз позволяет проводить любые манипуляции с данными. Но! Для этого надо писать правильные sql-скрипты. Можно смешивать скрипты на sql и xml. Но чем серьезнее и увесистее бд, тем грамотнее должен быть разработчик.


      1. dph
        11.07.2024 13:11
        +1

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

        А уж как сложно на liquibase работать с миграций данных в json/jsonb полей... Увы, в современных подходах работы с СУБД миграция - это просто код на языке высокого уровня.


        1. oragraf
          11.07.2024 13:11

          Почему я не могу одним dml обновить миллион записей? бд не знает, кто к ней стучится с того конца - liquibase или sqlplus. Про миллион я даже и задумуваться не буду. Про логику никто и не спорит. Мой коммент выше про грамотного разработчика именно поэтому


          1. istreb
            11.07.2024 13:11
            +2

            Смотря что за данные, потому что блокировки, нехватка места под rollback сегмент и прочее.


          1. dph
            11.07.2024 13:11

            Э, а что такое "грамотный разработчик"? Вот обновлять одним update t set c1=c2 на таблице больше 100k записей - неграмотно, но многие ли это знают?
            Даже alter table add column часто опасно вызывать - но это вообще мало кто знает.


  1. MishanyaT
    11.07.2024 13:11

    Нужна помощь в тестировании приложения на Android - самым активным бесплатная подписка на год! Написал в посте