本节目标:把结构迁移与数据初始化拆成两条线,掌握多环境脚本的组织方式,并确定开发、测试、生产三套环境各自的迁移执行策略。
适用版本:Spring Boot 4.1.x(Java 21)
15.3 多环境数据管理
15.2 结束时,图书服务已经有一套能演进的迁移脚本了。但还有一个问题没解决:本地开发时,book 表建好了却是空的,每次都得手动插几条测试数据。 更麻烦的是,同一个项目要跑在开发、测试、生产三套环境上,它们对「脚本」和「数据」的需求完全不同。
这一节把这两件事彻底分开:结构用 Flyway 管,数据用另一条线管,再按环境把它们组合起来。
15.3.1 为什么结构与数据必须分开
先看一个反面例子。有人图省事,把开发用的测试数据直接写进迁移脚本:
-- ❌ 不要这样做
-- V3__add_sample_books.sql
INSERT INTO book (isbn, title, author, price, stock)
VALUES ('9787115428028', '深入理解计算机系统', 'Randal E. Bryant', 139.00, 12);
问题在于:迁移脚本是结构定义,它会在所有环境执行,包括生产。这段 INSERT 会在生产的 book 表里凭空插入一条「深入理解计算机系统」。而且因为 V 脚本只执行一次,这条脏数据会永远留在那里,删也不是、留也不是。
结构与数据的本质差异:
| 维度 | 结构(schema) | 数据(data) |
|---|---|---|
| 变更频率 | 低,随版本发布 | 高,开发时反复调 |
| 是否可重复执行 | 否(CREATE TABLE 只跑一次) | 是(重建即可) |
| 各环境是否相同 | 是,结构必须一致 | 否,开发有假数据、生产没有 |
| 是否需要留痕 | 是,历史表记录 | 否,用完即弃 |
| 归谁管 | Flyway | data.sql / 测试夹具 |
结论很清晰:Flyway 只管结构,数据初始化走另一条路。 Spring Boot 内置的 spring.sql.init 就是为后者准备的。
15.3.2 spring.sql.init.* 与 defer-datasource-initialization
Spring Boot 会在启动时自动执行 classpath:schema.sql 和 classpath:data.sql(如果存在)。控制它的配置在 spring.sql.init 下:
| 属性 | 默认值 | 作用 |
|---|---|---|
spring.sql.init.mode | embedded | 何时执行:embedded(仅内嵌库)/ always / never |
spring.sql.init.schema-locations | classpath:schema.sql | 结构脚本位置 |
spring.sql.init.data-locations | classpath:data.sql | 数据脚本位置 |
spring.sql.init.platform | — | 追加平台后缀,如 data-h2.sql |
spring.sql.init.continue-on-error | false | 出错是否继续 |
spring.jpa.defer-datasource-initialization | false | 是否把数据脚本推迟到 JPA 初始化之后 |
默认值 mode: embedded 是个安全设计:只有连内嵌库(如 H2 内存库)时才执行这些脚本,一旦连上真正的 PostgreSQL,data.sql 会被自动跳过。也就是说,只要不改这个默认值,生产环境天然不会执行开发数据脚本。
关于执行顺序,理清三层即可:Flyway 迁移最先执行(它是 DataSource 初始化的一部分,早于 spring.sql.init 脚本);随后执行 spring.sql.init 的 schema.sql / data.sql;若 spring.jpa.defer-datasource-initialization: true,第二步会再往后排到 JPA 初始化之后,确保实体映射已就绪。
为什么需要 defer?因为默认情况下数据脚本跑在 Hibernate 之前。如果表是由 Hibernate 建的(ddl-auto: create),数据脚本执行时表还不存在,直接报错。开了 defer,数据脚本就会等 Hibernate 建完表再跑。在图书服务里,表由 Flyway 建,理论上不需要 defer;但把它设成 true 是常见且更稳妥的做法,它让「数据脚本永远在持久层就绪后执行」这条不变量成立,避免以后调整结构时踩到顺序问题。
给开发环境加一份数据脚本 src/main/resources/data.sql:
-- 仅供本地开发:H2 内存库每次启动都是空的
INSERT INTO book (isbn, title, author, price, stock) VALUES
('9787111544937', '深入理解计算机系统', 'Randal E. Bryant', 139.00, 12),
('9787115428028', '算法导论', 'Thomas H. Cormen', 128.00, 8),
('9787121362217', '设计模式', 'Erich Gamma', 79.00, 20);
application-dev.yml:
spring:
jpa:
defer-datasource-initialization: true
sql:
init:
mode: embedded
data-locations: classpath:data.sql
启动后,H2 里就有三条图书数据了。注意 mode: embedded——它保证这段脚本只在内存库执行,是「生产不插测试数据」的第一道防线。
15.3.3 多环境脚本组织:公共 + 方言
图书服务要同时支持 H2(开发/测试)和 PostgreSQL(生产)。大部分 DDL 两者通用,但总有些语法是数据库特有的:自增列、JSON 类型、索引语法、序列等。全塞进一个脚本,另一个库就会报错。
Flyway 的解法是按目录分层,并用 {vendor} 占位符让 Flyway 自动识别当前数据库:
src/main/resources/
├── db/migration/
│ ├── V1__create_book_table.sql # 公共:建表
│ ├── V2__add_stock_to_book.sql # 公共:加列
│ ├── R__book_summary_view.sql # 公共:视图
│ ├── h2/
│ │ └── V3__create_borrow_record_h2.sql
│ └── postgresql/
│ └── V3__create_borrow_record_pg.sql
└── data.sql # 开发数据(仅 embedded)
对应配置:
spring:
flyway:
locations:
- classpath:db/migration
- classpath:db/migration/{vendor}
{vendor} 会被 Flyway 替换成当前数据库的标识:连 H2 时是 h2,连 PostgreSQL 时是 postgresql。这样公共脚本永远执行,方言脚本只执行匹配的那一份。注意两个方言目录里的脚本版本号必须一致(这里都是 V3),因为对任意一个数据库来说,它只看到其中一个,版本序列必须连续。
两个方言脚本的差异示例:
-- db/migration/h2/V3__create_borrow_record_h2.sql
CREATE TABLE borrow_record (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
book_id BIGINT NOT NULL,
borrower VARCHAR(80) NOT NULL,
borrowed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_borrow_book FOREIGN KEY (book_id) REFERENCES book (id)
);
PostgreSQL 版本几乎相同,只有两处差异:主键改用 GENERATED ALWAYS AS IDENTITY,时间列改用 TIMESTAMPTZ 并默认 now()。这类方言差异正是分目录的理由。
除了 {vendor},还可以按 profile 直接覆盖 locations。这种方式更灵活,适合「同一数据库、不同环境脚本不同」的场景:
# application-prod.yml
spring:
flyway:
locations:
- classpath:db/migration
- classpath:db/migration/postgresql
两种方式不冲突:{vendor} 解决「跨数据库方言」,profile 覆盖解决「跨环境差异」。图书服务用 {vendor} 即可;如果未来生产要用不同的索引策略,再用 profile 覆盖。
15.3.4 生产环境绝不自动插入测试数据
这是本节最重要的一条纪律。落到配置上,靠三道防线:
第一道:mode 默认只对内嵌库生效。 前面说过,spring.sql.init.mode 默认是 embedded,生产连的是 PostgreSQL,数据脚本自动跳过。不要为了「图省事」把它改成 always。
第二道:生产 profile 显式关掉。 即便有人误把 mode 改了,生产配置里再兜一层:
# application-prod.yml
spring:
sql:
init:
mode: never
第三道:数据脚本不进生产。 把开发数据放在 src/main/resources/data.sql 会被打进 jar,理论上生产也能读到。更严格的做法是用 profile 专属位置,或干脆在构建时排除:
# application-dev.yml
spring:
sql:
init:
data-locations: classpath:dev-data.sql
然后只把 dev-data.sql 放在开发环境的 classpath 上(如 src/main/resources/ 下但生产打包时排除),生产包里根本没有这个文件。
一句话总结:生产环境的数据只能来自真实的业务写入或经过审批的数据迁移脚本,绝不能来自 data.sql。
15.3.5 测试环境的迁移策略
测试环境的问题不是「要不要迁移」,而是「多久重建一次」。有两种主流做法,取舍在于速度与干净度:
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 每次重建 | 每个测试类/每次运行起一个新的 Testcontainers 容器 | 绝对干净,隔离性好 | 慢,容器启动有开销 |
| 复用容器 | 全测试套件共用一个静态容器 | 快 | 测试间可能互相污染,要自己清理 |
用 Testcontainers 时,迁移是自动发生的:容器里的数据库是空的,Spring 上下文启动时 Flyway 照常执行 db/migration 下的脚本,表就建好了。Spring Boot 4.x 用 @ServiceConnection 把容器连接自动接到数据源上:
@DataJpaTest
@Testcontainers
class BookRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");
@Autowired
private BookRepository bookRepository;
}
注意 @Container 加在 static 字段上——这样容器在类级别复用,而不是每个测试方法起一个,能省下大量时间。若想整套测试共用一个容器,把容器放进一个 static 初始化块并配 @Testcontainers(disabledWithoutDocker = true)。
关于「重建 vs 复用」的建议:默认用复用容器 + 每个测试自己清理数据。真正的迁移正确性,用一个专门的「迁移冒烟测试」来保证——让 Flyway 从一个空容器跑完全部脚本,断言没有异常、且关键表存在。这样既快,又覆盖了迁移本身。
一个常见坑:@DataJpaTest 默认会尝试用内嵌库替换数据源。加了 Testcontainers + @ServiceConnection 后,它才会用容器里的 PostgreSQL。如果发现测试连的是 H2 而不是 PG,多半是少了 @ServiceConnection 或容器没声明成 static。
15.3.6 CI/CD 中的迁移执行时机
迁移在什么时候执行,有两种模式,风险不同:
| 模式 | 执行者 | 时机 | 风险 |
|---|---|---|---|
| 应用启动时执行 | 应用进程自己 | 每次启动 | 多实例并发启动会争锁;长迁移拖慢启动;失败即启动失败 |
| 独立步骤执行 | 流水线 / flyway 命令 | 部署前 | 需额外维护命令;失败要能阻断后续部署 |
开发与测试环境用「应用启动时执行」最省事:跑一次 java -jar,迁移自动完成。
生产环境建议用「独立步骤执行」,理由有三:多实例部署时,让每个副本都在启动时迁移会造成锁竞争,拖慢整体启动;迁移失败与启动失败耦合在一起,会让一次失败的 DDL 表现为「所有副本都起不来」,独立步骤能在流水线里明确暴露失败;给大表加索引这类长迁移会阻塞服务启动,拉长每次部署的停机时间。
若采用独立步骤,应用侧要关掉自动迁移:
# application-prod.yml
spring:
flyway:
enabled: false
然后在流水线里用 Flyway 官方 CLI 或构建插件执行:flyway -url=... -user=... migrate,把 locations 指到 db/migration 与对应的方言目录。若团队更倾向「应用启动时执行」,也完全可以,但要接受两点:迁移必须先于应用发布准备就绪(DDL 要向后兼容,因为新旧版本代码会短暂共存),以及部署策略要用滚动更新而非一次性替换,给迁移留出完成时间。
15.3.7 图书服务三套环境完整配置
把前面所有结论落到图书服务的实际配置上。
基线 application.yml——放所有环境共享的部分:
spring:
application:
name: book-service
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
flyway:
enabled: true
locations:
- classpath:db/migration
- classpath:db/migration/{vendor}
validate-on-migrate: true
clean-disabled: true
开发环境 application-dev.yml——H2 内存库 + 开发数据:
spring:
datasource:
url: jdbc:h2:mem:books
username: sa
password: ""
h2:
console:
enabled: true
jpa:
defer-datasource-initialization: true
sql:
init:
mode: embedded
data-locations: classpath:data.sql
测试环境 application-test.yml——连测试用 PostgreSQL,不要开发数据:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/books_test
username: books
password: books
sql:
init:
mode: never
flyway:
clean-disabled: false # 仅测试环境允许 clean 重置
生产环境 application-prod.yml——迁移独立执行,数据脚本彻底关闭:
spring:
datasource:
url: ${DB_URL}
username: ${DB_USER}
password: ${DB_PASSWORD}
jpa:
defer-datasource-initialization: false
sql:
init:
mode: never
flyway:
enabled: false # 迁移由流水线独立执行
baseline-on-migrate: true
三套环境放一起看,差异集中在三处:
| 关注点 | dev | test | prod |
|---|---|---|---|
| 数据库 | H2 内存 | PostgreSQL | PostgreSQL |
| 结构来源 | Flyway | Flyway | Flyway(流水线执行) |
| 开发数据 | data.sql(embedded) | 无 | 无 |
sql.init.mode | embedded | never | never |
flyway.enabled | true | true | false |
test 环境把 clean-disabled 设为 false,是为了在需要时能一键清库重来;这个值绝不能出现在生产配置里——它一旦生效,flyway clean 会删掉整个 schema。
15.3.8 常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 生产库里出现了测试数据 | sql.init.mode 被改成 always | 改回 embedded 或 never |
data.sql 报「表不存在」 | 数据脚本跑在 Hibernate 建表之前 | 开 defer-datasource-initialization: true |
| H2 能跑、PG 报语法错 | 方言 DDL 混进公共脚本 | 拆到 db/migration/{vendor}/ |
| 方言脚本版本号对不上 | 两个目录版本不一致 | 保证同一数据库只看到一套连续版本 |
| 测试连的是 H2 而非容器 PG | 漏了 @ServiceConnection | 补上并确认容器是 static |
| 生产多副本启动互相等锁 | 应用启动时迁移 + 多实例 | 改为流水线独立执行迁移 |
小结
多环境数据管理的核心是把两条线拆开:Flyway 管结构(版本化、留痕、所有环境一致),data.sql 管数据(仅开发、用完即弃)。spring.sql.init.mode 默认的 embedded 是「生产不插测试数据」的第一道防线,再叠加生产 profile 的 never 与 flyway.enabled: false。脚本按「公共目录 + {vendor} 方言目录」组织,profile 负责覆盖环境差异。测试环境用 Testcontainers 时迁移自动发生,生产环境推荐把迁移拆成流水线的独立步骤。到这里,图书服务的数据访问层就完整了:实体、查询、事务、迁移、多环境数据,全部可运行、可演进。
阅读导航:上一节:15.2 版本化迁移脚本 · 下一节:16.1 日志配置与级别 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。