☰
Spring Boot 用 Flyway 管理数据库表结构:自动建表、兼容已有库、改表不丢数据
2026/10/20 20:03:15 网站建设 项目流程

文章目录

    • 一、为什么不再用一个 schema.sql
    • 二、Flyway 是怎么工作的
    • 三、接入步骤
      • 3.1 依赖
      • 3.2 配置
      • 3.3 脚本
    • 四、最关键的问题:已经有库了怎么办
    • 五、初始数据为什么不写在 SQL 里
    • 六、H2 和 MySQL 共用一套脚本
    • 七、用测试证明迁移是对的
    • 八、团队使用约定
    • 九、总结

一、为什么不再用一个 schema.sql

我们课程小组的项目一开始用的是最常见的做法:写一个schema.sql,大家手动执行一次建库建表:

CREATEDATABASEIFNOTEXISTSonlineshopDEFAULTCHARACTERSETutf8mb4;USEonlineshop;CREATETABLEIFNOTEXISTSseller(...);INSERTINTOseller(username,password)VALUES('admin','123456');-- 其他表 ...

用了几天,问题就出来了:

  1. 要手动执行:每个组员都得记得先跑一遍脚本,新同学经常漏;
  2. 不能重复执行:里面有INSERT,跑第二次会因为用户名重复报错;
  3. 改表很麻烦:以后给表加一列,只能让大家要么删库重建(数据丢失),要么各自手动ALTER(每个人的库不一致);
  4. 没有记录:谁的库是哪个版本的表结构,说不清楚。

我负责后端的持久层,就把表结构改成了用Flyway管理。

二、Flyway 是怎么工作的

Flyway 是数据库表结构的版本管理工具,可以理解为「给数据库用的 Git」:

  1. 每次建表、改表都写成一个带版本号的脚本,放在db/migration目录:
    V1__init_schema.sql V2__add_query_indexes.sql V3__add_seller_password_version.sql
  2. 应用启动时,Flyway 先查数据库里的flyway_schema_history表,看已经执行到哪个版本;
  3. 只执行还没执行过的脚本,执行完记录下来,包括每个脚本的校验和。

两条规则:

  • 改表就新增一个脚本(下一个是 V4);
  • 已经执行过的脚本不能再改:Flyway 启动时会比对校验和,改过就报错,防止大家的库悄悄变得不一致。

三、接入步骤

3.1 依赖

<!-- 数据库表结构版本管理:启动时自动执行 db/migration 下的脚本 --><dependency><groupId>org.flywaydb</groupId><artifactId>flyway-core</artifactId></dependency><dependency><groupId>org.flywaydb</groupId><artifactId>flyway-mysql</artifactId></dependency>

版本由 Spring Boot 的父工程统一管理,不用写。注意新版 Flyway 把MySQL 的支持拆成了单独的flyway-mysql模块,只引flyway-core连 MySQL 会报不支持的数据库。

3.2 配置

spring:datasource:# createDatabaseIfNotExist=true:库不存在时自动创建url:${DB_URL:jdbc:mysql://localhost:3306/onlineshop?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai}username:${DB_USERNAME:root}password:${DB_PASSWORD:root}flyway:locations:classpath:db/migration# 脚本里有中文注释和 COMMENT,按 UTF-8 读取encoding:UTF-8# 库里已经有表、但还没有 Flyway 记录时,以版本 0 为基线继续执行全部脚本baseline-on-migrate:truebaseline-version:0

几个要点:

  • MySQL 连接串加上createDatabaseIfNotExist=true,连建库这一步也省了;
  • 数据库密码通过环境变量DB_PASSWORD传入,不写进仓库;
  • baseline-*两项是为了兼容已有的库,第四节专门讲。

3.3 脚本

V1 建三张表,和原来的schema.sql一致,但去掉了CREATE DATABASE、USE和INSERT,并且用IF NOT EXISTS:

