DBスキーマのバージョン管理、皆さんはどうしていますか。Spring Bootだと標準サポートされているFlywayを選ぶケースが多いですが、ロールバックや記法の柔軟さで Liquibase を選びたい場面もありますよね。

この記事ではSpring BootにLiquibaseを組み込み、changelogの書き方からロールバック実行までを一通り見ていきます。Flywayとの違いもあわせて整理するので、選定の参考にしてください。

LiquibaseとFlywayの違い

まず採用判断のために、両者の特徴を比較します。

観点LiquibaseFlyway
記法XML/YAML/JSON/SQLSQL(OSS)/Java
ロールバックネイティブ対応Undo機能はTeams/Enterprise版のみ(OSS版はSQLでの手動運用)
学習コストやや高い低い
DB対応非常に広い主要DBに対応

SQLだけで完結させたいならFlyway、ロールバックや複雑な変更を抽象化したいならLiquibaseが向いています。Flywayの詳細は Spring BootでFlywayを使ったDBマイグレーション を参照してください。

依存追加と初期設定

Mavenであれば liquibase-core を追加するだけです。

<dependency>
    <groupId>org.liquibase</groupId>
    <artifactId>liquibase-core</artifactId>
</dependency>

application.yml 側ではchangelogのパスやcontextを指定します。

spring:
  liquibase:
    enabled: true
    change-log: classpath:db/changelog/db.changelog-master.xml
    contexts: dev

change-log を指定しない場合、Spring Bootは classpath:/db/changelog/db.changelog-master.{yaml,yml,json,xml,sql} を自動検出します。XMLで書く場合は明示しておくと安心ですね。

自動マイグレーションは LiquibaseAutoConfiguration の仕業です。アプリ起動時にLiquibaseが DATABASECHANGELOGDATABASECHANGELOGLOCK テーブルを作成し、未適用のchangeSetを順番に流してくれます。無効化したいときは spring.liquibase.enabled=false を設定します。

master changelogとファイル分割

changelogが肥大化すると見通しが悪くなるので、リリース単位や機能単位でファイルを分けます。masterから includeincludeAll で読み込む構成が定番です。XSDは使うLiquibaseのバージョンに合わせるか、dbchangelog-latest.xsd を指定すると追従が楽です。

<?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">

    <include file="db/changelog/v1.0/001-create-users.xml"/>
    <include file="db/changelog/v1.0/002-create-orders.xml"/>
    <includeAll path="db/changelog/v1.1/"/>
</databaseChangeLog>

includeAll はファイル名のアルファベット順に取り込まれるので、001-002- のようなプレフィックスで順序を制御しましょう。

XML形式のchangeSet基本構文

changeSetは idauthor の組み合わせで一意に管理されます。一度適用されたchangeSetは内容を後から変更しないのが鉄則です。

<changeSet id="001-create-users" author="mizucoffee">
    <createTable tableName="users">
        <column name="id" type="BIGINT" autoIncrement="true">
            <constraints primaryKey="true" nullable="false"/>
        </column>
        <column name="email" type="VARCHAR(255)">
            <constraints nullable="false" unique="true"/>
        </column>
        <column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
    </createTable>
</changeSet>

外部キーは参照先テーブルが既に存在している必要があるので、別changeSetに分けます。先に orders テーブルを作る前提です。

<changeSet id="002-create-orders" author="mizucoffee">
    <createTable tableName="orders">
        <column name="id" type="BIGINT" autoIncrement="true">
            <constraints primaryKey="true" nullable="false"/>
        </column>
        <column name="user_id" type="BIGINT">
            <constraints nullable="false"/>
        </column>
    </createTable>
</changeSet>

<changeSet id="003-fk-orders-users" author="mizucoffee">
    <addForeignKeyConstraint
        baseTableName="orders" baseColumnNames="user_id"
        referencedTableName="users" referencedColumnNames="id"
        constraintName="fk_orders_user"/>
</changeSet>

constraints 要素で nullableuniqueprimaryKey を指定できます。エンティティ設計の話は JPAエンティティのリレーションマッピング も参考になります。

YAML形式とSQL形式

XMLが冗長に感じるならYAMLでも書けます。

