Java 基础体系 · 第 85/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 数据库迁移:Flyway、Liquibase、版本、回滚和零停机

数据库迁移(database migration)是把数据库结构、约束、索引、初始化数据以及必要的数据转换,以可重复、可审计、可部署的方式从一个状态推进到另一个状态。

应用代码通过 JDBC 或 Jakarta Persistence 使用数据库,但数据库结构并不会因为 Java 类发生变化而自动可靠地演进。User.email 从普通字段变成唯一字段,通常至少涉及:

  1. 新增或修改数据库对象;
  2. 将旧数据转换为满足新约束的形式;
  3. 部署能够读写新旧结构的应用;
  4. 在失败时保留可诊断、可恢复的状态。

Flyway 和 Liquibase 都是数据库迁移工具,但它们组织迁移的方式不同:

  • Flyway 通常以“有序版本化脚本”为核心;
  • Liquibase 通常以“changeset(变更集)及其变更描述”为核心;
  • 二者都需要在数据库中记录已经执行过的迁移;
  • 二者都不能自动解决所有业务数据回滚和零停机问题。

Java 25 LTS 不改变这些数据库迁移的基本原理。Java 程序仍然通过 JDBC Connection、事务和 SQL 与数据库交互;Jakarta Persistence 仍然负责对象持久化映射,但生产环境中的结构演进通常应交给显式迁移工具,而不是依赖 ORM 自动建表。


1. 数据库迁移要解决什么问题

设数据库结构状态为 SS,一次迁移为 MiM_i。迁移的目标是执行:

Si+1=Mi(Si)S_{i+1} = M_i(S_i)

例如:

-- V2__add_user_status.sql
ALTER TABLE app_user
    ADD COLUMN status VARCHAR(20);

如果初始状态是:

S1 = app_user(id, email)

执行迁移后:

S2 = app_user(id, email, status)

但真实系统中的迁移不只是“运行一条 SQL”。工具至少还要处理以下状态:

未发现迁移
    ↓
发现迁移文件
    ↓
校验文件名、版本、校验和
    ↓
获取迁移锁
    ↓
执行 SQL
    ↓
记录成功或失败
    ↓
释放锁

一次迁移的可靠性通常取决于四个条件:

  1. 顺序确定:不同实例看到的迁移顺序一致;
  2. 执行一次:同一个迁移不会被多个应用实例重复执行;
  3. 结果可验证:数据库中保存了迁移的版本、描述、校验和和状态;
  4. 失败可诊断:失败后不会让应用错误地认为数据库已经完成升级。

1.1 迁移不是 ORM 自动建表

Jakarta Persistence 定义了实体、表映射、查询和持久化生命周期,但生产系统是否根据实体创建或更新表,属于部署配置和实现行为,不等价于经过审查的数据库迁移。

常见 ORM 配置包括:

jakarta.persistence.schema-generation.database.action=none

或者某些 ORM 使用自己的配置项,例如 validateupdatecreate 等。名称和支持范围依实现而异。

其中:

  • validate 通常只检查映射与数据库结构是否匹配;
  • update 可能尝试推断并修改结构,但通常不能表达复杂数据迁移;
  • createdrop-and-create 可能破坏数据;
  • 显式迁移脚本可以被代码审查、测试、发布和审计。

因此生产环境常见的责任划分是:

Flyway/Liquibase 负责:数据库结构和数据演进
JPA                负责:实体映射和对象生命周期
JDBC               负责:底层连接、事务和 SQL 执行

2. 版本迁移的核心模型

2.1 版本必须是全局可比较的顺序

迁移版本需要定义一个全序关系。若两个迁移版本为 vav_avbv_b,必须能够判断:

va<vb,va=vb,va>vbv_a < v_b,\quad v_a = v_b,\quad v_a > v_b

Flyway 常见命名方式是:

V1__create_user.sql
V2__add_user_status.sql
V3__create_user_index.sql

这里:

  • V 表示版本化迁移;
  • 123 是版本;
  • 双下划线后是描述;
  • .sql 是迁移内容。

常见还包括:

R__refresh_reporting_view.sql

R 表示 repeatable migration。它通常依据内容校验和判断是否需要重新执行,适合视图、存储过程等可重复生成对象,而不适合任意一次性数据变更。

Flyway 的版本排序和具体版本语法应以所使用发行版文档为准。工程上应避免同时使用难以理解的版本形式,例如把时间戳、整数、带点版本混杂到一个项目中。

