Миграции схемы базы данных с Liquibase

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

· 24 минуты на чтение
Миграции схемы базы данных с Liquibase

Большинство приложений, с которыми я сталкивался, используют SQL-базы данных для хранения информации. Такие приложения обычно разворачиваются на нескольких средах: среде разработки, предрелизной (пре-прод) и продакшене. При этом над одним проектом одновременно работает команда разработчиков, что создаёт дополнительную сложность при поддержании единой схемы базы данных.

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

Эту задачу решает система управления миграциями Liquibase. Это система контроля версий для базы данных, которая не зависит от конкретного типа базы. Liquibase поддерживает множество баз данных, таких как DB2, Apache Derby, MySQL, PostgreSQL, Oracle, Microsoft® SQL Server и другие.

🤔
Существуют и другие системы управления миграциями: Doctrine 2 Migrations, Rails AR Migrations, DBDeploy и другие. Однако некоторые из них зависят от платформы, а другие уступают Liquibase по функциональности.
Спонсор поста

История изменения статьи

10.10.24: Обновил используемые версии. Добавил раздел про управление view.
23.04.22: Обновил используемые версии. Также перенес сюда информацию из статьи про откат изменений.

Используемые версии

Liquibase: 4.29.2
Postgres: 15.1
SpringBoot: 3.3.4
MacOS: 14.7

Как работает Liquibase

Liquibase — это кроссплатформенное Java-приложение. Вы можете скачать его в виде JAR файла и запускать на Windows, Mac или Linux.

Все изменения для базы данных описываются в формате, понятном Liquibase, а затем он переводит их в SQL-запросы для выбранной базы данных. Это обеспечивает независимость от конкретной СУБД.

ChangeLog

Изменения структуры базы данных записываются в файлы под названием changeLog. Эти файлы могут быть в одном из следующих форматов: XML, YAML, JSON или SQL.

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

Хотя я не являюсь сторонником использования XML для конфигураций, в данном случае это наиболее удобный формат.

ChangeSet

ChangeSet — это аналог коммита в системах контроля версий, таких как Git.

Каждый changeSet имеет уникальный составной идентификатор, состоящий из полей id, author и filename. В одном changeSet может быть одно или несколько изменений базы данных. Однако хорошей практикой считается использование одного изменения на каждый changeSet.

<changeSet id="1" author="author">
    <createTable tableName="example_table">
        <column name="id" type="int"/>
        <column name="name" type="varchar(255)"/>
    </createTable>
</changeSet>

Пример changeSet для создания таблицы

При первом запуске Liquibase создаёт две специальные таблицы для контроля миграций:

  • databasechangelog – содержит список уже выполненных изменений схемы БД (changeSet).
  • databasechangelock – Используется для блокировки на время работы, чтобы гарантировать одновременную работу только одного экземпляра Liquibase.

Блокировка

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

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

В случае экстренной остановки программы в начале её работы может возникнуть проблема, при которой Liquibase успевает установить флаг в true, но не переключает его обратно на false.

Например, если вы запускаете Liquibase как исполняемый файл, приложение может "зависнуть".

В логах Spring Boot приложения это можно увидеть наглядно.

Заблокированная база данных

Чтобы исправить эту проблему, нужно вручную изменить значение поля locked в таблице databasechangelock на false.

Таблица databasechangelock

Контрольная сумма

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

После выполнения changeSet в таблицу databasechangelog записывается контрольная сумма (MD5-хэш) этого changeSet. Хэш рассчитывается на основе нормализованного содержимого XML-файла.

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

Пример вывода в лог сообщения об измененном changeSet
После выполнения changeSet его нельзя изменять. Любое изменение приведет к ошибке при следующем запуске Liquibase.

Организация скриптов

Прежде чем запускать Liquibase, нужно создать главный changeLog файл и добавить в него несколько changeSet, чтобы было что применять. Для примера создадим простую таблицу person.

Создайте папку db, а внутри неё папку changelog. В папке changelog создайте файл db.changelog-master.xml. Это будет ваш главный changeLog файл, который будет ссылаться на другие changeLog файлы.

