Liquibase Split POC:把单体数据库迁移拆成三阶段的实战记录
目录
很多团队在做单体拆微服务时,第一反应都是先拆代码、拆接口、拆部署。但数据库层真正难的地方,往往不是“怎么建新表”,而是:
- 旧系统已经在线跑着,不能随便停
- Liquibase changelog 早就耦合成一个大文件
- 业务表和外键关系跨领域交织
- 拆服务后,数据库迁移责任边界也要跟着重画
这次我做了一个 Liquibase Split POC,目标不是做一个“看起来很对”的设计稿,而是把一条在本地跑通并验证过的渐进式迁移路径做出来:
完整 POC 代码及运行脚本见 GitHub: meirongdev/liquibase-split
- Phase 1:单体 + 单库 + 单 changelog
- Phase 2:还是单库,但 changelog 按服务拆开
- Phase 3:每个服务拥有自己的数据库
而且这次不是只写代码,我尽量把整个实验跑完整,并记录了每一步的迁移结果。
这次 POC 要解决什么问题#
如果一开始就要求把单体数据库“一刀切”拆成多个库,风险通常很高:
- Liquibase 历史已经存在,不能随便改 checksum
- 线上数据库里已经有数据,不能直接重建
- 跨服务 FK 会让拆库变得很痛苦
- 微服务代码和数据库边界很容易出现错位
所以这个 POC 的核心想法是 progressive split:
- 先把迁移职责拆开
- 再把数据库边界拆开
- 最后才进入真正的独立库阶段
和“先想理想架构,再一把切过去”相比,这条路径在现有项目里通常更容易落地。
三阶段目标#
| Phase | 数据库形态 | Liquibase 形态 | 关键目标 |
|---|---|---|---|
| Phase 1 | 单个 shopdb |
单体主 changelog | 先把完整单体 schema 跑起来 |
| Phase 2 | 仍然是 shopdb |
每个服务各自维护 changelog,DATABASECHANGELOG 隔离 |
零停机拆迁移边界 |
| Phase 3 | userdb / productdb / orderdb |
每个服务各自迁移到自己的库 | 完成数据库层拆分 |
这个模型里,Phase 2 是真正的关键。
很多文章会直接从单体跳到“每个服务一个库”,但实际项目里一个经常被忽略的点是:
按这次实验的体会,在真正拆库之前,Liquibase 的 ownership 往往就值得先拆开。
这次落地的项目结构#
POC 使用的是:
- Spring Boot 3.5
- Java 25
- Liquibase
- PostgreSQL
- Testcontainers
- Docker Compose
- Maven 多模块
模块拆分如下:
monolithuser-serviceproduct-serviceorder-service
其中领域模型非常克制,只保留了一个最小电商域:
usersproductsordersorder_itemspayments
这样既能覆盖跨域 FK,也不会让 POC 复杂到失控。
Phase 1:先把单体跑起来#
Phase 1 的目标很简单:
- 一个数据库
shopdb - 一个
public.databasechangelog - 一个单体应用
- 所有表都在
public - 所有跨域 FK 都存在
Schema 关系#
在这个阶段,结构是标准单体模式:
orders.user_id -> users.idorder_items.order_id -> orders.idorder_items.product_id -> products.idpayments.order_id -> orders.id
实际验证结果#
为了让后面 phase2/phase3 的数据迁移有可验证对象,我额外插入了一份最小业务数据:
users = 1products = 1orders = 1order_items = 1payments = 1
验证结果如下:
| 检查项 | 结果 |
|---|---|
| 单体服务启动 | 本机验证时 http://localhost:18080 返回 404,说明服务已正常启动 |
| FK 约束 | fk_orders_user_id、fk_order_items_order_id、fk_order_items_product_id、fk_payments_order_id 全部存在 |
| 数据行数 | users/products/orders/order_items/payments 各 1 行 |
这一步的意义不是复杂,而是为后面的迁移提供一个真实的起点。
Phase 2:不拆库,先拆 Liquibase ownership#
这是整个 POC 里我觉得最值得记录的部分。
设计关键点#
Phase 2 里,业务表仍然放在共享库 shopdb.public,但是每个服务不再共享同一套 Liquibase 历史表。
做法是:
user-service的DATABASECHANGELOG放在user_schemaproduct-service的DATABASECHANGELOG放在product_schemaorder-service的DATABASECHANGELOG放在order_schema
这意味着在 application-phase2.yml 中,我们要实现这种**“物理共享,逻辑隔离”**:
liquibase:
# 业务表依然在 public
default-schema: public
# 迁移历史被放到了各自服务的独立 schema
liquibase-schema: order_schema
parameters:
service_schema: public
也就是说,业务表仍然是共享的,变的只是迁移历史的 ownership。
这是这次实现里一个重要的设计点:
PostgreSQL schema 在 Phase 2 的主要用途,是隔离
DATABASECHANGELOG,不是把业务表提前拆走。这也意味着 Phase 2 理论上可以做成相对平滑的切换过程:服务可以一个个灰度启动,接管属于自己的表,而不用把所有改动一次性压到同一时刻。
为什么 user/product 用 changelogSync#
user-service 和 product-service 在 Phase 2 并不需要改业务表结构,它们只是要接管迁移责任。
所以最合理的动作不是 update,而是:
- 给自己的 schema 创建
databasechangelog - 把已有 changeset 标成已执行
也就是 changelogSync。
为什么 order-service 要真正跑 update#
order-service 在 Phase 2 不只是接管迁移历史,它还承担真正的 schema 调整:
- 去掉
orders.user_id -> users.id - 去掉
order_items.product_id -> products.id - 保留内部 FK:
order_items.order_id -> orders.idpayments.order_id -> orders.id
- 做一个 expand-contract 示例:
- 新增
orders.username - 回填数据
- 准备替换旧列
- 新增
这意味着 order-service 不能只做 changelogSync,而是需要真正执行迁移。
实际验证结果#
我跑完 ./scripts/phase1-to-phase2.sh 后,验证到了这些结果:
| 检查项 | 结果 |
|---|---|
user-service 可达 |
http://localhost:8081 返回 404 |
product-service 可达 |
http://localhost:8082 返回 404 |
order-service 可达 |
http://localhost:8083 返回 404 |
| changelog 隔离 | user_schema / product_schema / order_schema 均存在 databasechangelog 与 databasechangeloglock |
| changelog 记录数 | user_schema = 3、product_schema = 3、order_schema = 13 |
| public FK | 仅剩 fk_order_items_order_id、fk_payments_order_id |
orders 列结构 |
已存在 username,且 user_name 不在最终结构中 |
| 共享库数据量 | users = 1、products = 1、orders = 2、order_items = 2、payments = 2 |
这里的 orders/order_items/payments = 2,是因为:
- 一份来自我在 Phase 1 手动插入的数据
- 一份来自
order-service自带的 POC seed 数据
这正好验证了 Phase 2 的迁移不是“新建一套空表”,而是在已有共享库上继续推进。
Phase 3:每个服务拥有自己的数据库#
Phase 3 的目标是把共享库里的业务数据切到各自独立库:
userdbproductdborderdb
这一步怎么做#
我在脚本里做了下面几件事:
- 启动三个 PostgreSQL 实例
- 用 Liquibase Maven 插件把三个服务的 changelog 分别应用到各自数据库
- 清空目标库里的 demo seed 数据
- 流式搬迁数据:利用
psql \copy的 stdin/stdout 管道直接跨库搬运:
docker exec postgres-shared psql ... \copy (SELECT ...) TO STDOUT | \
docker exec user-db psql ... \copy users FROM STDIN
- 重置 sequence
- 启动三个服务
order-service 这边额外启用了 phase2,migration profile,用来表达:
- 共享库依然还在
- 目标库也已经接入
当前这个 POC 已经把 shared/target datasource wiring 接好了,也把数据搬运链路跑通了;
但如果你要把它演进成生产方案,还需要在业务写路径里继续补真正的 dual-write routing 和一致性校验。
也就是说,这个 POC 重点验证的是 Liquibase split + schema/data cutover,不是把所有生产级双写细节一次做满。
实际验证结果#
跑完 ./scripts/phase2-to-phase3.sh 后,我验证到了下面这组结果:
| 检查项 | 结果 |
|---|---|
user-service 可达 |
http://localhost:8081 返回 404 |
product-service 可达 |
http://localhost:8082 返回 404 |
order-service 可达 |
http://localhost:8083 返回 404 |
userdb.users |
1 行 |
productdb.products |
1 行 |
orderdb.orders |
2 行 |
orderdb.order_items |
2 行 |
orderdb.payments |
2 行 |
userdb.databasechangelog |
3 行 |
productdb.databasechangelog |
3 行 |
orderdb.databasechangelog |
13 行 |
orderdb FK |
仅剩 fk_order_items_order_id、fk_payments_order_id |
orderdb.orders 最终列 |
id, user_id, status, total_amount, created_at, username |
这里值得注意的一点是:在 orderdb 最终态里,user_id 依然被保留在 orders 表中,作为微服务之间的关联 ID(逻辑外键),但数据库级别的物理约束已经彻底消失,实现了物理层面的独立。
这说明几个关键点:
- 服务级 changelog 已经独立运行
- Phase 2 的共享库数据已经成功搬到独立库
- 跨服务 FK 没有被带到独立库
- expand-contract 结果在最终目标库结构里是闭合的
这次实施里踩到的几个坑#
如果只看设计稿,这些问题很容易被忽略;但一旦真的跑实验,很快就会撞上。
1. 本机没有 Liquibase CLI#
最开始脚本写的是直接调用 liquibase 命令,结果本机没有装 CLI。
后来的修正方案是:
- 改成直接调用 Liquibase Maven Plugin
- 这样只依赖本地 Maven 缓存和项目依赖
- 环境要求更低,也更适合放进项目脚本
这也是为什么最终脚本不再要求额外安装 Liquibase CLI。
2. 只用 pg_isready 不够#
PostgreSQL 容器初始化时,会出现一种很容易误判的情况:
pg_isready通过了- 但数据库其实还处于 init 流程的中间态
- 这时你马上跑 SQL,会看到
database system is shutting down
后来的修正是:
- 不再只看
pg_isready - 改成循环执行
SELECT 1 - 只有真正能连到目标数据库并执行查询,才继续往后跑
3. 脚本做了迁移,服务启动时又跑了一次 Liquibase#
这个坑也非常真实。
脚本已经先做了:
changelogSyncupdate
如果服务启动时不关掉 Spring 侧 Liquibase,应用还会再跑一次迁移,结果就是:
relation already existsduplicate key
最终修正是:
- 迁移脚本显式跑 Liquibase(通过 Maven Plugin)
- 服务启动时显式关闭 Spring 的 Liquibase 自动校验:
mvn spring-boot:run -Dspring-boot.run.arguments=--spring.liquibase.enabled=false
- 这样能尽量避免脚本执行完迁移后,服务启动又因为锁冲突或重复 checksum 校验而挂掉。
4. 记录后台 PID 时,不能只记外层 shell#
如果后台命令写成:
nohup bash -lc "mvn spring-boot:run" &
记录到的可能只是外层 shell PID,不是真正的 Maven 进程。
后面再切 phase2 -> phase3 时,你以为杀掉了旧进程,实际上 Java 还在,端口还被占着。
最终修正是:
- 直接记录
mvn spring-boot:run的后台 PID - phase2 / phase3 切换前先清理受控 PID
这个 POC 记录下来的几个观察#
做完整个实验后,我最确认的一点是:
按我的实验结果,Liquibase 拆分更适合放进拆库过程本身,而不是等拆库完成后再处理。
这个 POC 至少证明了三件事:
1. Phase 2 是这次实验里风险相对可控的过渡阶段#
如果没有“共享库 + changelog ownership 拆分”这个过渡阶段,直接从单体跳到独立库,会把 schema ownership、迁移历史、数据搬迁三件事同时叠加,风险太高。
2. 跨服务 FK 需要先处理,再谈拆库#
数据库拆分最先要动的,不是“把数据复制到新库”,而是先去掉那些会把领域边界锁死的跨服务 FK。
3. expand-contract 在数据库迁移里也适用#
很多人会把 expand-contract 只理解成 API 兼容技巧,但实际上它同样适用于数据库字段演进。
这次 orders.user_name -> orders.username 的 POC,就是一个很直观的例子。
这个 POC 还没有做到什么#
为了保持 POC 聚焦,这次没有把下面这些点做成完整生产方案:
- 真实业务写路径上的双写路由
- 双写失败补偿与重放
- 新旧库数据一致性巡检任务
- 切换后的 contract cleanup 自动化
但对一个 Liquibase split migration POC 来说,当前结果至少回答了我最关心的问题:
这条渐进式迁移路径是不是能真实落地、能不能被脚本化、跑完后数据库状态是不是符合预期?
答案是:在这个 POC 范围内,它是可以跑通的。
最后总结#
如果你正在把单体系统往微服务迁移,而且项目里已经用了 Liquibase,我会先问自己一个问题:
迁移文件的 ownership,什么时候拆?
如果答案是“等拆库之后再说”,那很可能已经太晚了。
更稳妥的做法通常是:
- 先让 monolith changelog 跑稳
- 再进入共享库下的 split changelog 阶段
- 最后才做独立数据库切换
这次 POC 的价值,不只是代码能跑通,而是把这条路径从“看起来合理”变成了“至少在本地完整跑过一遍”。
后面如果我继续把这个 POC 往前推,下一步最值得补的就是:
- 真正的 dual-write service layer
- 一致性校验任务
- 更真实的数据回填与回滚演练
但至少到这里,Liquibase split 这件事在这个仓库里已经不只是 PPT 设想,而是一条实际跑通过的工程路径候选。