Liquibase 的 changeset 通常写在 XML、YAML、JSON 或 SQL changelog 中,例如 XML:

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

    <changeSet id="2025-01-add-user-status" author="team-a">
        <addColumn tableName="app_user">
            <column name="status" type="varchar(20)"/>
        </addColumn>

        <rollback>
            <dropColumn tableName="app_user" columnName="status"/>
        </rollback>
    </changeSet>
</databaseChangeLog>

Liquibase 的身份主要由:

文件路径 + changeset id + author

组成,而不是只看 id。数据库会在 DATABASECHANGELOG 中保存执行记录和校验信息。

2.2 迁移记录表是状态机的一部分

Flyway 通常创建一个 schema history 表,Liquibase 通常创建 DATABASECHANGELOG 和锁表。具体表名、字段和默认 schema 可通过配置改变,不能假定所有项目完全相同。

逻辑上,一条迁移记录可抽象为:

(version, description, checksum, installed_by, installed_on, success)

一次成功执行的状态变化是:

数据库结构:S1
历史记录:没有 M2

执行 M2 成功

数据库结构:S2 = M2(S1)
历史记录:已有 M2,success = true

如果执行失败,则可能出现两种情况:

情况 A:数据库支持事务性 DDL,整个迁移回滚
结构仍为 S1,历史记录显示失败或没有成功记录

情况 B:数据库或 SQL 不能完全事务化
部分结构已变为 S2'
历史记录未必能自动恢复
必须人工诊断和修复

这就是为什么“迁移脚本能否回滚”不能只看工具,还必须看数据库的 DDL 事务语义和脚本中的操作。


3. Flyway:以迁移文件和顺序为中心

3.1 基本目录和脚本

一个典型目录:

src/main/resources/db/migration/
├── V1__create_user.sql
├── V2__add_user_status.sql
└── R__user_summary_view.sql

V1__create_user.sql

CREATE TABLE app_user (
    id BIGINT PRIMARY KEY,
    email VARCHAR(320) NOT NULL,
    created_at TIMESTAMP NOT NULL
);

V2__add_user_status.sql

ALTER TABLE app_user
    ADD COLUMN status VARCHAR(20);

R__user_summary_view.sql

CREATE OR REPLACE VIEW user_summary AS
SELECT
    status,
    COUNT(*) AS user_count
FROM app_user
GROUP BY status;

输入前提:

  • 数据库连接可用;
  • 执行账号具有创建表、修改表和创建视图的权限;
  • 迁移目录被加入 Flyway 的 locations;
  • 当前数据库尚未执行这些迁移,或历史记录与文件一致。

常见 CLI 操作的语义如下:

flyway info
flyway validate
flyway migrate

通常可以这样理解:

  • info:列出已执行、待执行或失败的迁移;
  • validate:比较本地迁移与历史记录,包括名称、版本和校验和;
  • migrate:按顺序执行待执行迁移。

预期状态可能类似:

V1  create user table     Success
V2  add user status       Pending
R   user summary view     Success

执行 migrate 后,V2 应变为 Success。如果两个应用实例同时启动,工具通常会使用数据库锁或等价协调机制,使其中一个实例执行迁移,另一个等待后重新读取历史状态。锁的具体实现依数据库和工具版本而不同,但部署设计不能假设“两个实例同时执行同一个脚本也没关系”。

3.2 Flyway 的校验和约束

迁移执行后,不应直接修改已经执行过的版本化脚本。例如已经执行:

V2__add_user_status.sql

再把它改为:

ALTER TABLE app_user
    ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE';

工具可能检测到校验和不一致并拒绝继续。这个拒绝是有价值的,因为数据库已经处于旧脚本产生的状态,而代码仓库却声称脚本内容不同。

正确做法通常是新增迁移:

V3__make_user_status_non_null.sql
UPDATE app_user
SET status = 'ACTIVE'
WHERE status IS NULL;

ALTER TABLE app_user
    ALTER COLUMN status SET NOT NULL;

这里有一个重要前提:必须先确认所有 NULL 都能安全映射到 ACTIVE。如果业务上无法确定,不能用默认值掩盖数据问题,应先统计并处理异常数据。

3.3 Flyway 的基线

当数据库已经存在,但没有迁移历史表时,不能简单把所有历史脚本从 V1 开始执行,否则可能重复创建生产表。

基线(baseline)是把已有数据库声明为某个起点,例如:

现有数据库状态 = V5

之后只执行高于该基线的迁移:

V6、V7、V8 ...

