数据库 schema 演化:Flyway/Liquibase/Sqitch + PlantUML ER 图

puml.online

数据库 schema 演化是后端最常见的破坏源——加列、删列、改类型、改约束,每次都要规划「怎么不停机、回滚、数据迁移」。这篇是 Flyway/Liquibase/Sqitch 三种工具对比、ER 图维护、零停机迁移模式、大表改字段的七种策略。

三种主流工具

工具 风格 优势 劣势
Flyway SQL-only 简单、学习成本低、Java/Spring 集成深 复杂逻辑要写 Java migration
Liquibase XML/YAML/SQL 支持 rollback 子、跨数据库抽象 XML 冗长、复杂
Sqitch Perl/SQL 纯 SQL、verify 脚本可重跑 社区小

新项目推荐 Flyway——Spring Boot 集成最深,SQL-only 简单。

Flyway 目录约定

1
2
3
4
5
6
db/migration/
├── V1__create_users_table.sql
├── V2__create_orders_table.sql
├── V3__add_email_to_users.sql
├── V4__create_index_users_email.sql
└── V5__add_orders_status_column.sql

命名:V{version}__{description}.sql,版本号严格递增,描述要清晰。

基础 ER 图:用 PlantUML 反映 schema 当前状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@startuml
hide circle

entity "users" {
*id : BIGINT <<PK>>
--
*email : VARCHAR(255) <<UK>>
*password_hash : VARCHAR(255)
*name : VARCHAR(100)
*created_at : TIMESTAMP
*updated_at : TIMESTAMP
*deleted_at : TIMESTAMP <<nullable>>
}

entity "orders" {
*id : BIGINT <<PK>>
--
*user_id : BIGINT <<FK>>
*total_cents : INT
*status : VARCHAR(20)
*created_at : TIMESTAMP
*updated_at : TIMESTAMP
}

entity "order_items" {
*id : BIGINT <<PK>>
--
*order_id : BIGINT <<FK>>
*product_id : BIGINT <<FK>>
*quantity : INT
*price_cents : INT
}

entity "products" {
*id : BIGINT <<PK>>
--
*name : VARCHAR(200)
*sku : VARCHAR(50) <<UK>>
*price_cents : INT
*inventory_count : INT
}

