楼层: 首页/ 软件技术/ 数据库三剑客/ 数据库迁移与版本化:Flyway / Liquibase
11

数据库迁移与版本化:Flyway / Liquibase

Schema Migration & Version Control · Flyway / Liquibase

代码有 Git,配置有 Git,可数据库的表结构呢?很多团队的表结构活在 DBA 的电脑里、活在某个人手动敲的 SQL 里、活在"上个月那次紧急上线我改过但忘了记"里。结果是:新同事拉下代码跑不起来、测试环境少一个字段、生产环境多一个索引,而且谁都不敢删那个没人认识的列。这一章讲怎么把表结构也纳入版本控制——让"改数据库"变成一次可评审、可回滚、可重放、可审计的代码提交。两个主流工具:Flyway(简单直接)和 Liquibase(强大抽象)。

11.1 先看清问题:手工改库的四种典型事故

为什么"用 SQL 客户端手动改一下"迟早会出事
事故怎么发生的版本化之后怎么解决
环境漂移开发在本地加了列,测试环境手动加了,生产忘了加。上线报 Unknown column迁移脚本随代码走,各环境自动执行同一份脚本,结构与代码版本一一对应
新人跑不起来新人克隆代码,跑建表脚本,缺了最近半年的十几个 ALTER,本地一堆报错从空库按顺序重放全部脚本,一次就得到与生产一致的结构
无法回滚改错了只能靠记忆写反向 SQL,边写边冒汗Liquibase 可自动生成回滚;Flyway 用"向前修复(forward-only)"策略——回滚靠新脚本而不是撤旧脚本
无法审计三个月后没人记得那个索引是谁加的、为什么加,删也不敢删每次结构变更都是一次带提交信息、评审记录、执行时间记录的变更

论核心思想:把"改结构"当"改代码"

迁移工具的模型其实非常朴素:

① 一组有序的迁移脚本(脚本就是资源文件,跟着代码进 Git)。

② 数据库里一张"已执行记录"表(Flyway 叫 flyway_schema_history,Liquibase 叫 databasechangelog)。

③ 启动时比对:把"脚本清单"和"已执行清单"对一下,只执行没跑过的那些,并且按顺序执行。

就这么三件事。理解了它,你就明白两条铁律:已经执行过的脚本不能再改内容(否则"已执行的校验和"对不上,工具会直接报错拒绝启动);表结构只能向前走,不能靠删历史脚本解决。

11.2 Flyway:约定优于配置的 SQL 迁移

Flyway 是最好上手的迁移工具,因为它几乎不要求你学新东西——迁移脚本就是普通的 SQL 文件,只是命名要守规矩。(当前稳定版本 Flyway 11.x,以官网为准;社区版免费,企业版才有undo撤销等特性。)

# 脚本命名规则:版本号__描述.sql(两个下划线!) src/main/resources/db/migration/ ├── V1__init_schema.sql # V = 版本化迁移,按版本号顺序执行,只执行一次 ├── V2__add_user_phone.sql ├── V2.1__add_phone_index.sql # 支持小数版本号 ├── V20260920__add_coupon_table.sql # 也常用日期当版本号 ├── R__refresh_report_view.sql # R = 可重复执行:校验和变化时重跑(适合视图/存储过程) └── U2__undo_add_user_phone.sql # U = 撤销脚本(企业版功能,社区版不支持)

迁移脚本怎么写(注意:写的时候就要考虑"生产上的大表")

-- V1__init_schema.sql:初始建表 CREATE TABLE users ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- V2__add_user_phone.sql:加字段。注意新列必须允许 NULL 或给默认值, -- 否则已有数据的表加 NOT NULL 列会直接失败 ALTER TABLE users ADD COLUMN phone VARCHAR(20) NULL AFTER name; -- V3__add_user_phone_unique.sql:先补数据,再加约束(同一脚本里两步) UPDATE users SET phone = CONCAT('1000000', id) WHERE phone IS NULL; CREATE UNIQUE INDEX uk_users_phone ON users (phone);

Spring Boot 集成:加依赖 + 配置,启动时自动迁移

# application.yml spring: datasource: url: jdbc:mysql://localhost:3306/shop?serverTimezone=Asia/Shanghai username: app password: ${DB_PASSWORD} flyway: enabled: true locations: classpath:db/migration # 脚本位置 baseline-on-migrate: true # 对已有的老库:把当前状态记为基线,不动存量 baseline-version: "1" validate-on-migrate: true # 启动时校验校验和(生产务必开启,能立刻发现有人偷改脚本) out-of-order: false # 禁止乱序执行,避免多人并行开发时结构错乱 clean-disabled: true # 禁用 clean(清库),防止有人手滑把生产清空
# 也可以不靠应用启动,单独用命令行或 Maven 插件跑迁移(上线流程里更可控) # Maven mvn flyway:info # 看哪些脚本待执行、哪些已执行、哪些校验和变了 mvn flyway:migrate # 执行所有待执行脚本 mvn flyway:validate # 只校验不改动(CI 里跑这个当门禁) mvn flyway:repair # 修复元数据表(比如失败记录,或你确认过的手工改动) # 命令行(CI/CD 流水线常用,版本号以官网为准) flyway -url=jdbc:mysql://db:3306/shop -user=app -password=*** -locations=filesystem:./sql migrate