基线不是“验证现有数据库确实等于 V5”。它只是在历史记录中建立一个起点。因此使用前应先比较:

  • 表和列;
  • 主键、外键、唯一约束;
  • 索引;
  • 视图、函数、触发器;
  • 数据修复是否已经完成。

如果把实际为 V3 的数据库错误基线为 V5,工具会跳过 V4 和 V5,之后的失败可能延迟到应用运行时才暴露。


4. Liquibase:以 changeset、变更类型和回滚描述为中心

4.1 一个 changeset 的执行单位

Liquibase 可以用声明式变更类型:

<changeSet id="001-create-user" author="team-a">
    <createTable tableName="app_user">
        <column name="id" type="BIGINT">
            <constraints primaryKey="true" nullable="false"/>
        </column>
        <column name="email" type="VARCHAR(320)">
            <constraints nullable="false"/>
        </column>
        <column name="created_at" type="TIMESTAMP">
            <constraints nullable="false"/>
        </column>
    </createTable>

    <rollback>
        <dropTable tableName="app_user"/>
    </rollback>
</changeSet>

也可以直接使用 SQL:

<changeSet id="002-add-status" author="team-a">
    <sql>
        ALTER TABLE app_user ADD COLUMN status VARCHAR(20);
    </sql>

    <rollback>
        <sql>
            ALTER TABLE app_user DROP COLUMN status;
        </sql>
    </rollback>
</changeSet>

Liquibase 会记录 changeset 的身份、执行时间、执行者和校验信息。执行一次后,不能随意修改其内容;修改可能导致 checksum 校验失败。对已经发布的 changeset,应通过新的 changeset 修正,而不是重写历史。

4.2 Liquibase 的锁和并发

Liquibase 使用锁表协调并发更新。常见过程是:

实例 A 尝试获取 DATABASECHANGELOGLOCK
    ↓
实例 A 获得锁并执行 update
    ↓
实例 B 获取锁失败,等待或退出
    ↓
实例 A 提交并释放锁
    ↓
实例 B 重新读取 DATABASECHANGELOG

如果进程在持锁期间崩溃,可能留下锁记录。此时不能直接删除锁表数据,必须先确认没有其他迁移进程正在运行,再使用对应版本的 Liquibase 解锁操作或人工修复。否则可能导致两个进程同时执行迁移。

典型命令语义包括:

liquibase status
liquibase update
liquibase validate
liquibase rollback-count 1

具体命令名和参数随 Liquibase 版本、发行版和集成方式变化,应以实际安装版本的帮助输出为准。rollback-count 1 的含义是回滚最近的一个可回滚 changeset,而不是恢复到任意历史数据库快照。


5. Flyway 和 Liquibase 的关键差异

二者都能执行 SQL、记录历史、协调并发和校验变更,但抽象重点不同。

方面 Flyway Liquibase
主要模型 版本化迁移文件 changeset
变更表达 SQL、脚本和特定迁移形式 XML、YAML、JSON、SQL 等
顺序来源 版本排序 changelog 中的 changeset 顺序及条件
回滚表达 通常需要额外 undo 能力或反向脚本 可在 changeset 中声明 rollback
校验 历史记录与迁移文件校验和 changeset 身份和 checksum
数据库抽象 更接近手写 SQL 可使用数据库无关变更类型
数据库特性 直接使用方言通常更自然 抽象与原生 SQL 可混用

选择不能只依据“哪个工具更简单”。如果团队需要精细的 rollback 定义、标签、上下文和数据库无关的变更描述,Liquibase 的模型可能更适合;如果团队明确采用 SQL-first、希望迁移文件和数据库语句一一对应,Flyway 的模型通常更直观。

无论选择哪一个,都不能把工具的回滚能力理解为完整的业务事务回滚。


6. 回滚:结构回滚、数据回滚和发布回滚不是一回事

“回滚”至少有三种含义。

6.1 事务回滚

一次迁移正在执行时,数据库事务失败:

BEGIN
ALTER TABLE ...
INSERT ...
错误
ROLLBACK

如果数据库和这些操作支持事务性 DDL,结构和数据可以一起恢复。若某条 DDL 自动提交,或数据库不支持该类 DDL 的事务回滚,则 ROLLBACK 不能撤销已经提交的结构变化。

因此,以下说法是不成立的:

所有数据库上的所有 Flyway/Liquibase 迁移都能通过事务自动恢复。

必须查阅目标数据库对具体 DDL 的支持。即使 CREATE TABLE 可回滚,某些索引、扩展、重建表或数据库管理命令也可能有不同语义。

6.2 反向迁移

如果正向变更是:

ALTER TABLE app_user ADD COLUMN status VARCHAR(20);

反向变更可以是:

ALTER TABLE app_user DROP COLUMN status;

但它只有在以下条件都满足时才安全:

  1. status 中没有需要保留的数据;
  2. 新版本应用已经停止写入该列;
  3. 没有依赖该列的视图、索引、触发器;
  4. 删除列不会破坏仍在运行的旧代码。

因此反向 SQL 在语法上可写,不代表在生产上可安全执行。

6.3 发布回滚

发布回滚是把应用版本从 A2 换回 A1。数据库不一定要从 S2 回到 S1

如果 A2 已经写入新列,直接运行 A1 可能失败;如果迁移删除了旧列,旧版本更无法启动。零停机发布通常要求数据库结构在一段时间内同时兼容旧应用和新应用。

所以更可靠的关系是:

应用回滚 ≠ 数据库回滚

应用可以回滚到旧版本,而数据库保留向后兼容的新结构。

6.4 不可逆的数据迁移

假设把电话号码拆成国家码和本地号码:

phone = "+86 13800138000"

迁移为:

country_code = "+86"
local_number = "13800138000"

如果清理时删除原始 phone,而解析规则存在异常输入:

phone = "001-abc"

那么转换可能不可逆。即使工具提供 rollback,也无法凭空恢复原始值。

对于不可逆数据变更,可采用:

  • 保留原列一段时间;
  • 先复制到备份列或审计表;
  • 将转换结果记录到可追踪表;
  • 先验证异常记录为零;
  • 延后删除旧数据。

7. 零停机迁移的核心:Expand、Migrate、Contract

零停机(zero downtime)不是“执行 DDL 时数据库永远不锁表”,而是对外服务在迁移期间仍可用,并且每个上线阶段的应用和数据库状态都兼容。

设旧应用为 A0A_0,新应用为 A1A_1,旧结构为 S0S_0,新结构为 S1S_1。直接从:

(A0, S0) → (A1, S1)

切换,要求切换瞬间完成结构和代码的同步,风险很高。

更安全的路径是引入中间结构 SxS_x

S0SxS1S_0 \rightarrow S_x \rightarrow S_1

并要求:

A0 可运行于 SxA_0 \text{ 可运行于 } S_x

A1 可运行于 SxA_1 \text{ 可运行于 } S_x

这就是 Expand/Contract:

  1. Expand:增加兼容结构,不删除旧结构;
  2. Migrate:应用和数据逐步转向新结构;
  3. Contract:确认旧应用、旧读写路径和旧数据都不再需要后,删除旧结构。

7.1 示例:把 name 拆成 first_namelast_name

初始结构:

CREATE TABLE app_user (
    id BIGINT PRIMARY KEY,
    name VARCHAR(200) NOT NULL
);

阶段一:Expand

V10__add_name_parts.sql
ALTER TABLE app_user
    ADD COLUMN first_name VARCHAR(100);

ALTER TABLE app_user
    ADD COLUMN last_name VARCHAR(100);

此时旧应用只访问 name,仍然可以运行。新列允许为空,因为旧应用不会填写它。

新旧应用兼容关系:

应用 读取 name 读取新列 写入 name 写入新列
A0
A1 初版 可选 可选

阶段二:双写或回填

先回填可确定的数据:

UPDATE app_user
SET first_name = split_part(name, ' ', 1),
    last_name  = NULLIF(
        substring(name from position(' ' in name) + 1),
        ''
    )
WHERE first_name IS NULL
  AND position(' ' in name) > 0;

这个 SQL 只是 PostgreSQL 风格示例,不适用于所有数据库。它还无法正确处理多段姓名、东亚姓名规则或带空格的复姓,因此不能把“语句执行成功”当成“业务转换正确”。

应用 A1 可以先双写:

try (PreparedStatement ps = connection.prepareStatement("""
    UPDATE app_user
       SET name = ?, first_name = ?, last_name = ?
     WHERE id = ?
    """)) {
    ps.setString(1, name);
    ps.setString(2, firstName);
    ps.setString(3, lastName);
    ps.setLong(4, id);
    ps.executeUpdate();
}

双写时必须考虑:

  • 两列写入是否在同一个数据库事务中;
  • 重试是否幂等;
  • 部分写入失败时如何修复;
  • 多个应用版本是否同时在线;
  • 哪一列是事实来源。

如果应用实例很多,在线回填不应长时间持有一条大事务。可按主键范围分批:

UPDATE app_user
SET first_name = ...,
    last_name = ...
