8.1 Schema 变更

数据库 Schema 变更管理深度实践:Flyway 与 R2DBC 的双连接集成配置、迁移脚本 V{version}__desc 命名规范、baseline 标记现有数据库、已部署脚本不可变原则、手写补偿回滚、测试环境用户隔离与 Testcontainers 方案。

为什么需要 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 问题、批量查询。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南