论flyway_schema_history 这张表你必须认识

Flyway 执行过的每个脚本都会在这张表里留一行:版本号、描述、类型、脚本名、校验和(checksum)、执行人、执行耗时、成功与否。

它带来两个极有用的能力:① 启动时先比对校验和——如果有人把已经执行过的脚本改了内容,Flyway 会直接抛 Migration checksum mismatch 并拒绝启动应用(这是特意的"刺耳失败",比默默带着错误结构跑要好得多);② 它是"当前数据库处于哪个版本"的唯一权威答案,排障时先看它。

还有一条必须知道的:MySQL 8.0 的 DDL 是原子的(一条 ALTER 要么成功要么回滚),但一个脚本里的多条 DDL 不是原子的。如果 V3 的第 2 条 SQL 执行失败了,脚本会记成"失败"状态,你修完脚本后必须先 repair 清掉那条失败记录,才能再执行。所以一个迁移脚本里最好只做一件事。

11.3 Liquibase:用 changeset 描述"我要什么"

Flyway 是"我直接写 SQL",Liquibase 是"我说我要什么变更,它翻译成各数据库的 SQL"。这个抽象让同一套 changelog 能跑在 MySQL、PG、Oracle 上(跨库迁移场景很有价值),并天然支持回滚、前置条件、上下文(按环境决定执行哪些变更)。(当前稳定版本 Liquibase 4.x,以官网为准。)

论三个核心概念:changelog / changeset / changeType

changeset:最小执行单元,用 id + author 唯一标识(它俩+文件名一起决定这条变更的身份)。一个 changeset 要么整体执行成功,要么整体失败。

changelog:一个有序的 changeset 列表(可以是主文件 include 多个子文件),也就是"迁移清单"。

changeType:具体动作,如 createTable、addColumn、createIndex。它由 Liquibase 翻译成数据库方言的 SQL——这就是跨库能力的来源,也是"不够灵活"的来源(要写复杂 SQL 时还得用 sql 类型嵌原生 SQL)。

用 YAML 写 changelog(也可以选 XML / JSON / SQL)

# db/changelog/db.changelog-master.yaml —— 主文件负责 include databaseChangeLog: - include: file: db/changelog/001-init-users.yaml - include: file: db/changelog/002-add-phone.yaml
# db/changelog/002-add-phone.yaml databaseChangeLog: - changeSet: id: 002-add-phone-to-users author: mj # 前置条件:只有表存在时才执行,避免在不该跑的库上乱动 preConditions: - onFail: HALT - tableExists: { tableName: users } changes: - addColumn: tableName: users columns: - column: name: phone type: varchar(20) remarks: 手机号 - createIndex: tableName: users indexName: idx_users_phone columns: - column: { name: phone } # 回滚定义:可以让 Liquibase 反向执行,也可以显式写 rollback: - dropIndex: { tableName: users, indexName: idx_users_phone } - dropColumn: { tableName: users, columnName: phone }
# Spring Boot 集成 spring: liquibase: enabled: true change-log: classpath:db/changelog/db.changelog-master.yaml contexts: dev,test # 上下文:只跑带 dev/test 标签的 changeset drop-first: false # 千万别在生产开!会清空数据库 # 命令行 liquibase --changeLogFile=db/changelog/db.changelog-master.yaml update liquibase --changeLogFile=db/changelog/db.changelog-master.yaml rollback-count 1 # 回滚最近 1 个 changeset liquibase --changeLogFile=db/changelog/db.changelog-master.yaml status --verbose

需要写复杂 SQL 时:用 sql 类型嵌原生语句(但要注意这是"不可移植"的)

- changeSet: id: 003-split-name author: mj changes: - sql: dbms: mysql # 只对 MySQL 生效,其他库可另写一份 splitStatements: false # 含存储过程等复杂语句时关掉自动切分 sql: |- UPDATE users SET display_name = CONCAT(last_name, first_name) WHERE display_name IS NULL;

11.4 Flyway 还是 Liquibase:一张表说清

两个工具的取舍
维度FlywayLiquibase
迁移脚本形态纯 SQL(也有 Java 迁移)XML / YAML / JSON / SQL,抽象成 changeType
学习成本极低——会写 SQL 就会用要学 changeset / preConditions / contexts 等概念
跨数据库要自己写各库方言(或分目录)强项:同一份 changelog 可跑多库(用抽象 changeType 时)
回滚社区版不支持 undo,走"向前修复":写一个新脚本改回去支持 rollback,可自动生成反向语句或显式定义
复杂 SQL 表达力强(就是原生 SQL,窗口函数、存储过程随便写)弱一些,复杂逻辑要退回 sql 类型,此时跨库优势也没了
执行记录表flyway_schema_history(含校验和)databasechangelog + databasechangeloglock
适合谁单一数据库(比如就用 MySQL)的团队、想快速上手、SQL 水平好多数据库支持需求、合规/审计要求回滚能力、团队更愿意接受"描述式"配置

