本文用一组「问答」拆解 Liquibase(XML 格式)在 Spring Boot 微服务下我更倾向采用的一些实践经验。文中的反例都来自真实可见的代码风格——表名、字段、author 全部脱敏,不指向任何具体项目。每条做法尽量给出官方文档或社区可访问的来源。

一、Schema 应该住在哪个 repo?#

Q:为什么我现在不太倾向继续用一个独立的 db-* 仓库(或子模块)单独保存所有 MS 的 schema?

历史上常见的做法是开一个 database-foodatabase-bar 的项目,里面只放 liquibase.properties + 一堆 change-log/*.xml,跑 mvn liquibase:update 完成迁移。问题是:

  1. schema 和服务代码不在同一个生命周期里。MS 加了一个 entity_id 字段,PR 在服务 repo;migration 在另一个 repo。两个 PR 难以原子合并,code review 也只能看半边。
  2. CI 触发不一致。服务 repo 的测试不会跑 migration repo 的最新变更;migration repo 的 CI 也很难拿到服务的 entity 类去做 schema-vs-entity 的 sanity check。
  3. 不利于 service ownership。微服务的核心原则是 database per service;schema 是这个服务的私有资产,住到外部 repo 等同于把私有资产挪出了边界。

Q:那我现在更倾向怎么放?

直接放进微服务自己的 repo,使用 Spring Boot 默认约定:

my-service/
  src/main/resources/
    db/
      changelog/
        db.changelog-master.xml
        changes/
          0001-init-schema.xml
          0002-add-orders-status-index.xml
          ...
    application.yml

spring.liquibase.change-log 的默认位置就是 classpath:/db/changelog/db.changelog-master.xml,参考 Spring Boot Liquibase 集成文档Reflectoring 的 one-stop guide

服务的 unit / integration 测试启 Testcontainers MySQL 时,Liquibase 也会被自动触发,意味着你写的 SQL 在每次 PR 上都会被真正跑一次——这是 schema 单独住外部 repo 时拿不到的体感。


二、Master changelog:<include> vs <includeAll>#

Q:用 <includeAll path="..."> 整个目录扫进来不是很方便吗?为什么还要手写每一条 <include>

includeAll 的执行顺序是按文件名字母序,这是官方文档明确说明的:includeAll 文档 写「all files inside of the included directory are run in alphabetical order」。问题来了——

db/changelog/Sprint64/
  CMS-15789.xml   ← 实际上需要在 16003 之前执行
  CMS-16003.xml
  CMS-17219.xml
  CMS-17249.xml
  CMS-18225.xml

ticket 编号是按申请时间分配的,不等于变更要被应用的顺序。如果 17219 创建了一张表,15789 要往这张表插数据,但因为 15789 < 17219,Liquibase 会先跑 15789,导致失败;或者更糟——第一次跑没问题(数据为空,DDL 顺序"恰好"对),上线后某次 mvn test 在新环境跑 update 才暴露。这类问题在 GitHub issue #87社区讨论 里反复出现。

Q:什么时候 includeAll 才是合理的?

满足两个条件:

  1. 强约定的文件名前缀——比如 ISO 时间戳 20260426T103000-add-orders-status.xmlLiquibase 官方 FAQ 直接建议 “prepend the file containing the changeset with a data timestamp”。
  2. 目录内的 changeSet 之间没有顺序依赖——纯加列、纯加索引、纯插初始化数据。

否则我会更倾向手写 <include>,并配上一行注释,让 PR review 一眼能看出这个 changelog 是干什么的:

<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.33.xsd">

    <include file="changes/0001-init-schema.xml"        relativeToChangelogFile="true"/>  <!-- 初始 schema -->
    <include file="changes/0002-add-orders-idx.xml"     relativeToChangelogFile="true"/>  <!-- 订单状态索引 -->
    <include file="changes/0003-payments-add-currency.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>

注意 relativeToChangelogFile="true"——否则路径解析会落到 working directory 上,Maven、CLI、Spring Boot 启动的相对位置不一定一致。这个细节在 include 官方文档Liquibase issue #1265 都有讨论。

更全面的复杂 changelog 拆分模式可以看 vendor blog: Main XML Changelogs in Liquibase


三、changeSet 的 ID 和 author 怎么取?#

Q:以下两种风格哪个更好?

反例

<changeSet author="janedoe (generated)" id="1754317603906-1">
    <createTable tableName="orders">...</createTable>
</changeSet>
<changeSet author="janedoe (generated)" id="1754317603906-2">
    <createTable tableName="payments">...</createTable>
</changeSet>

正例

<changeSet id="ORDER-1024.1-create-orders" author="jane.doe">
    <comment>Create orders table for the new checkout flow.</comment>
    <createTable tableName="orders">...</createTable>
</changeSet>
<changeSet id="ORDER-1024.2-create-payments" author="jane.doe">
    <comment>Create payments table; FK constraint added in 1024.3.</comment>
    <createTable tableName="payments">...</createTable>
</changeSet>

理由:

  • ID 是变更的主键。Liquibase 用 (filename, id, author) 三元组识别 changeSet(参考 What is a Changeset)。1754317603906-1 这种 epoch milli 派生的 ID 在 IDE 和 review 里完全不可读,并且可能在并行分支生成时碰撞——一旦碰撞,常见处理方式往往是清理 DATABASECHANGELOG 记录或改 ID(改 ID 会让目标库认为是一个新 changeSet,重新执行)。改 ticket 编号 + 序号通常更容易避开这种风险。
  • author 写真名,不写 (generated) 后缀。官方文档 bestpractices 明确说,“if an organization would not want to tie changes back to a particular individual or if the original author isn’t actually known, you can make up a value such as UNKNOWN”——也就是说 author 是有意义的归属,不该用 IDE 默认插的 placeholder。
  • <comment>。它会出现在 liquibase update-sql 输出和 Pro 的部署报告里,code review 也直接看得见原因。

Q:CI 能强制检查这件事吗?

可以。Liquibase Pro 提供了 Policy Checks 系列,包括 ChangesetCommentCheckChangesetContextCheck,可以让 liquibase checks run 在 PR pipeline 里失败掉没有 comment、ID 不规范、缺少 rollback 的 changeSet。OSS 用户我至少会补上 liquibase validate,它能在 CI 里抓住重复 ID、无效 XSD、缺失文件等编译期错误。


四、一个 changeSet 一件事#

Q:把 createTable + addPrimaryKey + 三个 createIndex 合在同一个 changeSet 里有什么坏处?

Liquibase 官方文档 直说:“It is best practice to have just one change per changeset unless there is a group of non-auto-committing changes that you want to apply as a transaction such as inserting data.”

具体的痛点:

  • 回滚粒度被锁死。某个索引建得不对,想用 rollback-one-changeset(Pro)单独回退,但同一 changeSet 里建表的部分会一起被回滚——结果一不小心把表也丢了。
  • DDL auto-commit 的副作用。在 MySQL 里 DDL 默认自动提交,如果 changeSet 里有 5 个 DDL,第 3 个失败,Liquibase 会把这个 changeSet 标记为失败但前两个已经生效——它们留在数据库里,但 DATABASECHANGELOG 没有这条记录。下次重跑会因为表已存在而再次失败。这就是文档里所说 “avoids failed auto-commit statements that can leave the database in an unexpected state”。

我更倾向把它拆开写:

<changeSet id="ORDER-1024.1" author="jane.doe">
    <createTable tableName="orders">...</createTable>
</changeSet>

<changeSet id="ORDER-1024.2" author="jane.doe">
    <addPrimaryKey tableName="orders" columnNames="id"/>
</changeSet>

<changeSet id="ORDER-1024.3" author="jane.doe">
    <createIndex indexName="idx_orders_status" tableName="orders">
        <column name="status"/>
    </createIndex>
</changeSet>

例外:当确实需要原子性的(比如插入一组关联种子数据),用一个 changeSet 没问题,因为这是「one logical change」。


五、XSD 版本:尽量不要写 dbchangelog-latest.xsd#

Q:用 latest 不是省事吗?为什么是反模式?

<!-- 反例 -->
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
                    http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"

Liquibase issue #3120 解释得很清楚:Liquibase 解析 XSD 时优先从 jar 包内读取——也就是说 latest 在不同 Liquibase 版本下解析到的实际 schema 不同。一个 4.20 jar 跑你写的 XML 时,latest 解析到 4.20;4.30 jar 又解析到 4.30。如果你在 4.30 用了 4.30 才有的标签,本地 4.30 通过,CI/生产 4.20 直接 schema 校验失败。

更稳妥的做法是显式 pin 到一个具体版本,并保持和服务 pom 里 liquibase-core 同步:

<!-- 正例:Liquibase 4.33 -->
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
                    http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd"

升级 Liquibase 时一并改 XSD 引用(一次性 sed 即可),review 显式可见。

Q:项目里以前留下的 changelog 写的是 4.1.xsd,现在 jar 是 4.33,会出问题吗?

通常不会。Liquibase 的 XSD 在兼容性上相对克制——你可以用旧 XSD 写出的文件喂给新 jar。但混用多个版本的 XSD(同一项目里有的写 4.1、有的写 4.20、有的写 latest)往往说明这件事已经没人持续维护,我一般会顺手把它们统一掉。可以参考 社区讨论:different versions of dbchangelog.xsd


六、初始化 schema:手写 vs generate-changelog 倒推#

Q:从一个已有的 MySQL 库 mysqldump 出 schema,再用 generate-changelog 倒灌成 Liquibase XML 当作 init,行不行?

短期能跑,长期是债。generate-changelog 倒出来的产物有几个共同特征:

  • 一个文件几千行,全是单一 author(比如 system (generated))的 createTableaddPrimaryKeycreateIndex
  • ID 是时间戳式自增(1754317603906-1-37)。
  • 列定义里会出现奇怪的默认值,比如 defaultValueNumeric="0E-8"(生成器把 0.00000000 转成科学计数法)。
  • 同一个文件里 type 大小写不一致(TINYINT(3)tinyint 混用,因为不同表导出时取了不同的格式化路径)。

这种文件不可读、不可改、不可分——你不能从中间删除一个表(中间被删了,新环境就建不出来)。一旦它进入 git,本质上变成了一份「只能追加」的合约。

我目前更倾向的一条路径

  1. 真正的初始 schema 自己手写。一个微服务初次上线的 schema 通常 5–15 张表,手写一遍有助于:自己想清楚命名规范、索引策略、字符集、引擎选项。
  2. 每张表一个 changeSet,ID 从 0001-init-orders 这种序号开始
  3. 不要把生产数据迁移到 loadData(除了 enum/字典表这种少量配置);业务数据走另外的迁移工具。

反例:generated dump

<!-- 8000+ 行的 change-log-structure-init.xml -->
<changeSet author="system (generated)" id="structure-init-1">
    <createTable tableName="affiliate_bet_history">...</createTable>
</changeSet>
<changeSet author="system (generated)" id="structure-init-2">
    <createTable tableName="affiliate_contribution">...</createTable>
</changeSet>
<!-- ... 重复 N 次 ... -->

Q:但项目已经在 dump 上跑了,怎么补救?

两条路径,二选一:

  • 冻结现状,新变更走规范文件change-log-structure-init.xml 不再修改,所有新 changeSet 走 0002-... 起步。这是绝大多数团队会选的折中方案,简单。
  • changelog-sync 一次清算。在生产库上跑 liquibase changelog-sync,把整理好的「人类版」changelog 标记为已执行(不真正跑 DDL),然后切换 master 引用。门槛高、风险大,仅在团队有充分把握时考虑。

七、加 NOT NULL 列:Expand-and-Contract 三步走#

Q:<addColumn> 里直接加 <constraints nullable="false"/> + defaultValue="" 看起来很省事,为什么不行?

这种写法在空表全新环境下能跑过,但在真实生产(已经有数据)就有几个问题:

<!-- 反例 -->
<changeSet id="orders-add-channel" author="jane.doe">
    <addColumn tableName="orders">
        <column name="channel" type="VARCHAR(255)" defaultValue="">
            <constraints nullable="false"/>
        </column>
    </addColumn>
</changeSet>
  • defaultValue="" 是一个无意义的 sentinel。后续业务代码读到空串会 if (channel.isEmpty()) 来兜底,但语义比 NULL 更糟(你失去了「未填」和「显式空」的区分)。
  • 大表上 ALTER TABLE ... NOT NULL 默认要锁表(MySQL 5.7 / 8.0 部分场景能用 INPLACE,但有限制)。
  • 滚动发布期间,新 pod 写新字段、旧 pod 不知道这个字段——如果同时配上 NOT NULL,两边读到的快照行为不一致。

Thorben Janssen 的 update-database-without-downtimeLiquibase 官方 zero-downtime extension 仓库 都把这个归到 expand-and-contract 这一类动作里:

<!-- Step 1 (expand):先 nullable 加进去 -->
<changeSet id="orders-add-channel.1-expand" author="jane.doe">
    <addColumn tableName="orders">
        <column name="channel" type="VARCHAR(64)"/>
    </addColumn>
</changeSet>

应用程序部署一个双写版本:写订单时同时给 channel 赋值,读取时容忍 NULL。

<!-- Step 2 (migrate):把历史数据填上 -->
<changeSet id="orders-add-channel.2-backfill" author="jane.doe" runInTransaction="false">
    <sql>
        UPDATE orders SET channel = 'unknown' WHERE channel IS NULL
    </sql>
</changeSet>

runInTransaction="false" 能降低超大 UPDATE 把事务日志撑爆的风险;如果数据量真的大,我一般还是会更倾向业务侧的批处理任务,而不是 Liquibase 一次性 UPDATE。

<!-- Step 3 (contract):所有应用都不再产生 NULL 后,加约束 -->
<changeSet id="orders-add-channel.3-not-null" author="jane.doe">
    <addNotNullConstraint tableName="orders" columnName="channel"
                          defaultNullValue="unknown"
                          columnDataType="VARCHAR(64)"/>
</changeSet>

addNotNullConstraint 文档 说 “if a defaultNullValue attribute is passed, all null values will be updated to the passed value before the constraint is applied”,给二次保险。


八、大表 DDL:Liquibase + 在线 schema 工具#

Q:<modifyDataType> 改一张几百万行表的列类型,为什么会卡?

modifyDataType 翻译成 MySQL 是 ALTER TABLE x MODIFY COLUMN ...,参考 Liquibase modifyDataType 文档data-type-handling。MySQL 8.0 在某些场景下能选择 ALGORITHM=INSTANT,但改 TEXT/VARCHAR 长度、改字符集、改类型类别通常退化到 INPLACE/COPY,需要长时间持有锁或重建整表。

如果项目里出现了这种连续改类型的 changeSet(每一项都对应一张大表):

<changeSet id="ORDER-2999.1" author="qa">
    <comment>Expand orders.metadata column from TEXT to MEDIUMTEXT</comment>
    <modifyDataType tableName="orders" columnName="metadata" newDataType="MEDIUMTEXT"/>
</changeSet>
<!-- 接下来还有 9 个 .2 ~ .10 ... -->

代码 review 时我会优先问:「这张表多大?是否会引起锁等待?是否要走在线变更?」

Q:怎么走「在线变更」?

两种主流方案:

  1. Liquibase Percona ExtensionGitHub / 官方教程)。装上之后,原本会执行 ALTER TABLE 的 changeSet 会自动改用 pt-online-schema-change 在影子表上变更然后切换;锁表时间从分钟级降到毫秒级。

  2. <sql> + 显式 ALGORITHM=INPLACE, LOCK=NONE(针对 MySQL 8)。绕过 modifyDataType,自己写 SQL:

    <changeSet id="ORDER-2999.1" author="qa">
        <comment>Expand orders.metadata column to MEDIUMTEXT (online)</comment>
        <sql>
            ALTER TABLE orders MODIFY COLUMN metadata MEDIUMTEXT,
                               ALGORITHM=INPLACE, LOCK=NONE
        </sql>
        <rollback>
            ALTER TABLE orders MODIFY COLUMN metadata TEXT,
                               ALGORITHM=INPLACE, LOCK=NONE
        </rollback>
    </changeSet>
    

更激进的场景(比如几亿行表的列重命名)我通常会优先评估 gh-ost(GitHub 出品,无 trigger 的 binlog 方案)这类在线 schema 工具,在 Liquibase 之外执行,再用 changelog-sync 把 changeSet 标记为已执行。Bytebase 的对比文章 解释了什么场景该选哪个。


九、tagDatabase 与回滚策略#

Q:每个 changeSet 都写 <rollback> 吗?

Liquibase 给一些 change type(createTableaddColumn 等)提供了自动回滚——文档 What is a rollback 列举了支持的列表。但是:

  • dropTablesqldropColumn 这些不可自动反推——必须自己写 <rollback>
  • Liquibase Pro 的 Policy Checks 里有 RollbackRequired 检查,可以在 CI 里强制每个 changeSet 都有 rollback 子节点。

Q:业务回滚到「上周三上线前」需要怎么做?

tagDatabase 在每次发布的最后打 tag,再用 liquibase rollback <tag> 回滚到那个 tag 之前的状态——前提是中间所有 changeSet 都有可用的 rollback。一个常见模式:

<!-- 在 ticket / sprint 末尾收口的 changelog 文件里 -->
<changeSet id="ORDER-1024-tag" author="jane.doe">
    <tagDatabase tag="ORDER-1024"/>
</changeSet>

配套命令在 Liquibase Rollback Workflow 有完整示例:

# 回滚到上次 tag 之前的状态
mvn liquibase:rollback -Dliquibase.rollbackTag=ORDER-1023

# 先看 SQL 不实际执行
mvn liquibase:rollbackSQL -Dliquibase.rollbackTag=ORDER-1023

注意:业务系统里的 rollback 多数情况下不是「回滚 schema」而是「回滚应用版本」。schema-level rollback 主要用在:

  • 上线后短窗口内(数据增量很小,rollback 不会丢业务数据)。
  • 测试环境快速重置。

数据增量已经累积时,我通常会优先考虑 forward-fix(写一个新 changeSet 修复)而不是 backward rollback。


十、runOnChange:危险但偶尔有用#

Q:什么时候用 runOnChange="true"

runOnChange 文档 描述:normal changeSet 只跑一次(按 ID/author/filename 标识,跑过就不再执行);runOnChange="true" 则在 changeSet 内容变化时重新执行。典型场景:

  • View 定义、stored procedure(这些是「定义优先」的对象,整体替换没问题)。
  • 配置表里少量 enum 数据(用 <sqlFile> 引一个 SQL 脚本,每次内容变就重跑)。

runOnChange 我不太会用在普通 DDL 上,因为它会削弱「changelog 是一份不可变历史」这个保证。至少在我看来,把它限制在 view / procedure / 少量配置对象上会更稳妥;再配合 runOrder="last" 让 view/proc 在所有 schema 改动之后再被刷新。

Q:<rollback empty="true"/> 是干什么的?

显式声明这个 changeSet 「没有 rollback」(不是忘了写)。常见在 INSERT 一些不可逆的种子数据或者 audit 日志写入。这种声明可以帮助 Pro Policy Checks 在 audit 时把它和「漏写 rollback」区分开。


十一、contextslabels:环境维度的开关#

Q:希望种子数据只在 dev/qa 跑,不进生产,怎么办?

Liquibase: Contexts vs. Labels 里讲过两者的差别:

  • Context:changeSet 自己声明属于哪个环境,由作者决定。
  • Label:changeSet 自己打标签,由部署人决定哪些 label 要被运行。

实践里的常见组合:

<!-- 只在 dev/qa 注入 fixture -->
<changeSet id="ORDER-1024.fixture" author="jane.doe" context="dev,qa">
    <loadData tableName="products" file="fixtures/products.csv"/>
</changeSet>

application-dev.yml

spring:
  liquibase:
    contexts: dev

生产环境完全不传 context(或者传 prod),fixture changeSet 就被跳过。

注意contexts: dev 这一行最好配在生产环境也加载得到的位置(如 application-prod.yml 留空),否则没设 context 时所有 contexts 都会跑——这是常见的踩坑点,官方 best-practices 文档 有专门说明。


十二、Spring Boot 启动迁移 vs 独立迁移#

Q:默认 spring.liquibase.enabled=true,每个 pod 启动都跑 Liquibase——这有什么问题?

Backbase Engineering 的 “Liquibase as an Init Container”Liquibase 官方 blog: Using Liquibase in Kubernetes 系统性梳理过几个症状:

  1. 滚动发布时多 pod 同时启动 → 同时尝试拿 DATABASECHANGELOGLOCK 表的锁;只有一个能拿到,其余 pod 在 readiness 探针失败前就 hang 住。
  2. Liquibase 跑了一半 pod 被 K8s liveness probe killLOCKED=1 留在表里,下次启动所有 pod 永远拿不到锁liquibase release-locks 是手动救命操作。
  3. migration 时间长 → 拖累整个滚动发布的速度。

Q:怎么解决?

主流方案三选一:

  • Init containerLiquibase 官方推荐)。把 Liquibase 跑在 init container 里,主容器启动前就完成。Init container 不受 livenessProbe 影响,不会被 kill。Spring Boot 里设 spring.liquibase.enabled=false
  • Job/Helm hook。把迁移做成 K8s Job 或 Helm pre-install/pre-upgrade hook,集群里至多一个迁移 Pod,应用层全部 enabled=false
  • liquibase-sessionlock extension。把基于行的锁换成 MySQL GET_LOCK() / Postgres pg_advisory_lock(),pod 异常退出时连接断开即自动释放,避免「LOCKED=1 锁死」。

反例:每个 MS 都默认开启 Liquibase 启动时迁移 + 多副本部署 + 没装 sessionlock extension——典型的「平时没事,一次发布卡 30 分钟」组合。

Q:在做 schema 改动前,能不能让所有应用先 disable Liquibase?

可以。两步走:

  1. 应用 deployment 加 spring.liquibase.enabled=false,全部 rollout 一次。
  2. liquibase-maven-plugin 或独立 Job 显式跑 mvn liquibase:update -Dliquibase.contexts=prod,发布完成后再恢复(或保持禁用)。

十三、liquibase.properties 不该提交什么#

Q:项目里出现这样的 src/main/resources/liquibase.properties,问题在哪里?

driver=com.mysql.cj.jdbc.Driver
changeLogFile=src/main/resources/db/change-log-master.xml

# local DB
url=jdbc:mysql://18.181.107.128:3306/dev_wallet?allowPublicKeyRetrieval=true&useSSL=false
username=
password=

几个问题:

  • 硬编码 IP——意味着开发者必须用同一个共享 MySQL,整个团队的 schema 实验互相污染。
  • changeLogFile=src/main/resources/db/change-log-master.xml 这种路径是「按 maven CWD 解析」的,IDE / CI / 容器里运行结果不一致。
  • 空 username/password——意味着真实凭证靠环境变量或本机 history 传入,但是 properties 文件不写明这一点,新人会迷惑。

Liquibase 官方 properties 文档 推荐的做法:

# liquibase.properties (template, committed)
driver=com.mysql.cj.jdbc.Driver
changeLogFile=db/changelog/db.changelog-master.xml
# Provide via env / CLI / CI secret:
#   LIQUIBASE_COMMAND_URL
#   LIQUIBASE_COMMAND_USERNAME
#   LIQUIBASE_COMMAND_PASSWORD

changeLogFile 用 classpath-relative 路径(db/changelog/...),这样 maven plugin、Spring Boot、CLI 都能解析得到(前提是 src/main/resources 在 classpath 中)。本机配置走 ~/.liquibase/liquibase.properties 或环境变量 LIQUIBASE_COMMAND_*


十四、CI 里到底应该跑什么#

Q:哪些命令必须在 PR pipeline 里跑?

最低保障:

# 1. 语法 / ID 唯一性 / 文件存在性
- run: mvn liquibase:validate

# 2. 在 ephemeral 数据库上真正跑一遍
- run: docker run -d --name testdb -e MYSQL_ROOT_PASSWORD=pw mysql:8
- run: mvn liquibase:update -Dliquibase.url=jdbc:mysql://...

# 3. 把这次 PR 引入的变更生成 SQL,附在 PR comment 里供人审
- run: mvn liquibase:updateSQL > /tmp/migration.sql

如果有 Liquibase Pro,加上:

# 4. 强制 changeSet 必须有 comment、有 rollback、不能用反例 change type
- run: liquibase checks run --checks-scope=changelog --checks-settings-file=liquibase.checks-settings.conf

Liquibase: Faster, safer, and easier database change management 列举了不少开箱可用的检查项。

Q:integration test 怎么验证 schema 和实体类的对齐?

Pretius: Testcontainers + LiquibaseMy Developer Planet: Spring Boot + jOOQ + Liquibase + Testcontainers 给出了模板。下面只放一个最小示例:

@SpringBootTest
@Testcontainers
class SchemaSanityTest {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");

    @DynamicPropertySource
    static void config(DynamicPropertyRegistry r) {
        r.add("spring.datasource.url", mysql::getJdbcUrl);
        r.add("spring.datasource.username", mysql::getUsername);
        r.add("spring.datasource.password", mysql::getPassword);
    }

    @Autowired EntityManager em;

    @Test
    void hibernate_schema_validation_passes() {
        // hibernate.hbm2ddl.auto=validate 在 Spring Boot test 默认下
        // 启动期就会校验所有 @Entity 与 Liquibase 跑出来的真实 schema 是否对齐
        assertThat(em).isNotNull();
    }
}

spring.jpa.hibernate.ddl-auto 设为 validate,相当于让 Hibernate 在 Spring 启动阶段对每张 @Entity 检查列、类型、约束是否和数据库一致——任何 Liquibase 漏改的字段、类型不一致,都会在测试启动时报错,把问题挡在 PR 阶段。


十五、清单速查#

把上面的问答压缩成一份「PR review 时可对照」的清单:

# 反模式 我更倾向的做法 主要依据
1 一个 db-* repo 单独管所有 MS 的 schema schema 跟服务代码住同一个 repo database per service
2 <includeAll> + 无序的 ticket 编号文件名 显式 <include> + 注释;或 <includeAll> 时强制 ISO 时间戳前缀 includeAll docs
3 id="1754317603906-1"author="x (generated)" id="TICKET-NNNN.X-意图"、author 用真名 Changeset docs
4 一个 changeSet 里好几种 DDL 混合 一个 changeSet 一件事,加 <comment> bestpractices
5 dbchangelog-latest.xsd pin 到具体版本(与 jar 同步) Issue #3120
6 8000 行 generated dump 当 init 手写每张表,序号化 ID (无单一来源,业内共识)
7 直接 nullable="false" + defaultValue="" 三步 expand-and-contract Thorben Janssen
8 大表上裸 modifyDataType / ALTER TABLE Percona ext 或显式 ALGORITHM=INPLACE,必要时 gh-ost liquibase-percona
9 没有 <rollback>、没有 tagDatabase 关键 changeSet 配 rollback;按 release 打 tag Liquibase Rollback
10 滥用 runOnChange="true" 改普通 DDL 仅用于 view / sproc / 配置表 runOnChange 官方文档
11 没有 context 区分 dev/prod 数据 context="dev,qa" + spring.liquibase.contexts Contexts vs Labels
12 多副本 Spring Boot 启动时跑 Liquibase init container / Job / sessionlock Liquibase + K8s
13 liquibase.properties 提交本机 IP / 路径 提交模板,凭证走 env / secret properties docs
14 CI 只跑 unit test,不跑 Liquibase validate + Testcontainers update + updateSQL 留痕 Pretius blog

进一步阅读#