Начальное содержимое главного файла changeLog:

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.6.xsd">

    // Здесь будут ссылки на другие changeLog файлы
    
</databaseChangeLog>

db.changelog-master.xml

Хорошей практикой считается создание множества отдельных changeLog файлов и их включение в основной файл с помощью тега <include>. Это позволяет избежать перегрузки одного большого файла, так как со временем количество changeSet значительно увеличится.

Пример организации

Допустим, у нас следующая версия приложения 1.0.0. В таком случае:

  1. Создайте в папке db/changelog новую папку v.1.0.0, которая будет содержать изменения для этой версии.
  2. В папке v.1.0.0 создайте файл changelog.xml, в котором будут находиться changeSet для этой версии.

Когда версия приложения изменится, вы создадите новую папку (например, v.2.0.0) и разместите в ней новый changeLog файл. Все эти версии файлов changeLog подключаются в главный файл db.changelog-master.xml.

Пример содержимого основного changeLog файла с подключением версии 1.0.0:

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.6.xsd">

    <include file="v.1.0.0/changelog.xml" relativeToChangelogFile="true"/>

</databaseChangeLog>

Тег <include> фактически вставляет содержимое указанного файла в место его вызова в основном changeLog. А атрибут relativeToChangelogFile="true" указывает, что путь до файла указывается относительно местоположения текущего changeLog файла.

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

Теперь создадим таблицу person. Для этого в папке v.1.0.0 создадим новый файл create-table.xml, который будет содержать миграцию:

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.6.xsd">

    <changeSet id="create-table-person" author="uPagge">
        <createTable tableName="person">
            <column name="id" type="int" autoIncrement="true">
                <constraints nullable="false" primaryKey="true"/>
            </column>
            <column name="first_name" type="varchar(64)"/>
        </createTable>
    </changeSet>

</databaseChangeLog>

Тег <createTable> задает имя новой таблицы через параметр tableName. Внутри этого тега перечислены колонки таблицы. Для каждой колонки необходимо указать её тип, который Liquibase автоматически приведет к нужному формату для используемой базы данных.

Особое внимание стоит уделить колонке id. Для неё задан автоинкремент, а также через тег <constraints> указаны следующие ограничения:

  • primaryKey="true" — колонка является первичным ключом.
  • nullable="false" — значения не могут быть NULL. Хотя это условие не обязательно при использовании первичного ключа, его отсутствие может вызывать ошибки при использовании базы данных H2 для тестов.

Теперь в файле changelog.xml добавим ссылку на новый changeLog файл с помощью тега <include>:

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.6.xsd">

    <changeSet id="add-tag-1.0.0" author="uPagge">
        <tagDatabase tag="v.1.0.0"/>
    </changeSet>

    <include file="create-table.xml" relativeToChangelogFile="true"/>

</databaseChangeLog>

Зачем нужен changeSet с тегом <tagDatabase>, будет объяснено в разделе про откат миграций.

Рандомный блок

Запускаем Liquibase

😺

Liquibase можно запускать несколькими способами, в зависимости от контекста:

  • Запуск с помощью исполняемого файла.
  • Запуск через Docker-образ.
  • Запуск при старте Spring Boot или Quarkus-сервиса.
💽
Перед запуском не забудьте создать базу данных.

Запуск исполняемого файла

Начнем с запуска с помощью исполняемого файла. Для этого скачайте Liquibase по кнопке ниже.

Распакуйте скачанный архив, в котором вы найдете папку liquibase-4.29.2. Она содержит несколько важных папок и файлов:

  • internal/lib – папка с зависимостями, необходимыми для работы liquibase, включая драйверы баз данных.
  • liquibase – бинарный файл для запуска в среде Linux/MacOS.
  • liquibase.bat – bat-файл для запуска в среде Windows.
  • examples – папка с примерами скриптов миграций (мы будем использовать свои скрипты).

После распаковки скопируйте папку db, созданную ранее, в папку liquibase-4.29.2.

