4. Liquibase核心概念与入门:changelog、changeset、databaseChangeLog锁、回滚机制

好,咱们进入正题。Liquibase 这东西,说白了就是一个数据库版本控制器。跟 Git 管代码一样,它管你的数据库结构变化。我最早接触它是在一个金融项目里,那时候每次上线都要手动执行 SQL 脚本,出过好几次事故。后来用了 Liquibase,虽然学习曲线有点陡,但用顺了之后,真香。

这一章,咱们把 Liquibase 的四个核心概念掰开揉碎讲清楚。你只要搞懂 changelog、changeset、锁机制和回滚,基本就能上手干活了。

4.1 changelog:你的数据库变更清单

changelog 是什么?它就是一份清单,记录了所有要对数据库做的变更。Liquibase 支持 XML、YAML、JSON 和 SQL 四种格式。我个人习惯用 XML,结构清晰,IDE 还有自动补全。

一个典型的 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.9.xsd">

    <include file="changesets/001-create-user-table.xml"/>
    <include file="changesets/002-add-email-column.xml"/>

</databaseChangeLog>

看到没?changelog 本身不直接写 SQL,它通过 <include> 把一个个 changeset 文件组合起来。这样做的好处是,每个变更文件职责单一,好维护。

我的习惯:changelog 主文件只做编排,不写具体变更。每个 changeset 文件按序号命名,比如 001-xxx.xml、002-xxx.xml。这样看文件名就知道变更顺序。

4.2 changeset:最小变更单元

changeset 是 Liquibase 里最核心的概念。一个 changeset 代表一次原子性的数据库变更。它必须有两个属性:id 和 author。这两个值加上文件名,共同构成 changeset 的唯一标识。

来看一个实际的 changeset:

<changeSet id="1" author="zhangsan">
    <createTable tableName="user">
        <column name="id" type="bigint" autoIncrement="true">
            <constraints primaryKey="true" nullable="false"/>
        </column>
        <column name="username" type="varchar(50)">
            <constraints unique="true" nullable="false"/>
        </column>
        <column name="created_at" type="timestamp">
            <constraints nullable="false"/>
        </column>
    </createTable>
</changeSet>

这里用的是 Liquibase 的 XML 标签,不是原生 SQL。好处是跨数据库兼容——同一份 changelog 可以在 MySQL、PostgreSQL、Oracle 上跑,Liquibase 会自动翻译成对应的方言。

当然,你也可以直接用 SQL:

<changeSet id="2" author="zhangsan">
    <sql>
        CREATE INDEX idx_user_email ON user(email);
    </sql>
</changeSet>
注意:一旦 changeset 被执行过,就不能修改它的内容。Liquibase 通过 MD5 校验和来检测 changeset 是否被篡改。我曾经见过有人改了已执行的 changeset,结果部署时报校验和不匹配,整条流水线卡住了。正确的做法是——新建一个 changeset 来做变更。

4.3 databaseChangeLog 锁:并发安全的保障

多个人同时执行 Liquibase 怎么办?会不会冲突?

Liquibase 用了一张锁表来解决这个问题。默认情况下,它会在你的数据库里创建两张表:DATABASECHANGELOG 和 DATABASECHANGELOGLOCK。

DATABASECHANGELOG 记录所有已执行的 changeset,包括 id、author、文件名、MD5 校验和、执行时间、执行耗时等。

DATABASECHANGELOGLOCK 就是锁表,只有一行记录。当 Liquibase 开始执行变更时,它会先尝试获取锁。获取成功才能继续,否则就等待或报错。

表名 作用 关键字段
DATABASECHANGELOG 记录已执行的变更历史 ID, AUTHOR, FILENAME, MD5SUM, DATEEXECUTED
DATABASECHANGELOGLOCK 保证并发执行安全 ID, LOCKED, LOCKGRANTED, LOCKEDBY

锁的机制是这样的:

  1. Liquibase 启动,检查锁表。
  2. 如果锁未被占用,写入锁记录(LOCKED=1),开始执行变更。
  3. 变更执行完毕,释放锁(LOCKED=0)。
  4. 如果锁已被占用,等待超时或直接失败。

避坑指南:我曾经遇到过一个问题——某个 changeset 执行到一半,进程被 kill 了。结果锁没释放,后续所有部署都卡住了。解决方案是手动去数据库里把 LOCKED 字段改成 0。所以,如果你的 CI/CD 流水线经常被中断,建议设置合理的超时时间,或者加个重试机制。

