Liquibase (XML) 在微服务里的实践记录:一份问答式整理
目录
本文用一组「问答」拆解 Liquibase(XML 格式)在 Spring Boot 微服务下我更倾向采用的一些实践经验。文中的反例都来自真实可见的代码风格——表名、字段、author 全部脱敏,不指向任何具体项目。每条做法尽量给出官方文档或社区可访问的来源。
一、Schema 应该住在哪个 repo?#
Q:为什么我现在不太倾向继续用一个独立的 db-* 仓库(或子模块)单独保存所有 MS 的 schema?
历史上常见的做法是开一个 database-foo、database-bar 的项目,里面只放 liquibase.properties + 一堆 change-log/*.xml,跑 mvn liquibase:update 完成迁移。问题是:
- schema 和服务代码不在同一个生命周期里。MS 加了一个
entity_id字段,PR 在服务 repo;migration 在另一个 repo。两个 PR 难以原子合并,code review 也只能看半边。 - CI 触发不一致。服务 repo 的测试不会跑 migration repo 的最新变更;migration repo 的 CI 也很难拿到服务的 entity 类去做 schema-vs-entity 的 sanity check。
- 不利于 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 才是合理的?
满足两个条件:
- 强约定的文件名前缀——比如 ISO 时间戳
20260426T103000-add-orders-status.xml,Liquibase 官方 FAQ 直接建议 “prepend the file containing the changeset with a data timestamp”。 - 目录内的 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 系列,包括 ChangesetCommentCheck 和 ChangesetContextCheck,可以让 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))的createTable、addPrimaryKey、createIndex。 - ID 是时间戳式自增(
1754317603906-1到-37)。 - 列定义里会出现奇怪的默认值,比如
defaultValueNumeric="0E-8"(生成器把0.00000000转成科学计数法)。 - 同一个文件里 type 大小写不一致(
TINYINT(3)和tinyint混用,因为不同表导出时取了不同的格式化路径)。
这种文件不可读、不可改、不可分——你不能从中间删除一个表(中间被删了,新环境就建不出来)。一旦它进入 git,本质上变成了一份「只能追加」的合约。
我目前更倾向的一条路径:
- 真正的初始 schema 自己手写。一个微服务初次上线的 schema 通常 5–15 张表,手写一遍有助于:自己想清楚命名规范、索引策略、字符集、引擎选项。
- 每张表一个 changeSet,ID 从
0001-init-orders这种序号开始。 - 不要把生产数据迁移到
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-downtime 与 Liquibase 官方 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:怎么走「在线变更」?
两种主流方案:
-
Liquibase Percona Extension(GitHub / 官方教程)。装上之后,原本会执行
ALTER TABLE的 changeSet 会自动改用pt-online-schema-change在影子表上变更然后切换;锁表时间从分钟级降到毫秒级。 -
<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(createTable、addColumn 等)提供了自动回滚——文档 What is a rollback 列举了支持的列表。但是:
dropTable、sql、dropColumn这些不可自动反推——必须自己写<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」区分开。
十一、contexts 与 labels:环境维度的开关#
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 系统性梳理过几个症状:
- 滚动发布时多 pod 同时启动 → 同时尝试拿
DATABASECHANGELOGLOCK表的锁;只有一个能拿到,其余 pod 在 readiness 探针失败前就 hang 住。 - Liquibase 跑了一半 pod 被 K8s liveness probe kill →
LOCKED=1留在表里,下次启动所有 pod 永远拿不到锁。liquibase release-locks是手动救命操作。 - migration 时间长 → 拖累整个滚动发布的速度。
Q:怎么解决?
主流方案三选一:
- Init container(Liquibase 官方推荐)。把 Liquibase 跑在 init container 里,主容器启动前就完成。Init container 不受 livenessProbe 影响,不会被 kill。Spring Boot 里设
spring.liquibase.enabled=false。 - Job/Helm hook。把迁移做成 K8s
Job或 Helmpre-install/pre-upgradehook,集群里至多一个迁移 Pod,应用层全部enabled=false。 liquibase-sessionlockextension。把基于行的锁换成 MySQLGET_LOCK()/ Postgrespg_advisory_lock(),pod 异常退出时连接断开即自动释放,避免「LOCKED=1 锁死」。
反例:每个 MS 都默认开启 Liquibase 启动时迁移 + 多副本部署 + 没装 sessionlock extension——典型的「平时没事,一次发布卡 30 分钟」组合。
Q:在做 schema 改动前,能不能让所有应用先 disable Liquibase?
可以。两步走:
- 应用 deployment 加
spring.liquibase.enabled=false,全部 rollout 一次。 - 用
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 + Liquibase 与 My 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 |
进一步阅读#
- 官方:Liquibase Best Practices FAQ
- 官方:Liquibase 4.33.0 release notes
- 社区:Reflectoring – One-Stop Guide to Database Migration with Liquibase and Spring Boot
- 社区:NashTech – Best Practices for Managing Liquibase ChangeLogs
- 社区:Backbase Engineering – Liquibase as an Init Container
- 社区:Percona – Zero downtime schema change with Liquibase & Percona
- 工具:liquibase/liquibase-percona · github/gh-ost · coenvk/liquibase-zd · blagerweij/liquibase-sessionlock