如果维护过那种上线五年、表结构改动全靠口头通知的遗留系统你一定能理解我在说什么开发库、测试库、生产库之间经常出现结构不一致本地加好的字段到线上就消失了每次排查都要花大量时间对表结构。Flyway 就是用来终结这种局面的工具。在 Spring Boot 项目里你只需要引入一个依赖、写好配置、把 SQL 脚本放进约定目录应用启动时它会自动把数据库迁移到最新版本。这篇文章没有教科书式的概念堆砌只聊我在真实项目里折腾 Flyway 几年后沉淀下来的完整玩法怎么配、怎么写脚本、团队协作时怎么避坑、上线出问题怎么排查。先说一个我印象最深的场景。当时项目还没有引入 Flyway项目组里一位同事在自己本地加了一张表上线文档里写了一句“请执行 db/alter.sql”结果这个文件躺在聊天记录里发布当天根本没人记得跑。业务一上线就报错生产库缺字段一群人围着数据库手工补 SQL狼狈得很。这种问题反复出现之后我们才下定决心数据库结构也是代码必须像代码一样纳入版本管理。Flyway 就是当时选定的答案。1. 为什么需要一个数据库版本管理工具1.1 没有版本控制的表结构有多可怕数据库结构变更最麻烦的一点是它不像代码那样有清晰的 diff也不容易在合并时发现冲突。没有版本控制工具的团队通常靠三种方式管理结构变更人肉执行、文档记录、运维备份。这三种方式单独拿出来看着还行组在一起就是连环坑。举个例子一个简单的新增字段需求在理想流程里只需要执行ALTER TABLE xxx ADD COLUMN yyy就够了。但现实中会遇到这些状况本地库、测试库、生产库的基线不一致同一个脚本在一个环境能跑在另一个环境报列已存在。多个需求并行开发两个人都加了同名或同类型的字段上线时先后执行第二次直接冲突。执行顺序不可控A 的脚本依赖 B 先执行的表但 B 的脚本还没人跑。出了问题时没有审计痕迹谁改的、什么时候改的、为什么改全靠回忆。最致命的一点是很多核心业务数据就在这些表里一旦执行错误比如误删列或者重复执行脚本修复成本远超普通代码 bug。这种状态下数据库结构更像一团“黑历史”没人敢轻易动动一次疼一次。1.2 Flyway 的设计思路把数据库变更变成有序迁移Flyway 解决这个问题的思路很直接它把每个数据库变更操作封装成一个“迁移脚本”按版本号排序逐个执行并把执行记录写进一张专门的历史表。你可以记住三个关键点第一脚本是带版本号的。V1__init.sql、V2__add_table.sql版本号就是执行顺序的依据。Flyway 启动时会扫描所有脚本文件算出哪些是新的、哪个版本高然后依次执行。第二历史表叫flyway_schema_history。每次成功后都会插入一条记录包含版本号、描述、脚本名、执行时间、checksum 等信息。这张表就是数据库结构的“提交记录”。第三每次执行前会做校验。Flyway 会把历史表里记录的 checksum 和当前磁盘上脚本内容的 checksum 做对比。如果有人偷偷改掉了一个已经执行过的脚本启动时就会报错别想静悄悄带病上线。这套设计本质上把数据库的最终状态定义成了“基线状态 一串有序的增量变更”。只要同一套脚本从零开始跑一遍任何环境都能得到一致的结构。这正是团队协作里最需要的东西。2. Spring Boot 集成 Flyway自动配置背后的逻辑2.1 最小依赖与自动装配Spring Boot 对 Flyway 的集成做得非常省心核心原因是它的自动配置机制。只要 classpath 里有 Flyway 的包同时项目中配置了数据源Spring Boot 就会自动帮你创建 Flyway 的 Bean并在应用启动阶段自动执行迁移。如果你是 Maven 项目引入依赖就两步。以 Spring Boot 2.7.x 为例dependency groupIdorg.flywaydb/groupId artifactIdflyway-core/artifactId /dependency dependency groupIdorg.flywaydb/groupId artifactIdflyway-mysql/artifactId /dependency这里有一个容易踩的坑Flyway 从 8.2.0 版本开始把不同数据库的支持拆成了独立模块。你要用 MySQL光引入flyway-core还不够必须额外引入flyway-mysql否则启动时会直接报错提示找不到对应的数据库支持。PostgreSQL、SQL Server 同理。Spring Boot 的依赖管理已经帮你锁定了 Flyway 版本所以版本号可以不用写。如果你用的是 Spring Boot 3.xFlyway 版本也跟着升级自动配置的逻辑一样只是底层包路径有些变化使用方式不受影响。配好依赖之后还不算完还要确认一件事项目里如果有 JPA/Hibernate记得把spring.jpa.hibernate.ddl-auto关掉。我第一次接入时就吃过这个亏Flyway 建好表之后Hibernate 又按照实体类去自动更新结构两边同时在管数据库结构结果有一次校验失败查了半天才意识到是 JPA 的设置没关。2.2 核心配置项逐个拆解Spring Boot 中所有 Flyway 配置都挂在spring.flyway前缀下。下面这份配置是我个人比较常用的一份结合注释逐项说明spring: datasource: url: jdbc:mysql://localhost:3306/demo?useSSLfalseserverTimezoneAsia/Shanghai username: root password: root flyway: enabled: true locations: classpath:db/migration baseline-on-migrate: true baseline-version: 1 validate-on-migrate: true out-of-order: false table: flyway_schema_history逐个拆开来看每一行背后都有它的理由。locations是脚本扫描路径默认就是classpath:db/migration。如果你有多模块工程或者想自定义目录可以通过逗号分隔配置多个位置比如classpath:db/migration/common, classpath:db/migration/biz。但我的建议是除非模块依赖非常清晰否则不要轻易拆目录脚本越集中越容易审查。baseline-on-migrate是给老库接 Flyway 用的。它的意思很简单如果当前数据库是一张白纸但里面已经有一些表而且没有flyway_schema_history表Flyway 会把它当作 baseline 处理自动建历史表并插入一条基线记录。这样可以避免“非空 schema 没有历史信息”的启动报错。baseline-version与 baseline 配合使用默认值是 1。它代表当前库已经处于版本 1 的状态所以之后的脚本版本必须大于 baseline 版本才会执行。这里有个容易误会的点如果你给老库设了 baseline-version1那之后你想补一个 V1 脚本它是永远不会执行的。新脚本得从 V2 开始写这一点我后面具体演示。validate-on-migrate默认就是 true建议保持。它让 Flyway 在迁移前把所有已执行脚本的 checksum 和当前文件做比对一旦发现有人改动历史脚本就立刻停止启动。这个校验是 Flyway 最值钱的能力之一关掉它等于自断一臂。out-of-order默认是 false。它控制的是“能否按照版本顺序执行缺失的脚本”默认情况下Flyway 只会执行版本号大于当前最新已执行版本的脚本即使有一个脚本版本较低但从未执行过也会被忽略并报错。团队协作特别容易碰到这个场景我在第 4 章详细讲。那张历史表名字建议保持默认。不要为了“看起来干净”去改它因为这个表名在很多团队规范和监控工具里已经成了默认约定改成其他名字反而增加沟通成本。2.3 脚本目录与命名规则脚本命名是 Flyway 里最有仪式感的部分也是大多数人容易忽略的部分。一个脚本名字拆开来看可以分为三个部分前缀、版本号、描述。前缀 V 代表版本化迁移versioned migrationU 代表撤销迁移undoR 代表可重复迁移repeatable。社区版不支持 U 前缀企业版才支持普通人主要用 V 和 R。版本号写在 V 和双下划线之间比如V1__init.sql、V1_1__add_index.sql、V20240101__create_user.sql。版本号可以是纯数字也支持点号分隔。不过我的经验是团队内部如果没有特殊要求用简单的递增整数最稳。注意“双下划线”它和“单下划线”不一样。Flyway 用双下划线来分离版本号和描述你如果用单下划线整个字符串可能被解析成一个版本号的一部分虽然有些情况下也能跑但容易出问题。描述部分建议写清楚这个变更做了什么比如V2__add_order_table.sql比V2__modify.sql有意义得多。可重复执行的 R 脚本要单独说明一下。它没有版本号只要内容 checksum 发生变化就会在所有 V 脚本执行完之后重新执行一遍。适合放视图、存储过程、函数这类“以最终状态为准”的对象。比如你维护一个统计视图字段逻辑经常变写进 V 脚本会导致版本号膨胀写成R__user_stats_view.sql就舒服很多。3. 从零实操一个可复现的完整案例3.1 准备一个空库和基础工程理论说再多不如动手跑一遍。我在这里用一个标准的场景演示Spring Boot 2.7 项目连接 MySQL 空库数据库名demo目标是通过 Flyway 管理整张业务表的结构。环境准备Spring Boot 2.7.x 工程已经引入spring-boot-starter-web、spring-boot-starter-jdbc或mybatis。MySQL 5.7 或 8.0本地创建空库demo字符集utf8mb4。在 pom.xml 里引入前文说的flyway-core和flyway-mysql。配置方面application.yml里把数据源和 flyway 的配置写好baseline 先不用开因为空库没有任何表Flyway 可以直接从零开始不需要 baseline。这是我建议的用法新项目、空库就不用动 baseline它只在老库接入时才需要。spring: datasource: url: jdbc:mysql://localhost:3306/demo?useSSLfalseserverTimezoneAsia/Shanghai username: root password: root flyway: locations: classpath:db/migration在src/main/resources/db/migration目录下准备第一个脚本。3.2 第一版脚本建表与初始化数据第一个脚本一般承担两件事建核心表、写入必要的初始化数据。但要控制在一个版本里不要让版本号承担太多职责。我这个例子建一张用户表和一张订单表的雏形再加一条初始数据。src/main/resources/db/migration/V1__init_schema.sqlCREATE TABLE user_account ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL, email VARCHAR(128) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username), UNIQUE KEY uk_email (email) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_info ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, order_no VARCHAR(32) NOT NULL, amount DECIMAL(10, 2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO user_account (username, email) VALUES (sysadmin, sysadminexample.com);这里有个细节要注意脚本加了ENGINE和CHARSET在不同 MySQL 版本下可以避免默认值不一致的问题。另一个细节是字符串列都给了明确长度和唯一约束这在后续做数据迁移时会省很多事。空库下执行没有任何问题Flyway 会记录版本 1 已应用。3.3 启动应用观察执行过程直接启动 Spring Boot 应用控制台里会打印类似的日志INFO o.f.core.internal.command.DbMigrate - Current version of schema demo: null INFO o.f.core.internal.command.DbMigrate - Migrating schema demo to version 1 - init schema INFO o.f.core.internal.command.DbMigrate - Successfully applied 1 migration to schema demo (execution time 00:00.202s)看到Successfully applied 1 migration就说明执行成功。这时可以打开数据库客户端查看flyway_schema_history表里面会有一条记录大致信息是installed_rank1, version1, descriptioninit schema, typeSQL, scriptV1__init_schema.sql, success1。这张历史表就是数据库结构变更的账本以后每次新脚本执行都会追加一条记录。如果想要一个更直观的展示界面可以启用 Spring Boot Actuator 的 flyway 端点GET /actuator/flyway会返回所有迁移脚本的元数据和执行状态排查问题的时候非常实用。3.4 第二次变更给已有表加字段应用上线跑了一段时间后新需求来了订单表需要加一个remark字段。此时绝对不要直接去数据库里执行ALTER TABLE也不要改动 V1 脚本正确做法是新建一个更高版本的脚本。src/main/resources/db/migration/V2__add_order_remark.sqlALTER TABLE order_info ADD COLUMN remark VARCHAR(255) NULL COMMENT 订单备注;再次启动应用日志会显示INFO o.f.core.internal.command.DbMigrate - Current version of schema demo: 1 INFO o.f.core.internal.command.DbMigrate - Migrating schema demo to version 2 - add order remark INFO o.f.core.internal.command.DbMigrate - Successfully applied 1 migration to schema demo (execution time 00:00.128s)关键点在于V1 不会再执行了因为历史表里已经有 V1 的记录Flyway 只会执行大于当前版本号的脚本。这就是增量变更的核心思想数据库已经运行过的历史不能回溯、不能修改只能继续向前加。3.5 启动顺序为什么 Flyway 能管住 JPA前面提到要把spring.jpa.hibernate.ddl-auto设成none这里补充一下原理。Spring Boot 自动配置里Flyway 的执行时机是在数据源完成初始化之后、JPA 等 ORM 框架启动之前。也就是说Flyway 先把库表结构迁到最新之后 JPA 再去使用这个结构时表已经在了。如果你不关掉 ddl-autoHibernate 会按照实体类定义去创建或更新表这等于两套机制在互相打架。最典型的症状是Flyway 的脚本建了 A 表Hibernate 又基于实体类动态加了一个字段结果下次 Flyway 校验时发现历史表里的 checksum 和脚本一致但实际表结构和脚本不一致整个状态变得不可信。所以只要用了 FlywayJPA 这边就老老实实把结构管理权交给 Flyway。4. 团队协作与多环境下的进阶用法4.1 分支冲突与 out-of-order 的正确理解Flyway 默认只允许版本号递增执行这个设计在单团队单分支时没有任何问题。但现实中的并行开发很容易产生一个场景两个同事各自拉分支各自写脚本各自本地测试最后合并到主干时两个人的脚本版本号撞车了。举例来说同事 A 写了V2__add_product.sql同事 B 写了V3__add_payment.sql。如果 A 先合并并上线B 后合并并上线情况还好V2 先执行V3 后执行。但如果 B 先上线生产库已经执行到 V3A 后上线时带着 V2这个 V2 的版本号比 V3 小而且历史表里没有它的记录。默认配置下Flyway 会直接报错因为它默认不允许执行版本号比当前最新版本小的脚本。这时有两个选择。第一个是打开spring.flyway.out-of-ordertrue让 Flyway 允许这种“补跑”的脚本它会在 V3 之后执行 V2历史记录里installed_rank是 4但version是 2顺序记录并不影响结果。第二个选择是坚持out-of-orderfalse靠严格的版本号管理来规避冲突。我的实际建议是小团队靠规范不要靠配置。打开 out-of-order 会掩盖版本号分配问题而且低版本脚本往往依赖更早期的表结构如果它设计时没有与后续脚本兼容补跑时反而容易出事。更踏实的做法是团队约定每次提交迁移脚本前先看主干上目前最大版本号新脚本从 max 1 开始写并且代码评审时专门检查脚本版本号是否冲突。4.2 老库接入baseline 的正确打开方式最难啃的场景是线上已经有库、有表、有数据但从来没有 Flyway团队想从明天开始用 Flyway 管理后续变更。这时如果直接启动应用Flyway 会报错Found non-empty schema demo but no schema history table。它不敢自己去动一个已有结构的库生怕把你的表搞坏。正确操作是配置两个参数spring: flyway: baseline-on-migrate: true baseline-version: 1启动时Flyway 会检测到空的历史表和已有业务表于是自动创建flyway_schema_history插入一条 baseline 记录版本号是 1。它的含义是我把这个库当前状态当作版本 1历史不再深究。从今往后的新脚本必须从 V2 开始写。这里有一个非常容易踩的坑很多同事接入老库后习惯性地写了一个V1__xxx.sql然后发现这个脚本永远不会执行因为它们以为 baseline 是从 V1 开始的实际上 baseline 本身已经占用“版本 1”这个位置。Flyway 只执行大于 baseline 的版本。所以结论很清晰老库接入了 Flyway第一份新脚本从 V2 开始之前的历史结构不要试图补脚本覆盖让它静静地停留在 baseline 里。如果你希望 baseline 之后再补一些“基础初始化脚本”比如给所有历史表加统一的updated_at字段不要用 V1 命名直接从V2__add_updated_at_to_all_tables.sql开始并在描述里写清楚原因。4.3 多实例部署并发迁移安全生产环境如果有多副本、多节点比如 Kubernetes 同时拉起多个 Pod每个节点都会在启动时触发 Flyway 的迁移逻辑。很多人第一反应是担心多个容器同时跑迁移会不会把库搞坏Flyway 内部通过数据库锁解决了并发问题。迁移开始时它会尝试获取一把锁只有一个实例能拿到并执行迁移其他实例会等待锁释放后重新检查历史表发现已经是最新版本就直接跳过。整个过程对应用来说是透明的最多就是启动时间稍微拉长一点。不过有一个点值得注意如果某个迁移脚本非常耗时比如给大表加索引、批量更新历史数据那么其他实例的启动会一直阻塞等待。生产发布时建议把数据和结构变更脚本拆开重操作放到发布窗口里的独立任务而不是挂在应用启动流程里。不要让所有 Pod 的可用性都押在一个肥大的迁移脚本身上。4.4 环境重建与回滚策略Flyway 社区版没有回滚命令U 开头的 undo 脚本是企业版功能。普通人面对已经执行过的错误迁移常用的正道是用一个新的反向迁移脚本来“撤销”。比如 V2 加了remark字段后来发现这个字段不该加那就写一份 V3 脚本把字段删掉而不是去编辑历史。这里唯一的建议是反向脚本也要按版本顺序执行保留完整历史。不要想着删掉历史记录重新跑这会让之前的 checksum 校验全部失去意义等于在账本上涂改。开发环境想重置数据库时常用的手段是flyway clean加migrate但 Spring Boot 里clean默认是禁用的因为 clean 会清空所有表在误操作下是灾难性的。开发环境确实需要重建时我一般直接通过数据库客户端删库重建或者把 demo 库整个 drop 掉重新建然后启动应用让 Flyway 从零跑一遍。生产环境永远不要动 clean 的念头。5. 常见问题排查这些坑我都踩过5.1 checksum mismatch别手贱改已执行的脚本这是 Flyway 用户碰到最多的报错Migration checksum mismatch for migration version 2。原因只有一个某个已经执行过的脚本文件被人修改过内容而且修改之后和当初执行时的内容对不上了。这个校验非常敏感连加一个空格、改一行注释都会导致 checksum 变化。我印象很深的一次是同事觉得 V1 脚本里的建表注释写得不够清楚顺手补了一句话结果项目启动直接失败所有人都在排查数据库最后发现是这种“好心”改动引发的。处理方式要根据环境区别对待。开发环境如果历史脚本改的内容无伤大雅可以通过数据库删掉flyway_schema_history里对应版本的记录再重新启动Flyway 会把新脚本当作未执行版本重新跑。但生产环境绝对不要这么干正确方案是新增一个变更脚本去修改实际结构而不是改历史脚本。如果有条件可以用flyway repair工具修复部分不一致记录但要理解 repair 只是更新元数据不解决真实表结构差异。5.2 迁移执行超时与锁等待多实例同时启动时可能出现这样的日志某个实例一直卡在迁移阶段迟迟不能完成启动。常见的表现就是应用在Starting Application日志之后长时间没有下文最后抛出一个锁等待或超时的异常。通常原因要么是某个脚本本身执行时间太长要么是另一个实例持有迁移锁本实例在等待。排查时先看数据库侧有没有大查询或 DDL 在跑比如SHOW PROCESSLIST看一下是否有长时间未结束的ALTER TABLE。如果脚本耗时长优先优化脚本或者拆分成多个版本。如果只是锁等待一般等它自己结束就行不需要额外处理。值得说明的是Flyway 在迁移期间会占用一个数据库连接整个迁移过程都在这个连接上执行期间应用的其他数据库操作不受影响但启动流程是阻塞的。多实例同时启动时建议观察整体启动时间是否在可接受范围内。5.3 MySQL DDL 部分执行一个版本只做一件事MySQL 的 DDL 语句默认不支持事务回滚很多ALTER TABLE在执行过程中会隐式提交事务。这意味着一个脚本里如果写了多条 DDL前两句执行成功后第三句报错前面的操作已经写进数据库不会因为脚本失败而回滚。我遇到过一次某版本脚本里同时加了两个字段和一条初始化 UPDATEUPDATE 因为数据问题失败了但字段已经加上了结果 Flyway 显示迁移失败历史表里success0数据库实际结构和期望结构已经不一致。最后只能手动比对结构差异重新整理脚本。这个问题的解决思路很简单每个版本脚本尽量只做一类变更DDL 与 DML 分开大操作拆小操作。尤其是历史数据清洗、回填这类的数据变更不要和数据表结构变更混在同一条脚本里。真出了问题时判断逻辑也很清晰先看历史表里对应的 success 标志再对比数据库实际结构确定哪些语句已经生效、哪些没有执行。5.4 脚本版本真空与历史表缺失另一种奇怪的报错是Flyway 提示数据库当前版本是 4但本地脚本里有 V3 未执行。这说明历史表里少了 V3 的记录可能原因是某一次部署时应用没有执行迁移但数据库结构已经被人手动改过甚至有人直接修改过flyway_schema_history表。Flyway 默认配置下不会自动跳过缺失的版本会直接报错因为它认为缺少中间版本意味着执行链断裂。处理这种问题先查历史表的记录是否完整SELECT installed_rank, version, description, success FROM flyway_schema_history ORDER BY installed_rank。如果确认缺失版本是人为误删而且对应结构也确实没执行过可以考虑手动补一条历史记录或者新建一个新版本脚本包含缺失变更。补记录是脏操作不建议频繁使用它属于让系统恢复工作状态的手段不是常规流程。5.5 数据库方言与 jdbc 驱动不匹配Flyway 的脚本本质是 SQL底层连接的数据库方言决定了脚本能不能跑通。如果项目在本地用 H2 数据库做单元测试生产用 MySQL那么同样的脚本可能在一套环境成功、另一套环境报错。比如 H2 的AUTO_INCREMENT写法、MySQL 的ENGINEInnoDB注释在切换数据库类型时都可能导致兼容性问题。我的建议是除了单元测试里刻意用嵌入式数据库外集成测试和预发环境尽量使用与生产一致的数据库类型和版本。如果确实需要兼容多个数据库版本脚本里少用数据库特有语法把引擎声明、字符集这类差异信息放进专门的初始化脚本里日常增量变更保持通用性。另一个细节是 MySQL 驱动连接串要加上serverTimezone参数否则时间和日期相关操作容易出现时区问题影响脚本里对时间字段的处理。最后再分享一点个人体会Flyway 这个东西初期接入成本大概就是一下午但它真正改变的其实是团队习惯。它逼着我把每个变更写成一段可追溯的历史而不是散落在各种文档和群消息里的 SQL。这几年用下来我最大的感受已经不是少踩了多少坑而是每次上线之前的底气结构对不对、版本对不对启一次机器就有答案。这种安全感越早建立越好。
阅读完成 · 觉得有帮助?