-- V1 初始表结构-- 由 Flyway 在应用启动时执行,每个版本只执行一次;已经执行过的脚本不要再修改,改表请新增 V{n}__说明.sql。-- 用 IF NOT EXISTS:库里已经存在的表原样保留,表里的数据不受影响。CREATETABLEIFNOTEXISTSseller(idBIGINTAUTO_INCREMENTPRIMARYKEY,usernameVARCHAR(50)NOTNULLUNIQUECOMMENT'用户名',passwordVARCHAR(100)NOTNULLCOMMENT'密码')ENGINE=InnoDBDEFAULTCHARSET=utf8mb4COMMENT='卖家';-- product、purchase_intent 两张表省略

V2 加常用查询的索引:

-- 单件在售检查、当前商品、历史商品都按状态查商品,历史商品再按发布时间排序CREATEINDEXidx_product_status_timeONproduct(status,create_time);-- 排队、意向人列表都按商品和意向状态查CREATEINDEXidx_intent_product_statusONpurchase_intent(product_id,status);

后来做「修改密码后所有登录失效」时,需要给卖家表加一列,就是新增一个 V3:

-- V3 卖家密码版本号:每次修改密码加 1,修改之前的所有登录随之失效ALTERTABLEsellerADDCOLUMNpassword_versionINTNOTNULLDEFAULT0COMMENT'密码版本号,修改密码时加 1';

组员拉代码、启动,表就自动多了这一列,原有数据不受影响。这就是用 Flyway 最大的好处。

四、最关键的问题:已经有库了怎么办

接入 Flyway 时,组员们的电脑上已经有用旧schema.sql建好的库,里面有数据,但是没有flyway_schema_history表。Flyway 默认遇到「非空、但没有历史记录」的库会直接报错拒绝执行。

baseline-on-migrate: true的意思是:遇到这种库,先记一条「基线」,再从基线之后开始执行。问题是基线版本设成多少。

我一开始想设成 1(认为旧库就等于 V1),但把三种可能的情况列出来以后,发现不对:

情况基线版本设为 1基线版本设为 0 + V1 用 IF NOT EXISTS
全新的库正常执行 V1、V2、V3正常执行 V1、V2、V3
用旧 schema.sql 建过、有数据跳过 V1,执行 V2、V3,正确执行 V1(表已存在,跳过),再执行 V2、V3,正确
库里只有一张无关的表(比如别的程序留下的)跳过 V1,业务表根本没建,V2 建索引直接报错执行 V1 建出全部表,正确

第三种情况是我在自己电脑上真实遇到的:本机的onlineshop库是之前别的练习项目建的,里面只有一张无关的表。

所以最终方案是:基线版本设为 0,V1 全部用CREATE TABLE IF NOT EXISTS。这样不管库里原来是什么状态,都会把 V1~V3 执行一遍;已存在的表跳过、数据保留,缺的表和索引补上。

五、初始数据为什么不写在 SQL 里

原来的schema.sql里有一句INSERT插初始账号。迁移后我把它去掉了,改成在 Java 里处理:启动时卖家表为空,才按配置创建初始账号。

原因有两个:

  1. 密码要用 BCrypt 加密后存储,加密要在 Java 里做,SQL 里写不出来;
  2. 写在 Java 里可以先判断「卖家表是不是空的」,不会覆盖已经改过的密码,也不会重复插入。