Файл liquibase.properties

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

Создадим файл liquibase.properties в папке liquibase-4.29.2.

url=jdbc:postgresql://localhost:5432/liquibase_example
username=postgres
password=your_pass
changeLogFile=db/changelog/db.changelog-master.xml
liquibase.hub.mode=off

Описание параметров:

  • url — URL соединения с базой данных. В примере используется PostgreSQL, но нужно подставить URL вашей базы данных.
  • username — имя пользователя для подключения к базе данных.
  • password — пароль пользователя базы данных.
  • changeLogFile — путь к основному файлу changeLog, где описаны все миграции.
  • liquibase.hub.mode=off — отключает интеграцию с Liquibase Hub, если она не используется.

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

.
└── liquibase-4.29.2
    ├── db
    │   └── changelog
    │       ├── db.changelog-master.xml
    │       └── v.1.0.0
    │           ├── create-table.xml
    │           └── cumulative-changelog.xml
    ├── internal
    │   └── lib
    │       ├── liquibase-core.jar
    │       └── postgresql.jar
    ├── liquibase
    ├── liquibase.bat
    └── liquibase.properties

Для запуска миграции, находясь в папке liquibase-4.29.2, выполните следующую команду:

./liquibase update

В случае успеха в логе будет следующее сообщение:

Обновления в структуре и установке Liquibase

С выходом версии 4.11.0 произошли важные изменения в установке и организации файлов Liquibase. Вот ключевые моменты:

  • Основной JAR-файл: В папке internal/lib находится файл liquibase-core.jar, который отвечает за все основные функции работы Liquibase. Раньше он был рядом с исполняемым файлом в корне папки.
  • Изменение структуры библиотек: В новой версии библиотеки и драйверы БД, необходимые для работы Liquibase, перемещены из папки lib в новую директорию internal/lib.

Запуск с помощь докера

Запуск Liquibase через Docker — это простой и удобный способ выполнения скриптов миграции без необходимости установки Liquibase локально. Рассмотрим, как это сделать с помощью небольшого скрипта на Shell.

#!/bin/sh
docker run --name liquibase --network host -v YOUR_PATH_TO_CHANGELOG/db/changelog:/liquibase/db/changelog liquibase/liquibase:4.29.2 --defaultsFile=/liquibase/db/changelog/liquibase.properties update
docker rm liquibase

Не забудьте заменить YOUR_PATH_TO_CHANGELOG на фактический путь к вашей папке с файлами миграций. В этой папке должны находиться ваши changeLog файлы и файл liquibase.properties, который вы создавали ранее для запуска через исполняемый файл.

При запуске SpringBoot приложения

Для добавления поддержки Liquibase в Spring Boot, вам нужно указать несколько зависимостей в вашем проекте Maven, а также настроить подключение к базе данных в файле application.yml.

Добавьте следующие зависимости в файл pom.xml:

<dependency>
    <groupId>org.liquibase</groupId>
    <artifactId>liquibase-core</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!--Драйвер БД-->
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

В файле application.yml укажите параметры для подключения к базе данных и путь к файлу с миграциями:

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/liquibase_example
    username: postgres
    driver-class-name: org.postgresql.Driver
    password: pass
  liquibase:
    change-log: classpath:db/changelog/db.changelog-master.xml
  

Отключение генерации Hibernate

Если вы используете Hibernate, не забудьте отключить автоматическое создание схемы базы данных, чтобы избежать конфликтов с Liquibase. Для этого в application.yml можно добавить следующие строки:

spring:
  jpa:
    hibernate:
      ddl-auto: none

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

В итоге должна получится вот такая структура папок.

После настройки достаточно запустить Spring Boot приложение. Во время старта в логах появятся сообщения от Liquibase о применении миграций. В результате должна быть создана таблица person.

Проверяем результат

Во всех трех случаях мы должны были получить нашу таблицу person:

Давайте рассмотрим содержимое технической таблицы databasechangelog, которая хранит информацию о выполненных миграциях. Эта таблица выполняет функцию журнала изменений, аналогично git log, фиксируя все применённые changeSet.