WHERE id > :last_id
  AND id <= :next_id
  AND first_name IS NULL;

每批独立提交,并记录进度。这样单批失败只需重试该批,但需要注意重复执行必须安全。

阶段三:切换读取路径

当新列已覆盖所有合法记录后,新应用读取:

SELECT first_name, last_name
FROM app_user
WHERE id = ?;

此时仍可保留对 name 的写入,或者继续双写一段时间,等待所有旧版本实例退出。

阶段四:Contract

只有当以下事实被验证后,才删除旧列:

ALTER TABLE app_user
    DROP COLUMN name;

验证条件至少包括:

  • 没有旧版本应用实例;
  • 旧代码路径和旧 API 已停止使用;
  • 新列不存在未转换的合法数据;
  • 监控显示双写冲突为零;
  • 已准备好数据备份和恢复方案。

删除列通常是破坏性操作。它不应与 Expand 放在同一个发布步骤中,否则无法实现应用版本之间的兼容。

7.2 示例:增加唯一约束

直接执行:

ALTER TABLE app_user
    ADD CONSTRAINT uq_app_user_email UNIQUE (email);

可能因为历史重复数据而失败,也可能在大表上长时间阻塞写入。

更可控的步骤是:

  1. 检查重复数据:
SELECT email, COUNT(*)
FROM app_user
GROUP BY email
HAVING COUNT(*) > 1;
  1. 处理重复数据;
  2. 在数据库支持的情况下,先创建唯一索引,并选择低阻塞方式;
  3. 验证索引状态;
  4. 再建立约束,或直接使用索引提供唯一性。

具体语法高度依赖数据库。例如 PostgreSQL 的 CREATE UNIQUE INDEX CONCURRENTLY 不能放在普通事务块中;某些数据库没有同等语义。迁移工具默认事务包装不能覆盖所有数据库特殊要求,必要时应使用工具提供的事务控制配置,并在测试环境验证。


8. 零停机不是所有 DDL 都能做到

以下操作容易造成锁、长时间重写或资源峰值:

  • 删除大表列;
  • 修改大表列类型;
  • 为大表创建非并发索引;
  • 增加需要扫描全表验证的约束;
  • 重建表;
  • 修改高流量表的主键或分区;
  • 运行大批量数据更新且只使用一个长事务。

需要把“数据库对象变更”分解为可观测步骤。例如增加非空列,不要直接执行:

ALTER TABLE app_user
ADD COLUMN status VARCHAR(20) NOT NULL;

因为旧行没有值,数据库可能拒绝执行;即使数据库允许默认填充,也可能触发大规模表重写。

可拆为:

ALTER TABLE app_user
    ADD COLUMN status VARCHAR(20);

然后分批回填:

UPDATE app_user
SET status = 'ACTIVE'
WHERE id > :last_id
  AND id <= :next_id
  AND status IS NULL;

回填完成后再增加非空约束:

ALTER TABLE app_user
    ALTER COLUMN status SET NOT NULL;

在最后一步之前执行验证:

SELECT COUNT(*)
FROM app_user
WHERE status IS NULL;

预期结果必须为:

0

如果不是零,不能继续加约束。这个过程的关键不在 SQL 数量,而在于把一个高风险原子操作转换成多个可验证状态。


9. Java 应用如何与迁移工具配合

9.1 JDBC 连接和迁移事务

JDBC 的基本生命周期是:

try (Connection connection = dataSource.getConnection()) {
    connection.setAutoCommit(false);

    try (PreparedStatement statement =
             connection.prepareStatement(
                 "UPDATE app_user SET status = ? WHERE id = ?")) {
        statement.setString(1, "ACTIVE");
        statement.setLong(2, 42L);
        statement.executeUpdate();
    }

    connection.commit();
} catch (SQLException e) {
    // 连接关闭前若事务未提交,通常会回滚;
    // 但应用不应依赖模糊的驱动行为,应该显式处理事务。
    throw e;
}

这是应用数据操作的示例,不是替代 Flyway 或 Liquibase 的迁移执行器。迁移工具需要额外处理:

  • 迁移文件发现;
  • 版本排序;
  • 历史表;
  • 校验和;
  • 并发锁;
  • 失败恢复;
  • 数据库方言和事务边界。

生产系统常见启动顺序是:

启动进程
  ↓
读取迁移配置
  ↓
执行 validate
  ↓
执行 migrate/update
  ↓
迁移成功后创建连接池或允许业务流量进入
  ↓
初始化 JPA EntityManagerFactory
  ↓
开始提供请求