选怎么快速定

只有一个数据库、团队 SQL 熟练 → Flyway。它就是"把 ALTER 语句收进 Git 并按顺序自动执行",几乎零心智负担,是目前 Java 生态的主流选择。

要同时支持多种数据库、或者必须能回滚 → Liquibase。尤其是产品需要交付给客户、由客户在自己机房的 Oracle / SQL Server 上部署时,抽象层的价值就体现出来了。

两个都不选的前提是:你的库很小、只有一个人维护、并且你愿意接受"某天发现测试库和生产库结构不一样"的惊喜。一般不建议。

11.5 生产实践:迁移脚本的五条纪律与四个坑

纪五条必须守的纪律

① 已执行的脚本永不修改。校验和不匹配会让应用拒绝启动。要改结构就新增一个更高版本的脚本。这是最重要的一条。

② 一个脚本只做一件事。方便失败时判断"执行到哪了",也方便单独回滚或临时跳过。

③ 结构变更和数据变更分开。把 ALTER TABLE 和几百万行的 UPDATE 写在同一个脚本里,一旦数据更新耗时太长或超时,整个脚本会被标记失败,结构也卡在半路。

④ 变更必须"向前兼容"(expand-contract)。因为发布过程中新旧版本的代码会同时运行(滚动发布),所以改字段要分三步走,不能一步到位。

⑤ 迁移脚本进 CI 门禁。每次 PR 都在一个干净的空库上完整跑一遍全部脚本(flyway:validate + 迁移 + 冒烟查询)。这样能在合并前就发现"新人跑不起来"的问题。

expand-contract:改字段名的安全三步法(举例:把 username 改名成 user_name)

-- 第 1 步(expand,与旧代码兼容):新增新列,允许为空 ALTER TABLE users ADD COLUMN user_name VARCHAR(50) NULL; -- 此时应用"双写":新代码同时写 username 和 user_name,读仍读 username -- 第 2 步(数据回填,单独一个脚本,分批做):把老数据搬过去 UPDATE users SET user_name = username WHERE user_name IS NULL LIMIT 5000; -- 大表不要一条 UPDATE 干到底:拆成多个脚本 / 用 pt-archiver / gh-ost 分批, -- 否则长事务锁表 + binlog 暴涨,线上直接出事故 -- 第 3 步(contract,等所有旧版本实例都下线之后):切读、停写旧列、删列 ALTER TABLE users ALTER COLUMN user_name SET NOT NULL; CREATE UNIQUE INDEX uk_users_name ON users (user_name); -- 确认没有任何代码再用 username 之后,再单独一个脚本: ALTER TABLE users DROP COLUMN username;
迁移的四个真实坑

① 大表加索引把库锁了。MySQL 8.0 支持 Online DDL(加索引一般 ALGORITHM=INPLACE 不锁表),但有些操作(如改列类型、加 NOT NULL 又无默认值、修改字符集)会退化成拷贝整表,几千万行的表能锁几分钟到几十分钟。上线前必须确认执行方式(看 ALTER 时的算法),必要时用 gh-ost / pt-online-schema-change 在线改。

② 有人手工改过生产库。迁移一跑就报错(列已存在 / 校验和不对)。不要删表重建,也不要改历史脚本。正确做法:写一个幂等的修复脚本(ADD COLUMN IF NOT EXISTS 之类,MySQL 不支持的话就先查 information_schema 再决定),或者用 flyway:repair / liquibase clearCheckSums 修正元数据(这段操作要在评审后进行)。

③ 生产开过 clean / drop-first。配置里 clean-disabled: true、drop-first: false 是保命配置,一定要在生产 profile 里显式打开。历史上不止一个团队在改配置时误把生产库清空。

④ 多实例同时启动跑迁移。Kubernetes 里一次起 10 个 Pod,10 个进程同时尝试执行迁移,容易互相打架(好在工具都会用锁表——Flyway 靠元数据表的锁、Liquibase 靠 databasechangeloglock,但依然建议把迁移拆成独立的 Job 或 initContainer 单独执行一次,别让业务进程兼任)。

记
本章小结

① 表结构也要版本化。手工改库换来的短期方便,代价是环境漂移、新人跑不起来、无法回滚、无法审计。

② 迁移工具的模型只有三件事:有序脚本 + 已执行记录表 + 启动时比对补齐。

③ Flyway 是"把 SQL 收进 Git",简单直接,单库场景首选;Liquibase 是"描述变更 + 自动翻译 + 支持回滚",跨库和合规场景更合适。

④ 铁律:历史脚本永不修改,改结构只能加新版本脚本。

⑤ 大表变更走 expand-contract(加新列 → 双写回填 → 切读删旧列),并用 gh-ost 之类工具做在线 DDL,别在生产上直接来一条阻塞式 ALTER。