Для удобства я отображу эту таблицу в виде дерева

В нашем случае таблица содержит две записи, так как мы выполнили два changeSet:

  1. Добавление тега с помощью команды tagDatabase.
  2. Создание таблицы person.

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

Спонсор поста 3

Скрипты миграций

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

Добавление колонки в таблицу

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

 <changeSet id="create-table-person" author="uPagge">
     <createTable tableName="person">
         <column name="id" type="int" autoIncrement="true">
             <constraints nullable="false" primaryKey="true"/>
         </column>
         <column name="first_name" type="varchar(64)"/>
         <column name="address" type="varchar(300)"/>
     </createTable>
 </changeSet>

При повторном запуске миграции это вызовет ошибку.

В Liquibase, если changeSet уже выполнен и записан в таблице databasechangelog, его изменение недопустимо — это похоже на невозможность изменить уже опубликованный коммит в Git.

В этом случае у вас три пути:

  • Создать новый changeSet с изменениями (рекомендуемый способ).
  • Откатить изменения с помощью команды Liquibase.
  • Удалить запись о выполненном changeSet из databasechangelog, но это небезопасно для сред, где миграция уже была выполнена, и лучше использовать этот метод только на локальной машине.

В нашем случае правильное решение — вернуть оригинальный changeSet в исходное состояние и создать новый для добавления колонки:

 <changeSet id="create-table-person" author="uPagge">
     <createTable tableName="person">
         <column name="id" type="int" autoIncrement="true">
             <constraints nullable="false" primaryKey="true"/>
         </column>
         <column name="name" type="varchar(64)"/>
     </createTable>
 </changeSet>

 <changeSet id="add-new-column-address" author="uPagge">
     <addColumn tableName="person">
         <column name="address" type="varchar(300)"/>
     </addColumn>
 </changeSet>

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

Связь с другой таблицей

Связи между таблицами — частое явление в реляционных базах данных. Для примера добавим таблицу book и свяжем её с таблицей person, указав внешний ключ:

<changeSet id="create-table-book" author="uPagge">
    <createTable tableName="book">
        <column name="id" type="int" autoIncrement="true">
            <constraints nullable="false" primaryKey="true"/>
        </column>
        <column name="name" type="varchar(64)"/>
        <column name="author_id" type="int">
            <constraints foreignKeyName="book_author_id_person_id" references="person(id)"/>
        </column>
    </createTable>
</changeSet>

В данном примере колонка author_id связана с колонкой id в таблице person. Важно указать уникальное имя для внешнего ключа — здесь используется формат book_author_id_person_id, который помогает лучше организовать имена ключей.

Также можно добавить каскадное удаление:

<constraints foreignKeyName="book_author_id_person_id" references="person(id)" deleteCascade="true"/>

Теперь, если автор будет удалён из таблицы person, соответствующая запись в таблице book также будет удалена.

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

<changeSet id="create-table-book" author="uPagge">
    <createTable tableName="book">
        <column name="id" type="int" autoIncrement="true">
            <constraints nullable="false" primaryKey="true"/>
        </column>
        <column name="name" type="varchar(64)"/>
        <column name="author_id" type="int"/>
    </createTable>

    <addForeignKeyConstraint baseTableName="book" baseColumnNames="author_id"
                             constraintName="book_author_id_person_id"
                             referencedTableName="person" referencedColumnNames="id" onUpdate="CASCADE"/>
</changeSet>

В данном случае при обновлении значения id в таблице person соответствующие значения в author_id таблицы book также будут обновлены.

Добавление индекса по внешнему ключу

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

Вот пример, как можно создать индекс по внешнему ключу в таблице book:

<changeSet id="create-index-author-id" author="uPagge">
    <createIndex indexName="idx_book_author_id" tableName="book">
        <column name="author_id"/>
    </createIndex>