如果迁移失败,通常应让实例启动失败,而不是让它继续接收请求。否则会出现:

数据库仍是旧结构
新代码已经上线
请求运行时才出现 "column does not exist"

不过,在多实例滚动发布中,不能简单地让每个应用实例都执行可能阻塞业务的重迁移。更稳妥的策略是:

  • 单独运行迁移 Job;
  • 迁移成功后再发布依赖新结构的应用;
  • 或让一个受控实例执行迁移,其余实例等待;
  • 对迁移账号和业务账号分权。

9.2 JPA 映射的兼容阶段

假设 Java 实体新增字段:

@Entity
@Table(name = "app_user")
public class User {
    @Id
    private Long id;

    private String email;

    private String status;
}

如果应用先上线,而数据库没有 status 列,查询或插入可能失败。正确顺序通常是:

先执行 Expand 迁移,增加 status
再发布包含 status 的应用
回填 status
最后加 NOT NULL 或其他约束

在过渡阶段,Java 字段是否允许 null 也要和数据库状态一致。数据库列尚未加 NOT NULL 时,实体层强制非空可能导致旧数据加载或更新异常;数据库已加约束而 Java 仍可能写入 null,则会在提交时失败。

hibernate.hbm2ddl.auto=update 之类配置不应作为生产迁移机制。它可能无法表达数据回填、索引策略、兼容发布和可审计回滚。


10. 迁移脚本的事务、锁和故障路径

10.1 成功路径

以一个版本化迁移为例:

1. 工具读取 V7__add_index.sql
2. 查询历史表,确认 V7 尚未成功
3. 获取迁移锁
4. 开启事务(若数据库和配置允许)
5. 执行 CREATE INDEX ...
6. 提交事务
7. 写入 V7 的成功记录
8. 释放锁

顺序细节可能因工具和数据库不同,但必须满足一个不变量:

不能在历史记录显示“成功”之前,向其他应用暴露一个工具认为尚未完成的迁移结果;也不能在结构未完成时把历史记录伪造为成功。

10.2 进程崩溃

如果进程在步骤 5 崩溃:

  • 事务性 DDL 可能全部回滚;
  • 非事务性 DDL 可能已经创建部分对象;
  • 工具重启时可能报告对象已存在;
  • 历史表可能显示失败、没有记录或部分状态。

处理步骤应是:

  1. 停止其他迁移进程;
  2. 查看工具历史表;
  3. 查看数据库实际结构;
  4. 检查数据库活动会话和锁;
  5. 判断脚本执行到哪一步;
  6. 使数据库恢复到一个明确状态;
  7. 再执行 validate 和迁移。

不要在不了解状态的情况下反复执行“修复命令”。例如手工删除历史记录可能使工具再次执行已经部分完成的 DDL。

10.3 锁等待和超时

如果迁移需要修改一张高流量表,可能出现:

迁移进程等待表锁
业务请求等待迁移持有的锁
连接池耗尽
应用延迟上升
健康检查失败
实例被平台重启

诊断应同时查看:

  • 迁移工具日志;
  • 数据库锁等待视图;
  • 长事务;
  • 活跃连接数;
  • 慢查询和等待事件;
  • 迁移前后表大小和索引状态。

将迁移超时设置得很大,不会消除锁冲突,只会让故障持续更久。要降低风险,应先确认数据库支持的低阻塞操作,并把大操作拆分或安排在合适窗口。


11. 回滚设计的完整示例

11.1 Liquibase changeset 的结构回滚

<changeSet id="003-add-login-attempts" author="security">
    <addColumn tableName="app_user">
        <column name="login_attempts" type="INTEGER"
                defaultValueNumeric="0"/>
    </addColumn>

    <rollback>
        <dropColumn tableName="app_user"
                    columnName="login_attempts"/>
    </rollback>
</changeSet>

正向路径:

没有 login_attempts
    ↓
增加列并给旧行默认值 0
    ↓
应用开始使用 login_attempts

反向路径只有在应用已经停止依赖该列时才安全。若新版本已经写入重要安全信息,删除列会造成信息丢失,不能把这个 rollback 当作无损恢复。

11.2 Flyway 的反向迁移限制

Flyway 的基础版本化模型通常是:

V1 → V2 → V3

不是自动执行:

V3 → V2

回退可采用:

  • 使用所用发行版支持的 undo/逆向迁移能力;
  • 编写新的前向修复迁移;
  • 恢复数据库备份或快照;
  • 回滚应用但保留兼容结构。

如果选择手写逆向脚本:

U3__remove_login_attempts.sql

也必须验证工具版本和发行版是否会识别、何时执行这类脚本。不能仅凭文件名假定所有 Flyway 环境都支持相同的 undo 行为。

11.3 为什么“写一个 down 脚本”仍不够

数据迁移:

UPDATE app_user
SET email = LOWER(email);

理论上的反向操作不是:

UPDATE app_user
SET email = UPPER(email);

因为原始大小写信息已经丢失。回滚需要恢复原值,而不是执行一个看起来相反的 SQL。

更可靠的迁移方式是先保存原值:

CREATE TABLE app_user_email_backup AS
SELECT id, email
FROM app_user
WHERE 1 = 0;

再按批次写入备份表和新值,并记录迁移批次。这样恢复时可以依据 id 找回原始数据。但备份表本身也需要容量、访问权限、保留周期和一致性设计。


12. 验证:迁移成功不等于业务正确

工具报告迁移成功,只能说明脚本按数据库返回的结果执行完成。还需要验证不变量。

12.1 结构验证

-- PostgreSQL 示例:检查未填充的新列
SELECT COUNT(*)
FROM app_user
WHERE status IS NULL;
-- 检查重复邮箱
SELECT email, COUNT(*)
FROM app_user
GROUP BY email
HAVING COUNT(*) > 1;

还应检查:

  • 列类型和默认值;
  • NULL/NOT NULL;
  • 主键和外键;
  • 唯一约束;
  • 索引状态;
  • 视图定义;
  • 触发器和存储过程。

SQL 查询系统目录的写法依数据库而异,不能把 PostgreSQL 查询直接用于 MySQL、Oracle 或 SQL Server。

12.2 应用验证

至少验证以下路径:

旧应用连接新结构
新应用连接过渡结构
新应用读写新字段
重复执行迁移不会重复破坏数据
迁移失败后重试行为明确

例如,双写逻辑应验证:

写入 name、first_name、last_name
读取新列
故意让一列违反约束
确认整个业务事务失败,不出现只更新一半的状态

12.3 校验和验证

如果迁移历史中已存在某个版本,而代码仓库中的脚本被改动:

历史 checksum ≠ 当前文件 checksum

工具通常应拒绝继续。诊断时应先判断:

  • 文件是否被错误修改;
  • 是否由换行符或编码变化造成;
  • 是否有人在数据库外手工执行过变更;
  • 当前数据库是否来自另一个发布分支。

不应把 repair 当成普通修复按钮。修复历史记录只是让工具接受当前文件,不会自动把数据库结构改成脚本描述的样子。


13. 常见错误及其失败表现

错误一:修改已执行迁移

表现:

校验和不匹配,启动失败

原因:

历史记录表示数据库按旧内容执行过,而代码仓库已经改变了迁移定义。

处理:

优先恢复原脚本,再新增一个迁移完成后续变更。只有在确认历史记录和实际结构都正确时,才考虑工具提供的历史修复功能。

错误二:应用先于数据库发布

表现:

column does not exist
table does not exist
constraint violation

原因:

新代码依赖结构尚未存在。

处理:

采用 Expand-first。先增加兼容结构,再发布代码。

错误三:先删除旧列,再发布新代码

表现:

旧实例在滚动发布期间持续报错。

原因:

旧代码仍然执行:

SELECT name FROM app_user

但迁移已经删除了 name

处理:

把删除列放到 Contract 阶段,并确认所有旧实例已经退出。

错误四:把大批量回填放在一个事务中

表现:

  • 长时间持锁;
  • WAL/日志快速增长;
  • 复制延迟;
  • 连接池耗尽;
  • 回滚时间远大于执行时间。

处理:

按主键或时间范围分批,设置批量大小、提交边界和进度记录。批次操作必须可重试且不会错误覆盖并发更新。

错误五:依赖数据库默认值掩盖业务转换

例如直接:

ALTER TABLE app_user
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE';

这只能表达“所有旧记录都应为 ACTIVE”。如果旧用户中存在冻结、待审核等状态,数据库默认值会制造业务错误,而不是解决迁移问题。

错误六:多个实例同时执行迁移

表现:

锁等待
对象已存在
死锁
迁移历史冲突

原因:

部署系统和迁移工具的并发协调方式不匹配,或者人工绕过了工具锁。

处理:

使用单独迁移 Job 或确认工具的锁机制和启动策略;不要在锁表中直接改状态来“解除卡住”。


14. 一套可审计的发布流程

假设要将 app_user.status 从可空字段变成必填字段,可按以下顺序执行。

