文章目录
- 一、为什么不再用一个 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');-- 其他表 ...用了几天,问题就出来了:
- 要手动执行:每个组员都得记得先跑一遍脚本,新同学经常漏;
- 不能重复执行:里面有
INSERT,跑第二次会因为用户名重复报错; - 改表很麻烦:以后给表加一列,只能让大家要么删库重建(数据丢失),要么各自手动
ALTER(每个人的库不一致); - 没有记录:谁的库是哪个版本的表结构,说不清楚。
我负责后端的持久层,就把表结构改成了用Flyway管理。
二、Flyway 是怎么工作的
Flyway 是数据库表结构的版本管理工具,可以理解为「给数据库用的 Git」:
- 每次建表、改表都写成一个带版本号的脚本,放在
db/migration目录:V1__init_schema.sql V2__add_query_indexes.sql V3__add_seller_password_version.sql - 应用启动时,Flyway 先查数据库里的
flyway_schema_history表,看已经执行到哪个版本; - 只执行还没执行过的脚本,执行完记录下来,包括每个脚本的校验和。
两条规则:
- 改表就新增一个脚本(下一个是 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 里处理:启动时卖家表为空,才按配置创建初始账号。
原因有两个:
- 密码要用 BCrypt 加密后存储,加密要在 Java 里做,SQL 里写不出来;
- 写在 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 里写了三条约定:
- 改表结构就新增脚本,文件名
V{下一个版本号}__说明.sql,比如V4__add_intent_requeue_column.sql; - 已经执行过的脚本不要再改,否则启动时校验失败;
- 两个人同时加脚本要先沟通版本号。如果两个分支都加了 V4,后合并的那个启动会报版本重复。人多的项目可以改用日期作版本号(比如
V20261018_1__xxx.sql)来避免撞号。
九、总结
| 问题 | Flyway 的做法 |
|---|---|
| 要手动建库建表 | 启动时自动执行,连接串createDatabaseIfNotExist=true自动建库 |
| 脚本不能重复执行 | 每个版本只执行一次,有历史表记录 |
| 改表要删库 | 新增一个版本脚本,已有数据保留 |
| 已有的旧库 | baseline-on-migrate+ 基线版本 0 +IF NOT EXISTS |
| 测试和正式环境不一致 | H2 MySQL 兼容模式执行同一套脚本,测试直接复用正式的 Flyway 配置 |
这次最大的收获是:设计方案时把各种可能的初始状态都列出来。基线版本设成 1 看起来最「自然」,但遇到「库里只有无关表」这种情况就会出错,而这恰好是我自己电脑上的真实情况。