</changeSet>
Индекс для внешнего ключа таблицы БД
При создании внешнего ключа в базе данных важно не забывать добавлять индекс на связанный столбец. Внешний ключ обеспечивает связь между двумя таблицами, гарантируя, что значения в одном столбце соответствуют значениям в другой таблице.

Создание представления

Несмотря на то, что большинство миграций в Liquibase вы, возможно, уже привыкли создавать с помощью XML, для создания представлений (views) зачастую лучше подходит использование SQL-запросов.

<changeSet id="create-view-book-author" author="uPagge">
    <createView viewName="author_and_book">
        SELECT p.id as person_id,
               p.first_name as person_first_name,
               b.id as book_id,
               b.name as book_name
        FROM person p
                 LEFT JOIN book b on p.id = b.author_id
    </createView>
</changeSet>

Изменение типа колонки

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

<changeSet id="modify-column-type" author="uPagge">
    <modifyDataType tableName="person" columnName="first_name" newDataType="varchar(128)"/>
</changeSet>

Удаление колонки

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

<changeSet id="drop-column" author="uPagge">
    <dropColumn tableName="person" columnName="address"/>
</changeSet>

Удаление таблицы

Если таблица больше не нужна, можно удалить её, чтобы уменьшить нагрузку на базу данных.

<changeSet id="drop-table" author="uPagge">
    <dropTable tableName="book"/>
</changeSet>

Добавление уникального ограничения

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

<changeSet id="add-unique-constraint" author="uPagge">
    <addUniqueConstraint columnNames="email" tableName="person" constraintName="unique_person_email"/>
</changeSet>

Заполнение таблицы начальными данными

Иногда требуется заполнить таблицу начальными данными (например, справочными данными) сразу после её создания.

<changeSet id="insert-initial-data" author="uPagge">
    <insert tableName="person">
        <column name="id" value="1"/>
        <column name="first_name" value="John"/>
        <column name="last_name" value="Doe"/>
    </insert>
</changeSet>

Откат изменений

Теперь, когда мы разобрались с запуском Liquibase и созданием базовых миграций, давайте рассмотрим откат изменений (rollback).

Спойлер: Судя по всему откат изменений это бесполезная возможность Liquibase, которой на практике в продакшен среде никто не пользуется. За 5 лет, я ни разу это не использовал.

На практике откат миграций используется редко, особенно в приложениях на Spring Boot, который не поддерживает откат изменений через стандартный механизм. Если нужно откатить миграцию, вам придётся воспользоваться либо исполняемым файлом Liquibase, либо Docker-образом. В этом разделе мы рассмотрим использование отката с помощью исполняемого файла.

Liquibase автоматически может откатить многие изменения, такие как: создание таблицы, добавление колонки, добавление внешнего ключа. Для некоторых changeSet необходимо написать скрипты отката.

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

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

Представим, что наш сервис обновляется до версии 2.0.0. Для этого создадим новую папку v.2.0.0 и добавим туда новый changeLog файл с изменениями:

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.6.xsd">

    <changeSet id="create-table-hero" author="uPagge">
        <createTable tableName="hero">
            <column name="id" type="int" autoIncrement="true">
                <constraints nullable="false" primaryKey="true"/>
            </column>
            <column name="book_id" type="int">
                <constraints nullable="false"/>
            </column>
            <column name="name" type="varchar(64)">
                <constraints nullable="false"/>
            </column>
        </createTable>
    </changeSet>

    <changeSet id="create-fk" author="uPagge">
        <addForeignKeyConstraint baseTableName="hero" baseColumnNames="book_id"
                                 constraintName="hero_book_id"
                                 referencedTableName="book" referencedColumnNames="id"
                                 deleteCascade="true"/>
    </changeSet>

</databaseChangeLog>

create-table-hero.xml

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.6.xsd">

    <changeSet id="add-tag-2.0.0" author="uPagge">
        <tagDatabase tag="v.2.0.0"/>
    </changeSet>

    <include file="create-table-hero.xml" relativeToChangelogFile="true"/>

</databaseChangeLog>

cumulative-changelog.xml