第一步:发布 Expand 迁移

ALTER TABLE app_user
    ADD COLUMN status VARCHAR(20);

验证:

SELECT column_name, is_nullable
FROM information_schema.columns
WHERE table_name = 'app_user'
  AND column_name = 'status';

预期:

status | YES

第二步:发布兼容应用

新应用:

  • 读取 status 时允许空值;
  • 新写入记录填充合法状态;
  • 旧应用仍可以运行;
  • 不立即依赖 NOT NULL

第三步:回填和监控

UPDATE app_user
SET status = 'ACTIVE'
WHERE id > :start_id
  AND id <= :end_id
  AND status IS NULL;

每批完成后检查:

SELECT COUNT(*)
FROM app_user
WHERE status IS NULL;

同时监控:

  • 回填速度;
  • 锁等待;
  • 数据库 CPU、IO;
  • 复制延迟;
  • 应用错误率;
  • 新旧写入路径的差异。

第四步:增加约束

确认计数为零后:

ALTER TABLE app_user
    ALTER COLUMN status SET NOT NULL;

然后让应用把 status 当作必填字段。此时结构约束和代码约束相互一致。

第五步:清理旧路径

如果该迁移还涉及旧列,例如从 state 改名为 status,应再经过一个发布周期,确认没有旧实例和旧查询后,才删除 state

整个过程的状态可以表示为:

stateDiagram-v2
    [*] --> S0: 只有旧列 state
    S0 --> Sx: 增加 status,可为空
    Sx --> Sx2: 应用双读/双写并回填
    Sx2 --> S1: status 完整且加约束
    S1 --> S2: 停止旧代码后删除 state
    Sx2 --> Sx: 回填失败,修复后重试
    S1 --> Sx2: 发现新代码不兼容,保留兼容结构

关键点是:失败时尽量退回到仍兼容的中间状态,而不是强行删除新结构或恢复整个数据库。


15. 如何选择和组合两种工具

可以按以下事实判断,而不是按工具名称判断:

选择 Flyway 风格时

适合:

  • 团队熟悉 SQL;
  • 强依赖数据库特性;
  • 希望迁移文件直接对应 SQL;
  • 迁移顺序清晰,主要采用前向演进;
  • 需要简单、直观的历史文件结构。

风险是:SQL 方言、事务 DDL、索引并发创建和数据转换责任更多地落在团队身上。

选择 Liquibase 风格时

适合:

  • 需要 changeset 身份和显式 rollback;
  • 需要 labels、contexts 等条件化执行能力;
  • 希望部分变更使用数据库无关描述;
  • 需要较强的变更元数据管理。

风险是:抽象变更最终仍会生成数据库特定操作;使用声明式类型不代表可以忽略锁、性能和数据语义。

不应混用多个迁移历史

同一个数据库 schema 通常应由一个明确的迁移历史系统负责。若一部分表由 Flyway 管理,另一部分由 Liquibase 管理,需要明确:

  • 两个工具是否使用不同的 schema;
  • 谁先执行;
  • 是否可能修改同一对象;
  • 两套锁是否会产生死锁;
  • 审计系统如何合并两套历史。

否则会形成两个互不理解的版本序列:

Flyway 认为:V8 已完成
Liquibase 认为:changeset X 已完成
真实数据库:两者可能互相覆盖或缺少依赖

16. 生产环境的判断标准

一个迁移设计至少应能回答以下问题:

  1. 当前数据库到底处于哪个迁移状态?
  2. 迁移文件是否与历史校验和一致?
  3. 多个应用实例同时启动时谁获得执行权?
  4. 迁移失败后数据库是完整旧状态、完整新状态,还是部分状态?
  5. 旧应用是否能运行在过渡结构上?
  6. 数据回填是否可分批、可重试、可验证?
  7. 哪些操作不可逆,原始数据保存在哪里?
  8. 应用回滚时数据库是否仍向后兼容?
  9. 删除列、索引或约束前,依据什么证据确认没有调用者?
  10. 迁移锁等待、失败和部分执行如何诊断?

如果只能回答“工具会自动处理”,说明迁移设计还没有落到数据库事务、应用兼容性和故障恢复层面。

数据库迁移的可靠目标不是让每次变更都能神奇地回到过去,而是让状态变化有顺序、有记录、有验证,并在发布失败时保留一条可恢复的路径。Flyway 或 Liquibase 负责执行和记录这条路径;JDBC、JPA、数据库事务和发布系统共同决定它在真实生产环境中是否成立。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。