users ||--o{ orders : "places"
orders ||--|{ order_items : "contains"
products ||--o{ order_items : "referenced by"

@enduml

约定:

  • * 必填
  • <<PK>> 主键
  • <<FK>> 外键
  • <<UK>> 唯一键
  • <<nullable>> 可空
  • -- 分隔键和属性

从 Flyway schema 读表结构生成 ER 图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# gen_er_from_flyway.py
import re, pathlib

# 从 V*.sql 文件提取 CREATE TABLE
schema_sql = ""
for f in sorted(pathlib.Path("db/migration").glob("V*.sql")):
schema_sql += f.read_text() + "\n"

# 解析 CREATE TABLE
tables = re.findall(
r'CREATE TABLE (\w+)\s*\((.*?)\);',
schema_sql, re.DOTALL,
)

# 输出 PlantUML ER
print("@startuml")
print("hide circle\n")
for table_name, columns_sql in tables:
print(f'entity "{table_name}" {{')
columns = re.findall(r'(\w+)\s+(\w+(?:\(\d+\))?)', columns_sql)
for i, (col_name, col_type) in enumerate(columns):
marker = "*" if "NOT NULL" in columns_sql.split("\n")[i] else ""
suffix = ""
if "PRIMARY KEY" in columns_sql.split("\n")[i]:
suffix = " <<PK>>"
elif "UNIQUE" in columns_sql.split("\n")[i]:
suffix = " <<UK>>"
print(f" {marker}{col_name} : {col_type}{suffix}")
print("}")
print("@enduml")

CI 跑这个脚本——每次 migration 都重新生成 ER 图,保证图跟 schema 同步

Flyway migration 写法

V1:建表

1
2
3
4
5
6
7
8
9
10
11
12
13
-- V1__create_users_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);

CREATE INDEX idx_users_deleted_at ON users(deleted_at)
WHERE deleted_at IS NOT NULL;

V3:加列

1
2
3
4
5
6
7
8
-- V3__add_email_to_users.sql
ALTER TABLE users ADD COLUMN phone VARCHAR(20);

-- 部分填充
UPDATE users SET phone = '+86-default' WHERE phone IS NULL;

-- 加 NOT NULL 约束(分两步:加可空 → 填默认 → 改 NOT NULL)
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;

V4:加索引

1
2
3
-- V4__create_index_users_email.sql
CREATE INDEX CONCURRENTLY idx_users_email_lower
ON users(LOWER(email));

CONCURRENTLY 不锁表——大表加索引必备。

V5:加外键

1
2
3
4
5
6
7
8
9
10
-- V5__add_orders_status_column.sql
ALTER TABLE orders ADD COLUMN status VARCHAR(20);

-- 加 CHECK 约束
ALTER TABLE orders ADD CONSTRAINT chk_orders_status
CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled'));

-- 加索引
CREATE INDEX CONCURRENTLY idx_orders_status
ON orders(status) WHERE status != 'cancelled';

零停机迁移模式

模式 1:扩展-迁移-收缩(Expand-Migrate-Contract)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@startuml
title Expand-Migrate-Contract Pattern

participant "App v1" as V1
database "DB" as DB
participant "App v2" as V2

== Phase 1: Expand (兼容老格式) ==
V1 -> DB : ① 加新列 phone (NULL 允许)
note right of V1
旧版本 V1 还能跑
(新列 NULL,不影响)
end note

== Phase 2: Migrate (数据迁移) ==
V1 -> DB : ② 一次性 backfill 旧数据\nUPDATE users SET phone = '+86-default'\nWHERE phone IS NULL
V1 -> DB : ③ ALTER COLUMN phone SET NOT NULL
note right of V1
数据库约束收紧
但应用还没用新列
end note

== Phase 3: Deploy V2 (双写) ==
V2 -> DB : ④ 部署新版本,同时写老格式 + 新格式
note right of V2
V2 双写,确保 V1/V2
都能正常工作
end note

== Phase 4: Migrate Read (V2 主导) ==
V2 -> DB : ⑤ 部署 V2.1,只从新列读
V1 -> DB : ⑥ 老版本彻底下线

== Phase 5: Contract (清理) ==
V2 -> DB : ⑦ DROP COLUMN 老列(如果存在)

@enduml

核心思想:每个阶段都可回滚——回滚到上一阶段不需要数据迁移。

模式 2:影子表(Shadow Table)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@startuml
title Shadow Table Pattern (rename column)

participant "App" as App
database "DB" as DB

== Step 1: 加新列 ==
App -> DB : ① ALTER TABLE users ADD COLUMN email_new VARCHAR(255)
App -> DB : ② CREATE TRIGGER sync_email\nBEFORE INSERT OR UPDATE ON users\nFOR EACH ROW EXECUTE FUNCTION copy_email();

== Step 2: 应用层双写 ==
note over App
App 写时同时写 email 和 email_new,
读时优先读 email_new,
老代码读 email 仍能用
end note

== Step 3: 数据迁移 ==
App -> DB : ③ UPDATE users SET email_new = email\nWHERE email_new IS NULL;

== Step 4: 切换应用 ==
note over App
部署新版本,只读 email_new
end note

== Step 5: 清理 ==
App -> DB : ④ DROP COLUMN email;
App -> DB : ⑤ DROP TRIGGER sync_email;

@enduml

适合:重命名列、改列类型(从 VARCHAR(50) 到 VARCHAR(255))、拆列(从 name 拆 first_name + last_name)。

模式 3:大表改字段(INPLACE vs COPY)

PostgreSQL ALTER TABLE ... ALTER COLUMN type 两种算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- ACCESS EXCLUSIVE 锁(默认)
ALTER TABLE users ALTER COLUMN phone TYPE VARCHAR(30);
-- 全表重写,锁表,大表(>1M 行)要 10+ 分钟

-- 不锁表(部分类型转换支持)
ALTER TABLE users ALTER COLUMN phone TYPE VARCHAR(30) USING phone::VARCHAR(30);
-- 同样全表重写

-- 真·不锁表:用 pg_repack
-- 1. 加新列
ALTER TABLE users ADD COLUMN phone_new VARCHAR(30);
-- 2. 触发器同步
-- 3. backfill
UPDATE users SET phone_new = phone;
-- 4. 切换
-- 5. 删旧列

大表改字段的七种策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@startuml
title Big Table ALTER Strategies

start

:加列 (ADD COLUMN);
note right: 11+ 秒锁表 → 改用 INSTANT (MySQL 8.0.12+)
:PG 11+ 加列默认不重写表

:删列 (DROP COLUMN);
note right: PG 11+ 不重写表,只标记

:改类型 (ALTER COLUMN TYPE);
note right: 全表重写,锁表\n→ 分批或 pg_repack

:加索引 (CREATE INDEX);
note right: 锁表 → CONCURRENTLY 不锁

:删索引 (DROP INDEX);
note right: 短锁 → CONCURRENTLY

:加 NOT NULL;
note right: 全表扫 → 先填默认再改

:加 FK (ADD CONSTRAINT FOREIGN KEY);
note right: 全表扫验证 → NOT VALID + VALIDATE

stop
@enduml

NOT VALID + VALIDATE 模式:

1
2
3
4
5
6
7
-- 第一步:加 FK 不验证(秒级)
ALTER TABLE orders ADD CONSTRAINT fk_orders_user
FOREIGN KEY (user_id) REFERENCES users(id)
NOT VALID;

-- 第二步:后台验证(只 SHARE UPDATE EXCLUSIVE 锁,可并发 DML)
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user;

Liquibase 的回滚能力

1
2
3
4
5
6
# changelog.yml
databaseChangeLog:
- include:
file: db/changelog/001-create-users.yaml
- include:
file: db/changelog/002-add-orders.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# db/changelog/001-create-users.yaml
databaseChangeLog:
- changeSet:
id: 1
author: alice
changes:
- createTable:
tableName: users
columns:
- column:
name: id
type: bigint
autoIncrement: true
constraints:
primaryKey: true
- column:
name: email
type: varchar(255)
constraints:
nullable: false
unique: true
rollback:
- dropTable:
tableName: users

rollback让 Liquibase 能 liquibase rollbackCount 1 自动回滚。

Flyway 没原生 rollback——要回滚自己写 U{version}__*.sql(undo migration)。

Sqitch:verify 脚本保证迁移幂等

1
2
3
4
5
-- sqitch.plan
%syntax-version=1.0.0

users 2026-01-15T10:00:00Z alice <email@example.com> # 添加 users 表
add_orders 2026-01-20T10:00:00Z alice <email@example.com> # 添加 orders 表
1
2
3
4
5
6
7
8
-- deploy/users.sql
CREATE TABLE users (...);

-- verify/users.sql
SELECT id, email FROM users WHERE FALSE;

-- revert/users.sql
DROP TABLE users;

verify/*.sql 是 Sqitch 独有——任何时候跑 sqitch verify 检查 schema 是否到位

迁移测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# tests/test_migrations.py
import subprocess, pytest

@pytest.fixture
def db():
"""fresh database"""
subprocess.run(["docker", "run", "--rm", "-d",
"--name", "pg-test", "-e", "POSTGRES_PASSWORD=test",
"-p", "5433:5432", "postgres:16"], check=True)
subprocess.run(["sleep", "2"], check=True)
yield "postgresql://postgres:test@localhost:5433/postgres"
subprocess.run(["docker", "rm", "-f", "pg-test"], check=True)

def test_full_migration(db):
"""所有 migration 跑完,schema 应该长这样"""
subprocess.run(["flyway", "-url", db, "migrate"], check=True)

# 用 sqlacodegen 拉真实 schema 对比 ER 图
actual_schema = subprocess.check_output(
["psql", db, "-c", "\\dt"]
).decode()
assert "users" in actual_schema
assert "orders" in actual_schema

def test_individual_migrations(db):
"""每个 migration 单独跑也能成功"""
for migration in sorted(MIGRATION_DIR.glob("V*.sql")):
# 重置 DB
subprocess.run(["flyway", "clean", "-url", db], check=True)
# 跑这一个
...

ER 图跟代码同步

架构漂移测试——读 schema,生成 ER,对比文档:

1
2
3
4
def test_er_diagram_matches_schema():
actual_tables = get_tables_from_db()
documented_tables = parse_puml("docs/er.puml")
assert actual_tables == documented_tables

CI 跑测试 → 加新表但没改 ER 图 → fail。

实战踩坑

  • Flyway clean 会 DROP ALL——生产环境永远不要跑本地/测试环境用
  • Migration 不能改——已部署的 V1__ 不能改,只能新增 V2__ 修正
  • ADD COLUMN NOT NULL 不写默认——大表 + NOT NULL = 全表更新,超长锁表分两步:加可空 → UPDATE 填默认 → 改 NOT NULL
  • SERIAL 类型——PG 10+ 用 BIGINT GENERATED ALWAYS AS IDENTITY,SERIAL 还能用但不推荐。
  • Liquibase XML 缩进错——一改就 syntax error,用 YAML 简洁
  • 多数据库类型抽象陷阱——Liquibase 的 BOOLEAN 在 MySQL 变 TINYINT(1),类型差异反而麻烦直接 SQL 更可控
  • migration 跑半截失败——事务没提交,migration 表状态不一致。每个 migration 单独文件 + 单事务

决策树

1
2
3
4
5
6
7
8
9
10
11
要做什么?
├─ 新项目 → Flyway + SQL-only
├─ Java/Spring Boot → Flyway(深度集成)
├─ 需要自动回滚 → Liquibase
├─ 多数据库兼容(Oracle/PG/MySQL) → Liquibase
└─ 纯 SQL + verify → Sqitch

migration 大小?
├─ 小 (<10 万行) → ALTER TABLE 直接改
├─ 大 (>100 万行) → 加新列 → 双写 → 切换 → 删旧列
└─ 超大 (>1 亿行) → pt-online-schema-change / pg_repack / gh-ost

最小配置:Flyway + db/migration/V*.sql + CI 跑 flyway migrate + 单独的 gen_er.py 同步 ER 图。大表改 schema 时永远先想三件事:能不能分批?能不能不停机?能不能回滚?

  • 标题: 数据库 schema 演化:Flyway/Liquibase/Sqitch + PlantUML ER 图
  • 作者: puml.online
  • 创建于 : 2026-07-30 17:35:00
  • 更新于 : 2026-08-14 21:34:29
  • 链接: https://puml.online/blog/plantuml-database-schema-migration/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。