下面这张图展示了 Liquibase 执行变更的完整流程:

Liquibase 变更执行流程 开始执行 检查 DATABASECHANGELOGLOCK 锁是否可用? 否 等待或报错退出 是 获取锁,开始执行变更 记录到 DATABASECHANGELOG 释放锁,执行完成 DATABASECHANGELOGLOCK 只有一行记录,LOCKED 字段 标记是否被占用

4.4 回滚机制:做错事了怎么办?

变更执行错了,或者上线后发现有问题,需要回退。Liquibase 提供了三种回滚方式。

4.4.1 自动回滚

有些 changeset 类型,Liquibase 能自动生成回滚语句。比如 <createTable> 对应的回滚就是 DROP TABLE。你只需要在 changeset 里声明:

<changeSet id="3" author="zhangsan">
    <createTable tableName="product">
        <column name="id" type="bigint"/>
        <column name="name" type="varchar(100)"/>
    </createTable>
    <rollback>
        DROP TABLE product;
    </rollback>
</changeSet>

嗯,这里要注意——虽然 Liquibase 能自动推断回滚,但我建议你总是显式写 rollback 标签。为什么?因为自动推断不一定准确,尤其是复杂场景下。

4.4.2 手动回滚

对于 <sql> 标签或者自定义变更,Liquibase 没法自动生成回滚。这时候你必须手动写:

<changeSet id="4" author="zhangsan">
    <sql>
        ALTER TABLE user ADD COLUMN age INT;
    </sql>
    <rollback>
        ALTER TABLE user DROP COLUMN age;
    </rollback>
</changeSet>

4.4.3 回滚命令

执行回滚时,Liquibase 提供了几个命令:

命令 说明 示例
rollbackCount 回滚最近 N 个 changeset liquibase rollbackCount 3
rollbackToDate 回滚到指定日期之前 liquibase rollbackToDate 2024-01-15
rollback 回滚到指定 tag liquibase rollback v1.0
我的建议:每次上线前,在 changelog 里打一个 tag。比如 <tagDatabase tag="v1.0"/>。这样万一出问题,直接 liquibase rollback v1.0 就能回到上线前的状态。我在生产环境就是这么干的,回滚速度从半小时缩短到几秒钟。

4.5 实战:第一个 Liquibase 项目

光说不练假把式。咱们快速搭一个项目试试。

首先,创建一个 db/changelog/db.changelog-master.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.9.xsd">

    <changeSet id="1" author="zhangsan">
        <createTable tableName="department">
            <column name="id" type="bigint" autoIncrement="true">
                <constraints primaryKey="true" nullable="false"/>
            </column>
            <column name="name" type="varchar(100)">
                <constraints nullable="false"/>
            </column>
        </createTable>
        <rollback>
            DROP TABLE department;
        </rollback>
    </changeSet>

    <changeSet id="2" author="zhangsan">
        <addColumn tableName="department">
            <column name="manager_id" type="bigint"/>
        </addColumn>
        <rollback>
            ALTER TABLE department DROP COLUMN manager_id;
        </rollback>
    </changeSet>

    <changeSet id="3" author="zhangsan">
        <tagDatabase tag="v1.0"/>
    </changeSet>

</databaseChangeLog>

然后执行:

liquibase --changeLogFile=db/changelog/db.changelog-master.xml \
          --url=jdbc:mysql://localhost:3306/mydb \
          --username=root \
          --password=root \
          update

执行成功后,去数据库看看,DATABASECHANGELOG 表里多了三条记录。想回滚?简单:

liquibase --changeLogFile=db/changelog/db.changelog-master.xml \
          --url=jdbc:mysql://localhost:3306/mydb \
          --username=root \
          --password=root \
          rollback v1.0

搞定。department 表被删了,manager_id 列也没了,数据库回到了 v1.0 标签时的状态。

核心要点回顾:

  • changelog 是变更清单,通过 include 组合多个 changeset 文件。
  • changeset 是原子变更单元,id + author + 文件名 唯一标识。
  • DATABASECHANGELOGLOCK 保证并发安全,但要注意死锁场景。
  • 回滚有三种方式:自动、手动、按 tag/日期/数量。
  • 已执行的 changeset 不能修改,只能追加新的。