为什么需要 Schema 迁移
手动执行 CREATE TABLE 和 ALTER TABLE 在团队协作中不可持续:
- 开发、测试、生产环境的 schema 状态容易不一致
- 多人同时修改表结构会产生冲突
- 没有版本记录,无法回滚或审计
- 新成员加入时无法重建完整数据库
Schema 迁移工具把数据库变更视为版本化的代码,像管理 Java 源码一样管理 DDL。
Flyway 集成
Flyway 支持通过 JDBC 连接执行迁移。R2DBC 不直接兼容 Flyway,需要双连接配置:
# application.yml
spring:
r2dbc:
url: r2dbc:postgresql://localhost:5432/eventd
username: eventd
password: eventd
flyway:
url: jdbc:postgresql://localhost:5432/eventd # Flyway 用 JDBC
user: eventd
password: eventd
locations: classpath:db/migration
baseline-on-migrate: true
Maven 依赖:
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
Spring Boot 自动配置会在应用上下文初始化阶段运行 Flyway 迁移,早于开始处理请求。不要假设它和 R2DBC 连接池的创建有严格先后关系:两者是独立的 Bean,池通常按需创建,迁移是否先完成取决于依赖关系,必要时显式声明 @DependsOn。
迁移脚本规范
脚本放在 src/main/resources/db/migration/:
db/migration/
├── V1__create_users_table.sql
├── V2__create_orders_table.sql
├── V3__add_user_email.sql
└── V4__create_inventory_table.sql
命名规则:V{version}__{description}.sql
| 部分 | 规则 |
|---|---|
V | 版本前缀,固定大写 |
version | 数字,可用点分隔(如 V1.2.3) |
__ | 双下划线分隔符 |
description | 描述,下划线代替空格 |
.sql | 扩展名 |
V1__create_users_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(200) UNIQUE,
status VARCHAR(20) DEFAULT 'ACTIVE',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_status ON users(status);
V2__create_orders_table.sql
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
symbol VARCHAR(20) NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
price DECIMAL(18, 8) NOT NULL,
status VARCHAR(20) DEFAULT 'PENDING',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created_at ON orders(created_at);
Baseline 与现有数据库
如果数据库已有表结构,Flyway 首次运行会因版本表(flyway_schema_history)不存在而失败。使用 baseline:
# 命令行(开发环境一次性操作)
mvn flyway:baseline -Dflyway.baselineVersion=2 -Dflyway.baselineDescription="Existing schema"
# 或配置自动 baseline(properties 形式)
spring.flyway.baseline-on-migrate=true
spring.flyway.baseline-version=4 # 改成现有数据库已经到达的最高版本
mvn flyway:* 需要先引入 flyway-maven-plugin;只加 flyway-core 时这些命令不可用,可在 pom.xml 的 plugin 段声明版本后再用。
baseline-on-migrate: true 会在首次迁移时,把当前数据库状态标记为 baseline,然后执行版本号大于 baseline 的脚本。因此 baseline-version 必须等于现有数据库已经到达的最高版本:如果库里已有 V1~V4 的表结构,却把 baseline 设成 1,Flyway 仍会尝试执行 V2~V4,导致表已存在而失败。拿不准时先 flyway:info 或人工核对,再设置 baseline。
变更不可变原则
迁移脚本一旦部署到共享环境(测试/生产),永远不可修改。如果需要修正:
❌ 错误:修改 V2__create_orders_table.sql
✅ 正确:新建 V5__fix_orders_constraint.sql
-- V5__fix_orders_constraint.sql
ALTER TABLE orders
ADD CONSTRAINT chk_orders_positive CHECK (price > 0);
这个原则保证所有环境按相同顺序执行相同脚本,事务日志可审计。
回滚策略
Flyway 没有内置回滚。社区版只支持「向前修复」:
-- V6__revert_email_unique.sql
-- 撤销 V1 的 UNIQUE 约束(业务需求变更)
ALTER TABLE users DROP CONSTRAINT users_email_key;
如果需要真正的 undo 能力,Flyway Teams 版支持 U{version}__{description}.sql undo 脚本,或手写补偿脚本。
eventd v2 的回滚策略:
- 开发环境:直接
flyway:clean+flyway:migrate重建 - 测试环境:新建反向迁移脚本(如
V6__revert_xxx.sql) - 生产环境:不在线回滚 schema,先回滚应用代码,再计划窗口执行反向迁移
测试环境隔离
每个开发者需要独立的数据库实例,避免迁移冲突:
# application-test.yml
spring:
r2dbc:
url: r2dbc:postgresql://localhost:5432/eventd_test_${USER}
flyway:
url: jdbc:postgresql://localhost:5432/eventd_test_${USER}
clean-disabled: false
${USER} 是系统环境变量,每个开发者有独立数据库。测试完成后可以 flyway:clean 清理。
Testcontainers 是更好的方案(第 13.2 节):
@Testcontainers
class OrderRepositoryTest {
@Container
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:17")
.withDatabaseName("eventd_test")
.withUsername("eventd")
.withPassword("eventd");
@DynamicPropertySource
static void r2dbcProperties(DynamicPropertyRegistry registry) {
registry.add("spring.r2dbc.url",
() -> "r2dbc:postgresql://" + POSTGRES.getHost() + ":"
+ POSTGRES.getMappedPort(5432) + "/eventd_test");
registry.add("spring.r2dbc.username", POSTGRES::getUsername);
registry.add("spring.r2dbc.password", POSTGRES::getPassword);
registry.add("spring.flyway.url", POSTGRES::getJdbcUrl);
registry.add("spring.flyway.user", POSTGRES::getUsername);
registry.add("spring.flyway.password", POSTGRES::getPassword);
}
}
@Container 由 JUnit 扩展负责启动和停止;把容器声明成普通 @Bean 并手动 start(),容器不会随测试类结束而停止,只能等 JVM 退出后由 Testcontainers 的 Ryuk 回收,测试之间也容易共享脏状态。
验证迁移
# 查看待执行的迁移
mvn flyway:info
# 验证脚本有效性(不执行)
mvn flyway:validate
# 手动执行迁移
mvn flyway:migrate
# 清理(仅测试环境!)
mvn flyway:clean
eventd v2 Schema 设计
eventd v2 的完整 schema(把前面的 V1/V2 合并成单文件展示;实际项目里只保留一套 V1,不要和上面的脚本同时使用):
-- V1__init_schema.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(200) UNIQUE,
status VARCHAR(20) DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
symbol VARCHAR(20) NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
price DECIMAL(18, 8) NOT NULL,
status VARCHAR(20) DEFAULT 'PENDING',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE inventory (
id BIGSERIAL PRIMARY KEY,
symbol VARCHAR(20) UNIQUE NOT NULL,
quantity INTEGER NOT NULL DEFAULT 0,
reserved INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_events_type ON events(type);
CREATE INDEX idx_events_created_at ON events(created_at DESC);
小结
| 能力 | 命令/配置 | 注意 |
|---|---|---|
| 自动迁移 | spring.flyway.enabled=true | 应用启动时自动执行 |
| 脚本命名 | V{version}__{description}.sql | 双下划线分隔 |
| 现有数据库 | baseline-on-migrate=true | 标记当前状态为起点 |
| 不可变原则 | 已部署脚本不修改 | 只新建正向脚本 |
| 回滚 | 社区版无内置回滚 | 手写补偿脚本或使用 Teams 版 |
| 测试隔离 | ${USER} 后缀或 Testcontainers | 避免共享数据库冲突 |
下一节 8.2 查询优化 :索引策略、EXPLAIN ANALYZE、N+1 问题、批量查询。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。