很多团队在做单体拆微服务时,第一反应都是先拆代码、拆接口、拆部署。但数据库层真正难的地方,往往不是“怎么建新表”,而是:

  • 旧系统已经在线跑着,不能随便停
  • Liquibase changelog 早就耦合成一个大文件
  • 业务表和外键关系跨领域交织
  • 拆服务后,数据库迁移责任边界也要跟着重画

这次我做了一个 Liquibase Split POC,目标不是做一个“看起来很对”的设计稿,而是把一条在本地跑通并验证过的渐进式迁移路径做出来:

完整 POC 代码及运行脚本见 GitHub: meirongdev/liquibase-split

  1. Phase 1:单体 + 单库 + 单 changelog
  2. Phase 2:还是单库,但 changelog 按服务拆开
  3. 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 多模块

模块拆分如下:

  • monolith
  • user-service
  • product-service
  • order-service

其中领域模型非常克制,只保留了一个最小电商域:

  • users
  • products
  • orders
  • order_items
  • payments

这样既能覆盖跨域 FK,也不会让 POC 复杂到失控。


Phase 1:先把单体跑起来#

Phase 1 的目标很简单:

  • 一个数据库 shopdb
  • 一个 public.databasechangelog
  • 一个单体应用
  • 所有表都在 public
  • 所有跨域 FK 都存在

Schema 关系#

在这个阶段,结构是标准单体模式:

  • orders.user_id -> users.id
  • order_items.order_id -> orders.id
  • order_items.product_id -> products.id
  • payments.order_id -> orders.id

实际验证结果#

为了让后面 phase2/phase3 的数据迁移有可验证对象,我额外插入了一份最小业务数据:

  • users = 1
  • products = 1
  • orders = 1
  • order_items = 1
  • payments = 1

验证结果如下:

检查项 结果
单体服务启动 本机验证时 http://localhost:18080 返回 404,说明服务已正常启动
FK 约束 fk_orders_user_idfk_order_items_order_idfk_order_items_product_idfk_payments_order_id 全部存在
数据行数 users/products/orders/order_items/payments 各 1 行

这一步的意义不是复杂,而是为后面的迁移提供一个真实的起点


Phase 2:不拆库,先拆 Liquibase ownership#

这是整个 POC 里我觉得最值得记录的部分。

设计关键点#

Phase 2 里,业务表仍然放在共享库 shopdb.public,但是每个服务不再共享同一套 Liquibase 历史表。

做法是:

  • user-serviceDATABASECHANGELOG 放在 user_schema
  • product-serviceDATABASECHANGELOG 放在 product_schema
  • order-serviceDATABASECHANGELOG 放在 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-serviceproduct-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.id
    • payments.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 均存在 databasechangelogdatabasechangeloglock
changelog 记录数 user_schema = 3product_schema = 3order_schema = 13
public FK 仅剩 fk_order_items_order_idfk_payments_order_id
orders 列结构 已存在 username,且 user_name 不在最终结构中
共享库数据量 users = 1products = 1orders = 2order_items = 2payments = 2

这里的 orders/order_items/payments = 2,是因为:

  • 一份来自我在 Phase 1 手动插入的数据
  • 一份来自 order-service 自带的 POC seed 数据

这正好验证了 Phase 2 的迁移不是“新建一套空表”,而是在已有共享库上继续推进。


Phase 3:每个服务拥有自己的数据库#

Phase 3 的目标是把共享库里的业务数据切到各自独立库:

  • userdb
  • productdb
  • orderdb

这一步怎么做#

我在脚本里做了下面几件事:

  1. 启动三个 PostgreSQL 实例
  2. 用 Liquibase Maven 插件把三个服务的 changelog 分别应用到各自数据库
  3. 清空目标库里的 demo seed 数据
  4. 流式搬迁数据:利用 psql \copy 的 stdin/stdout 管道直接跨库搬运:
docker exec postgres-shared psql ... \copy (SELECT ...) TO STDOUT | \
docker exec user-db psql ... \copy users FROM STDIN
  1. 重置 sequence
  2. 启动三个服务

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_idfk_payments_order_id
orderdb.orders 最终列 id, user_id, status, total_amount, created_at, username

这里值得注意的一点是:在 orderdb 最终态里,user_id 依然被保留在 orders 表中,作为微服务之间的关联 ID(逻辑外键),但数据库级别的物理约束已经彻底消失,实现了物理层面的独立。

这说明几个关键点:

  1. 服务级 changelog 已经独立运行
  2. Phase 2 的共享库数据已经成功搬到独立库
  3. 跨服务 FK 没有被带到独立库
  4. 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#

这个坑也非常真实。

脚本已经先做了:

  • changelogSync
  • update

如果服务启动时不关掉 Spring 侧 Liquibase,应用还会再跑一次迁移,结果就是:

  • relation already exists
  • duplicate 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,什么时候拆?

如果答案是“等拆库之后再说”,那很可能已经太晚了。

更稳妥的做法通常是:

  1. 先让 monolith changelog 跑稳
  2. 再进入共享库下的 split changelog 阶段
  3. 最后才做独立数据库切换

这次 POC 的价值,不只是代码能跑通,而是把这条路径从“看起来合理”变成了“至少在本地完整跑过一遍”。

后面如果我继续把这个 POC 往前推,下一步最值得补的就是:

  • 真正的 dual-write service layer
  • 一致性校验任务
  • 更真实的数据回填与回滚演练

但至少到这里,Liquibase split 这件事在这个仓库里已经不只是 PPT 设想,而是一条实际跑通过的工程路径候选。