@Overridepublicvoidrun(ApplicationArgumentsargs){if(sellerMapper.selectCount(null)==0){createDefaultSeller();// 按 application.yml 里的 shop.default-seller 创建,密码加密保存}else{encryptPlainPasswords();// 已有卖家:把还是明文的密码转成加密}}

经验是:表结构交给迁移脚本,和业务逻辑有关的初始数据交给代码。

六、H2 和 MySQL 共用一套脚本

测试用 H2 内存数据库,不需要装 MySQL。为了让测试和正式环境执行同一套Flyway 脚本,H2 开了 MySQL 兼容模式:

# application-test.ymlspring:datasource:driver-class-name:org.h2.Driverurl:jdbc:h2:mem:onlineshop;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1username:sapassword:""
  • MODE=MySQL:能识别ENGINE=InnoDB、COMMENT这些 MySQL 写法;
  • DATABASE_TO_LOWER=TRUE:表名、列名统一小写,和 MySQL 的习惯一致;
  • DB_CLOSE_DELAY=-1:同一个 JVM 里多个测试共享这个内存库。

H2 毕竟不是真正的 MySQL,所以关键场景我又在本机 MySQL 8 上手动验证了一遍(见下一节的三种情况)。

七、用测试证明迁移是对的

@SpringBootTest@ActiveProfiles("test")classDatabaseMigrationTest{@Test@DisplayName("启动时执行完全部迁移脚本")voidallMigrationsApplied(){assertThat(Arrays.stream(flyway.info().applied()).map(MigrationInfo::getVersion).map(Object::toString)).contains("1","2","3");assertThat(flyway.info().pending()).isEmpty();}@Test@DisplayName("再次启动不会重复执行脚本")voidmigrateAgainDoesNothing(){assertThat(flyway.migrate().migrationsExecuted).isZero();}@Test@DisplayName("库里只有无关的表时,照常建出全部业务表")voiddatabaseWithUnrelatedTableIsInitialized(){DataSourceother=h2("other_db");newJdbcTemplate(other).execute("CREATE TABLE app_config (config_key VARCHAR(50) PRIMARY KEY)");migrateWithAppSettings(other);JdbcTemplatejdbc=newJdbcTemplate(other);assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM product",Long.class)).isZero();}/** 用 application.yml 里的 Flyway 配置迁移另一个库 */privatevoidmigrateWithAppSettings(DataSourcetarget){Flyway.configure().dataSource(target).locations(flywayProperties.getLocations().toArray(String[]::new)).encoding(flywayProperties.getEncoding()).baselineOnMigrate(flywayProperties.isBaselineOnMigrate()).baselineVersion(flywayProperties.getBaselineVersion()).load().migrate();}// 其他用例和辅助方法省略}

migrateWithAppSettings直接注入 Spring Boot 的FlywayProperties,用和正式配置完全相同的参数去迁移另一个测试库,这样改了application.yml测试也会跟着变,不会出现「测试配置和正式配置不一致」。

另外还有一个用例:先用旧schema.sql的内容建表、插入数据,再执行迁移,断言原有数据还在、新索引已经补上。

在本机 MySQL 8.0 上实测的结果:

情况结果
全新的库(不存在)自动建库建表;重启后提示Schema is up to date,不重复执行
用旧 schema.sql 建过、有数据原有数据保留,补上两个索引,升级到 V3
库里只有一张无关的表三张业务表正常建出,原来的表不动

八、团队使用约定

接入后,我在 README 里写了三条约定:

  1. 改表结构就新增脚本,文件名V{下一个版本号}__说明.sql,比如V4__add_intent_requeue_column.sql;
  2. 已经执行过的脚本不要再改,否则启动时校验失败;
  3. 两个人同时加脚本要先沟通版本号。如果两个分支都加了 V4,后合并的那个启动会报版本重复。人多的项目可以改用日期作版本号(比如V20261018_1__xxx.sql)来避免撞号。

九、总结

问题Flyway 的做法
要手动建库建表启动时自动执行,连接串createDatabaseIfNotExist=true自动建库
脚本不能重复执行每个版本只执行一次,有历史表记录
改表要删库新增一个版本脚本,已有数据保留
已有的旧库baseline-on-migrate+ 基线版本 0 +IF NOT EXISTS
测试和正式环境不一致H2 MySQL 兼容模式执行同一套脚本,测试直接复用正式的 Flyway 配置

这次最大的收获是:设计方案时把各种可能的初始状态都列出来。基线版本设成 1 看起来最「自然」,但遇到「库里只有无关表」这种情况就会出错,而这恰好是我自己电脑上的真实情况。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询