Не забываем добавить include на новый changelog.xml в наш главный changeLog:

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.6.xsd">

    <include file="v.1.0.0/changelog.xml" relativeToChangelogFile="true"/>
    <include file="v.2.0.0/changelog.xml" relativeToChangelogFile="true"/>

</databaseChangeLog>

db.changelog-master.xml

В итоге получаем простую схему БД

Посмотрим содержимое таблицу databasechangelog, которая сохраняет историю выполнений changeLog. Для удобства я скрыл пустые колонки.

Откат rollbackCount

Команда rollbackCount позволяет откатить определённое количество выполненных changeSet. Например, если вы хотите откатить только последнее изменение, достаточно указать rollbackCount 1:

./liquibase rollbackCount 1

В этом случае Liquibase удалит последнее выполненное изменение, например, если последним было добавление связи между таблицами hero и book, эта связь будет удалена. Кроме того, запись о выполнении этого changeSet будет удалена из таблицы databasechangelog, что выглядит так, как будто это изменение никогда не применялось.

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

Откат rollback tag

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

Теги создаются с помощью команды tagDatabase в каждом релизе changeLog.

Этот подход позволяет откатить все изменения, сделанные после указанного тега, включая саму запись о создании тега. Например, если нужно откатиться до версии v.2.0.0, команда будет выглядеть так:

./liquibase rollback v.2.0.0

В этом случае будут отменены все изменения, выполненные после тега v.2.0.0, включая:

  • Связь таблиц hero и book.
  • Создание таблицы hero.
  • Запись о создании тега v.2.0.0.
./liquibase rollback v.2.0.0

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

Таблицы hero также больше не существует:

Ручные rollBack

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

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

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

<changeSet id="insert-into" author="uPagge">
    <insert tableName="person">
        <column name="first_name" value="Александр"/>
    </insert>
    <insert tableName="book">
        <column name="name" value="Капитанская дочка"/>
        <column name="author_id" value="1"/>
    </insert>
    <insert tableName="hero">
        <column name="name" value="Савельич"/>
        <column name="book_id" value="1"/>
    </insert>
</changeSet>

Если вы попробуете выполнить откат этого changeSet с помощью команды rollbackCount 1, то увидите, что данные не были удалены.

Автоматически вставку данных не отменить. Для отмены, необходимо в changeSet добавить раздел rollback.

Добавим секцию rollback для удаления данных:

<changeSet id="insert-into" author="uPagge">
    <insert tableName="person">
        <column name="first_name" value="Александр"/>
    </insert>
    <insert tableName="book">
        <column name="name" value="Капитанская дочка"/>
        <column name="author_id" value="1"/>
    </insert>
    <insert tableName="hero">
        <column name="name" value="Савельич"/>
        <column name="book_id" value="1"/>
    </insert>

    <rollback>
        <delete tableName="hero">
            <where>name = 'Савельич'</where>
        </delete>
        <delete tableName="book">
            <where>title = 'Капитанская дочка'</where>
        </delete>
        <delete tableName="hero">
            <where>first_name = 'Александр'</where>
        </delete>
    </rollback>
</changeSet>
Хотя изменять уже выполненные changeSet запрещено, это правило не распространяется на секцию rollback. Вы можете добавлять или изменять её в уже выполненном changeSet.

Теперь, при выполнении команды rollbackCount 1, Liquibase удалит данные из таблиц в соответствии с условиями, указанными в секции rollback.

Вы также можете разделить откаты на несколько секций rollback, что обеспечит выполнение всех успешных откатов, даже если один из них завершится с ошибкой:

<rollback>
    <delete tableName="hero">
        <where>name = 'Савельич'</where>
    </delete>
</rollback>
<rollback>
    <delete tableName="book">
        <where>name = 'Капитанская дочка'</where>
    </delete>
</rollback>
<rollback>
    <delete tableName="person">
        <where>first_name = 'Александр'</where>
    </delete>
</rollback>

Если вам нужно больше гибкости, вы можете использовать чистый SQL в секции rollback:

<changeSet id="insert-into" author="uPagge">
    ... ... ... ... ...

    <rollback>
        <sql>
            DELETE FROM person
            WHERE first_name = 'Александр'
        </sql>
    </rollback>
</changeSet>

Проверка отката

Команда updateTestingRollback в Liquibase используется для проверки миграций с откатом. Она позволяет протестировать процесс наката изменений и их последующего отката. Команда выполняет следующие действия:

  • Накатывает все изменения, как если бы вы запустили команду update.
  • Откатывает все изменения, возвращая базу данных в исходное состояние.
  • Повторно применяет все изменения.

Пример использования команды:

./liquibase updateTestingRollback
Эта команда не гарантирует целостность данных. Её основная цель — убедиться, что миграции и откаты могут быть выполнены, но это не полноценная проверка консистентности базы данных.

Запрет на откат changeSet

Иногда нужно, чтобы конкретный changeSet не откатывался при выполнении команды rollback. Это можно сделать, добавив пустой тег <rollback/>. В этом случае Liquibase пропустит этот changeSet при откате.

<changeSet id="create-table-person" author="uPagge">
    <createTable tableName="person">
        <column name="id" type="int" autoIncrement="true">
            <constraints nullable="false" primaryKey="true"/>
        </column>
        <column name="first_name" type="varchar(64)"/>
    </createTable>
    <rollback/>
</changeSet>

В данном примере таблица person не будет удалена при выполнении команды отката. Все остальные изменения будут откатиться, а этот changeSet останется в базе данных.

💪 Советы от бывалых

Я познакомился с Liquibase в 2017 году во время стажировки, и с тех пор он стал для меня стандартным инструментом в рабочих и пет-проектах. Со временем я настолько привык к написанию changeSet, что они стали проще для меня, чем написание SQL-запросов.

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

Организация ChangeSet

База данных часто меняется в процессе разработки, особенно на ранних стадиях. В результате возникает множество changeSet. Чтобы ваш changeLog не превратился в бесконечный и сложно управляемый файл, лучше разделять изменения по версиям и логически организовывать их в отдельных файлах.

Мой подход к организации:

  • Для каждой новой версии приложения создаю отдельную папку в db/changelog. Например, если текущая версия — v.1.0.0, то для следующего релиза создаю папку v.1.1.0.
  • В каждой такой папке создаётся файл changelog.xml, который является локальным главным changeLog. В него включаются все changeSet для этой версии приложения.
  • Главный changeLog включает ссылки на все локальные changeLog файлов для каждой версии. Это помогает структурировать изменения и предотвращает превращение главного файла в слишком длинный документ.
На картинке немного устаревшая версия, но всю суть она передает.
Выпуск релиза

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

Правила именования

Корректное именование файлов и changeSet в Liquibase помогает избежать ошибок и конфликтов при разработке. Оно делает миграции легко читаемыми и позволяет понять, что было сделано и в какой последовательности.

Мой подход к именованию:

  • Каждый changeLog файл начинается с даты, за которой следует краткое описание изменений. Это позволяет быстро определить, какие изменения были сделаны и когда. Пример: 2020-03-08-create-tables.xml.
  • Идентификаторы changeSet также начинаются с даты. Это помогает избежать повторных ID и конфликтов. Пример: id="2020-03-08-create-table-person".

Изменение данных — плохая практика

Liquibase предназначен для управления структурой базы данных (таблицами, индексами, связями), а не для внесения изменений в данные.

Применение changeLog для модификации данных может привести к следующим проблемам:

  • Трудности с версионностью данных: Контроль изменений данных в разных окружениях сложен, так как данные могут различаться, что вызывает путаницу и потенциальные ошибки.
  • Проблемы с откатом: Откат структуры в Liquibase работает предсказуемо, а вот с данными всё сложнее. Например, откат вставки данных может привести к потере важных данных или нарушению целостности базы.

Управление VIEW в Liquibase

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

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

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