databaseChangeLog:
  - changeSet:
      id: 001-create-users
      author: mizucoffee
      changes:
        - createTable:
            tableName: users
            columns:
              - column:
                  name: id
                  type: BIGINT
                  autoIncrement: true
                  constraints:
                    primaryKey: true
                    nullable: false
              - column:
                  name: email
                  type: VARCHAR(255)
                  constraints:
                    nullable: false
                    unique: true

SQLが既にある、あるいはチームがSQL慣れしているならformatted SQLも便利です。

--liquibase formatted sql

--changeset mizucoffee:001-create-users
CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
--rollback DROP TABLE users;

形式は混在させず、プロジェクト内で統一しておくとレビューが楽になります。

preConditionsとcontextで条件付き実行

環境差に応じて適用を切り替えたい時は preConditionscontext を使います。

<changeSet id="010-seed-dev-data" author="mizucoffee" context="dev">
    <preConditions onFail="MARK_RAN">
        <tableExists tableName="users"/>
        <sqlCheck expectedResult="0">SELECT COUNT(*) FROM users</sqlCheck>
    </preConditions>
    <insert tableName="users">
        <column name="email" value="[email protected]"/>
    </insert>
</changeSet>

context="dev"application.ymlcontexts と照合され、開発環境でのみ実行されます。なお contexts を未指定で起動するとフィルタは無効になり、全changeSetが対象になる点に注意です。

contexts は「どの環境で流すか」、labels は「どの目的タグの変更を流すか」という観点の違いがあります。環境分離はcontexts、機能フラグ的に変更を束ねたい場合はlabels、と使い分けるとすっきりします。

ロールバックの書き方

Liquibase最大の魅力がロールバックです。createTable のような可逆な変更は自動で逆操作を導出してくれますが、データ変更や sql タグでは手動で書きます。

<changeSet id="020-add-status-column" author="mizucoffee">
    <addColumn tableName="users">
        <column name="status" type="VARCHAR(20)" defaultValue="ACTIVE"/>
    </addColumn>
    <rollback>
        <dropColumn tableName="users" columnName="status"/>
    </rollback>
</changeSet>

<changeSet id="021-tag-v1" author="mizucoffee">
    <tagDatabase tag="v1.0.0"/>
</changeSet>

tagDatabase でチェックポイントを打っておけば、後からそのタグまで戻せます。Maven pluginから実行する例はこちらです。

# 直近1件を戻す
mvn liquibase:rollback -Dliquibase.rollbackCount=1

# 特定タグまで戻す
mvn liquibase:rollback -Dliquibase.rollbackTag=v1.0.0

DROP系の物理削除(dropTabledelete 後の状態)は、rollbackタグを書いてもデータ自体は復元できません。スキーマは戻せても中身は戻らない、と覚えておきましょう。ロールバック前提なら論理削除や別テーブル退避を検討します。

プロファイル別の運用

環境ごとの挙動制御は application-{profile}.yml で切り替えるのがシンプルです。

# application-prod.yml
spring:
  liquibase:
    enabled: false  # 本番はCIから明示実行する想定
    contexts: prod

本番で enabled: false にした場合は、CI/CDから次のように明示実行します。

mvn liquibase:update -Dliquibase.contexts=prod

プロファイルの切替方法は プロファイルで環境別設定を安全に切り替える も参考にしてください。

よくあるトラブル

導入直後に詰まりやすいポイントを軽くまとめておきます。

  • checksum不一致エラー : 適用済みchangeSetを編集すると発生します。原則編集せず、修正用の新しいchangeSetを追加する運用に。やむを得ない場合は mvn liquibase:clearCheckSums で再計算します。
  • DATABASECHANGELOGLOCKが解除されない : 強制終了などでロックが残った場合は mvn liquibase:releaseLocks か、該当テーブルを直接UPDATEで解放します。
  • changelogが読み込まれない : classpath: パスやファイル配置(src/main/resources/db/changelog/)を確認しましょう。includeAll で空ディレクトリを指していないかも要チェックです。
  • Hibernate ddl-auto との併用 : update などとの併用は予期しないスキーマ変更の原因になるので、本番では none にしてLiquibaseに一本化するのが安全です。

まとめ

LiquibaseはXML/YAML/SQLで柔軟に書け、ロールバックが標準サポートされている点が強みです。チームのスキルセットや変更の複雑さに応じて、FlywayとLiquibaseを使い分けるとよいでしょう。

まずは小さなchangeSetから始めて、tagDatabase でリリース単位の戻り先を確保しておくと、運用がぐっと楽になります。