<?xml version="1.1" encoding="UTF-8"?>  
<databaseChangeLog  
       xmlns="http://www.liquibase.org/xml/ns/dbchangelog"  
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"  
       xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog  
       http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">  
  
    <include file="v.1.0.0/changelog.xml" relativeToChangelogFile="true"/>  
    
    <!-- VIEWS -->  
    <include file="views/changelog.xml" relativeToChangelogFile="true"/>  
  
</databaseChangeLog>

В changeLog файле для создания вьюх необходимо придерживаться следующего порядка:

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

Важным здесь является указывание следующий параметров changeSet-а:

  • runAlways="true" — указывает Liquibase всегда выполнять этот changeSet, даже если он был уже выполнен ранее. Это особенно полезно, когда необходимо поддерживать актуальность данных вьюхи после каждого изменения.
  • runOnChange="true" — указывает Liquibase игнорировать несовпадение контрольной суммы данного changeSet-а. Это важно, когда вносятся изменения в структуру вьюхи, и необходимо обновить её без возникновения ошибок из-за изменения контрольной суммы.

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

<?xml version="1.0" encoding="UTF-8"?>  
<databaseChangeLog  
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"  
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"  
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd  
        http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">  
  
    <changeSet id="drop-view" author="uPagge" runAlways="true" runOnChange="true">  
        <preConditions onFail="CONTINUE">  
            <viewExists viewName="view_name"/>  
        </preConditions>        
        <dropView viewName="view_name"/>  
    </changeSet>  
    <changeSet id="create-view" author="uPagge" runAlways="true" runOnChange="true">  
        <preConditions onFail="CONTINUE">  
            <not>                
	            <viewExists viewName="view_name"/>  
            </not>        
        </preConditions>        
        <createView viewName="view_name">  
           ...SQL FOR CREATE VIEW...   
        </createView>  
    </changeSet>  
</databaseChangeLog>

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

Управление VIEW в Liquibase
Проблема При создании VIEW в Liquibase возникают проблемы с её поддержкой, поскольку она часто изменяется, особенно когда меняется исходная таблица, используемая вьюхой…

Используйте XML

Хотя есть альтернативы XML, такие как Groovy, YAML или JSON, у XML есть несколько преимуществ, особенно при работе с Liquibase:

  • Автодополнение в IDE: XML поддерживается большинством IDE, которые могут предоставлять автодополнение, что упрощает написание changeSet.
  • Проверка схемы: XML позволяет автоматически проверять формальную корректность документа по схеме, что помогает избежать ошибок на этапе написания.

Используйте скрипты в формате XML, иногда используя SQL, если невозможно использовать XML.

Используйте remark

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

<changeSet id="2021-02-22-create-person" author="uPagge">
    <createTable tableName="person" remarks="Пользователи системы">
        <column name="id" type="int" autoIncrement="true" remarks="Идентификатор">
            <constraints nullable="false" primaryKey="true"/>
        </column>
        <column name="name" type="varchar(64)" remarks="Имя пользователя">
            <constraints nullable="false"/>
        </column>
        <column name="telegram" type="int" remarks="Идентификатор в телеграмм">
            <constraints unique="true"/>
        </column>
    </createTable>
</changeSet>

Резюмирую

В этой статье мы познакомились с основами работы Liquibase и узнали, как этот инструмент управляет миграциями базы данных. Мы рассмотрели различные способы запуска миграций: через исполняемые файлы, Docker и интеграцию со Spring Boot. Также обсудили основные типы changeSet для создания и модификации схемы базы данных, а также методы отката изменений, включая автоматический и ручной rollback.

Кроме того, мы затронули важные рекомендации по организации changeLog файлов и узнали, почему изменение данных через Liquibase — это плохая практика. В заключение, были приведены полезные советы, такие как использование правил именования, добавление комментариев с помощью remark и преимущества работы с XML.

Эти знания помогут вам более эффективно управлять миграциями базы данных и минимизировать ошибки в ваших проектах.

Struchkov Mark
Struchkov Mark
Задавайте вопросы, если что-то осталось не понятным👇