
如果你的Spring Boot应用中集成了Flyway那你大概率见过这样一段日志Flyway Community Edition 9.1.0 Database: jdbc:postgresql://... (PostgreSQL 14.0) Successfully validated 23 migrations Current version of schema public: 7.2 Migrating schema public to version 7.3然后就没有然后了。既不报错也不前进就像堵死在早高峰的高架桥上。标题里说的“flyway执行无限等待”指的就是这种看似正常、实则死锁的状态。我自己处理过不少这类问题也在深更半夜陪过发版的同事一起看数据库会话发呆。这篇文章就把我踩过的坑和沉淀下来的排查方法整理出来希望能帮你少走几条弯路。如果你是Java后端、负责数据库演进或者日常会维护CI/CD流水线这篇内容应该都适用。就算你只是听说过Flyway、还没在生产环境里被它坑过看完也能对“迁移到底卡在哪里”有一个清晰的认识不至于在黑灯瞎火的故障现场抓瞎。1. 问题现象与核心影响1.1 现象描述“flyway执行无限等待”在日志里并不总是千篇一律。我遇到过几次印象深刻的现场形态不同但本质相似。最常见的一种场景是服务启动时日志停在Migrating schema public to version 7.3然后整条链路安静得像被按了暂停键。持续几分钟、几十分钟甚至更久。这时候如果去翻线程Dump会发现Flyway的工作线程往往停在JDBC的socket read上也就是等待数据库返回结果。但数据库端可能连一个正在执行这个迁移的会话都找不到。另一种场景出现在CI/CD流程里。流水线执行mvn flyway:migrate任务就一直挂在那里直到agent的默认超时时间到了才被强制杀掉。这种情况尤其容易让人误判为构建工具卡死实际上去看数据库侧通常能找到一条长时间处于active状态的会话正在执行某条DDL或锁等待。还有一种是数据库侧能清楚看到会话卡在锁等待上。比如我遇到过一条ALTER TABLE已经跑了半个多小时pg_stat_activity里wait_event_type字段是Lock而持锁的会话却是一个来自后台任务、已经保持了两小时未提交的事务。这三种形态背后的根本原因不一样但处理之前必须先承认一个事实Flyway自己不会故意“无限等待”它只是在等待一个它控制不了的外部条件。你要做的是搞清楚它到底在等什么。1.2 影响范围与严重程度不要小看这个等待它的影响面远超“一个服务起不来”本身。第一应用无法启动所有依赖它的调用方都会跟着超时。如果卡住的是配置中心、注册中心这类基础设施下游系统会成片报警。第二当Flyway和业务共用一个连接池时迁移线程占着一个连接连接池很快会被其他业务请求耗尽整个服务表现为“半死不活”大量线程阻塞在拿连接的路上。第三如果卡在数据库锁等待上影响会向整个数据库实例扩散。比如迁移目标是订单表恰好有一个未提交事务锁住了这张表那么所有访问这张表的业务SQL都可能排队数据库的活跃会话数量迅速上升CPU和IO跟着上涨最终拖垮同一个实例上的其他应用。更要命的是很多团队在这种时刻的第一反应是“重启Pod”或者“回滚版本”。可如果根因是数据库里残留着一个未提交事务重启多少次都白搭——新实例启动后照样会撞上同一把锁继续无限等待。所以这种问题的排查思路远比“重启大法”重要。你必须定位到它在等什么而不是让它反复在同一堵墙上撞成同一个包。2. Flyway执行流程与等待发生的环节2.1 Flyway迁移过程简述先完整捋一遍Flyway的工作流程后面排查才会有的放矢。以一次正常的Spring Boot启动为例默认情况下Flyway会走这样几步从数据源获取一个数据库连接。校验项目里配置的迁移脚本与数据库现有版本是否一致检查checksum。访问/创建元数据表默认叫flyway_schema_history。计算待执行的迁移版本列表。尝试获取一个“迁移锁”防止多个实例同时执行迁移。逐条执行待运行的迁移脚本执行完后在元数据表写入一条成功记录。释放迁移锁关闭连接。在这七步里第5步的锁机制是Flyway的看家本事也是引发“无限等待”的一个集中区。不同数据库对这个锁的实现方式不同PostgreSQL会有专门的咨询锁MySQL和SQL Server依靠元数据表上的锁来互斥Oracle也是类似思路。这个锁本身是好设计但在多实例部署或长事务合流时可能变成等待的源头。第6步是最容易踩雷的地方。迁移脚本执行的是DDL或DMLDDL往往需要获取表级的独占锁或读锁只要目标表正被一个长事务占用DDL就会进入锁等待。如果脚本里还包含耗时的循环、外部调用或存储过程那一只脚就已经踏进了“无限等待”的泥潭。2.2 哪些环节最容易无限等待根据我实际遇到和帮别人排查过的case等待点大致可以分成四类下面分别说。第一类是“连接池拿不到连接”。Flyway启动时要取一个连接来跑迁移。如果应用连接池的maximum-pool-size很小而且已经被其他业务线程全部占住Flyway线程就会卡在connection-timeout等待上。有些项目把HikariCP的connection-timeout配置成0也就是无限等待那结果就真的是“无限等待”了。第二类是“元数据表锁冲突”。两个实例同时启动一个拿到了迁移锁另一个只能排队。正常情况这个排队只有几秒钟因为应用迁移脚本通常都在秒级内完成。但假如拿锁的实例因为脚本问题迟迟不结束或干脆被强制杀掉却没有释放锁等待方就会一直干等。第三类是“目标表被长事务持锁”。这最坑。迁移脚本想给一张表加索引但另一个连接里有个事务对这张表做过更新却一直没提交数据库的锁机制让DDL只能等。这类问题在技术上没什么悬念纯粹是“别人留下的锅”但排查的复杂度最高因为你不知道是谁、什么时候、为什么要拖着一个事务不提交。第四类是“脚本自身挂起”。这属于脚本质量问题比如里面写了pg_sleep(9999)、DBMS_LOCK.sleep或者触发了某个存储过程后一直不返回。这种情况只要手动在数据库客户端执行一遍脚本就能复现。记住一个原则无论发生在哪一步Flyway日志停住的位置只是最终的“遇难现场”真正的元凶可能在几小时前就埋下了。所以不要只盯着Flowy日志看要把视野放到数据库整体会话状态上去。3. 无限等待的常见根源与排查手段3.1 数据库锁与连接池争用先看连接池。假设你在Spring Boot里用的HikariCP默认maximum-pool-size是10connection-timeout是30秒。服务启动瞬间可能有各色线程在初始化缓存、拉取配置、预加载数据把连接池占满。这时候Flyway线程就得等一个连接空闲下来而那个连接可能永远空不出来。判断这种问题第一件事是打线程Dump。你把进程的jstack搞出来搜HikariPool.getConnection如果看到一堆线程都等在同一个锁上而且连接池的active数已经等于max数那基本就坐实了。我见过最典型的配置是把connection-timeout写成了0结果整个启动过程在连接池上卡到天荒地老。连接不是靠等来的超时必须设。数据库侧要重点看锁。MySQL用SHOW FULL PROCESSLIST看线程状态再用sys.innodb_lock_waits直接看等待关系。PostgreSQL用pg_stat_activity看wait_event_type如果等于Lock说明会话因为等待锁而挂起。Oracle看v$session里的blocking_session字段。找到那个“一直不干活”的会话无限等待的真相就浮出一半了。3.2 元数据表(flyway_schema_history)锁问题多实例部署时元数据表锁冲突是重灾区。设想一下K8s滚动发布新旧Pod并存旧Pod因为某种原因正在重启新Pod启动同时触发迁移。Flyway靠迁移锁保证了同一时刻只有一个实例能操作历史表但假设拿锁的那个实例本身处理得很慢另一个实例就会一直等。PostgreSQL场景下这种等待通常表现为pg_stat_activity里存在两个与Flyway相关的会话一个在跑DDL另一个处于等锁状态。MySQL场景则更容易看到flyway_schema_history表上出现元数据锁等待。这类问题最气的不是它发生了而是它不一定每次都发生。也许你发布十次才撞上一次但只要撞上一次就足够让上线窗口从几分钟变成几小时。我的建议是不要指望这个锁能救你而在部署层面做强制互斥把这个偶发变成不可能。具体做法放到第5章去说。3.3 会话与长事务未提交曾有一次线上故障卡在Flyway迁移上数据库是Oracle。查了v$session后发现迁移脚本的会话在等一张表的锁而持锁的会话来自一个PL/SQL Developer的客户端它执行的是一条SELECT * FROM t_user FOR UPDATE从昨天下午一直没提交。那个同事下班前忘了关窗口就这么让数据库替他的饭局“陪坐”了一整夜。这个案例非常经典事实上有大量“Flyway无限等待”的根因就是这类残留事务。排查时直接查锁等待关系MySQL可以跑SELECT * FROM sys.innodb_lock_waits;PostgreSQL可以关联pg_stat_activity和pg_locks找到阻塞者。 Oracle可以查v$locked_object和v$session的BLOCKING_SESSION。定位到阻塞会话后别急着杀。先看它的源地址、客户端名、开始时间和当前执行的SQL判断一下是不是一个已经明确的“孤儿事务”。如果确认是残留连接可以直接终止。我自己的习惯是再留几分钟观察它是否有心跳实在没动静再动手。贸然杀掉一个正在跑批量任务的会话后续数据对账的代价会让你痛不欲生。3.4 脚本自身缺陷造成的挂起有人会觉得“脚本是我写的怎么可能把Flyway搞挂”。但脚本的坑往往藏得很深。举例来说我有一次在某项目里看到迁移脚本末尾有一段BEGIN DBMS_LOCK.SLEEP(3600); END;本意是“让数据看得见”结果直接把迁移变成了夜班陪跑。T-SQL里也有类似的WAITFOR DELAY 12:00:00翻译过来就是“睡半天再说”。还有一种更隐形的脚本里调用了一个外部HTTP接口或者用存储过程UTL_HTTP调外部服务外部服务不回包脚本就一直等。数据库会话看起来是ACTIVE但执行的内容不是一个普通SQL而是一个函数或过程调用。遇到这种要快速确认就要去数据库看v$session的SQL_FULLTEXT或PostgreSQL的query字段发现它不是正常的DDL就要盯紧脚本内容。最后的土办法也最有效把待执行的迁移脚本复制出来在数据库客户端里手动跑一遍。如果手动执行也卡住那就是脚本本身的问题如果手动执行秒过那问题就出在并发环境和锁环境和代码质量无关。这个二分法能让你少走一半弯路。3.5 网络与基础设施干扰这类问题不算高频但一旦碰上就很迷惑。Flyway连接的数据库如果跨网段或跨云中间经过防火墙、负载均衡网络设备长时间没有流量可能会静默回收TCP连接。而JDBC驱动未必能及时发现连接已经死了于是线程一直挂在读响应的循环里看起来就是“无限等待”。判断特征是应用日志里停在迁移步骤但数据库端压根找不到对应会话或者短暂能看到一个连接处于空闲状态和应用的等待状态对不上。这时候需要检查的是JDBC连接串里的socketTimeout、connectTimeout参数以及数据库主机所在网络设备的idle连接超时时间。不要一开始就怀疑Flyway配置先测一下网络稳定性比如用一个简单的客户端连上数据库跑一条查询看是否也会长时间无响应。3.6 一张表盘点常用排查命令不同数据库的系统视图差异很大我把最常用的排查命令整理成一个速查表现场排查时对照着用。数据库核心命令/视图关注点MySQLSHOW FULL PROCESSLIST;Command和Time列找到长时间Query/Sleep会话MySQLSELECT * FROM sys.innodb_lock_waits;直接展示锁等待关系PostgreSQLSELECT * FROM pg_stat_activity;看state、wait_event_type、query_startPostgreSQLSELECT * FROM pg_locks;结合咨询锁查询迁移锁OracleSELECT * FROM v$session;v$locked_object看blocking_session、wait_classSQL Serversys.dm_exec_requestssys.dm_exec_sessions看blocking_session_id、status这些命令本身网上随处可查但关键不在背命令而是要知道你要找的是“谁的会话在长时间执行它持有什么锁它又在等谁的锁”。把这个链条找出来无限等待的答案基本就水落石出了。4. 从实战案例看具体解决步骤4.1 案发现场与初步判断讲一个真实的案例。某微服务项目PostgreSQL 13服务有4个副本每次发布走滚动更新。一次上线时监控突然报警健康检查失败后面连着的其他服务也开始报数据库连接池超时。登录到Pod里看日志最后一行停在Migrating schema public to version 7.3我知道又撞上“Flyway无限等待”了。这次没有犹豫直接登录数据库主机执行了下面这条查询SELECT pid, usename, state, wait_event_type, wait_event, query_start, query FROM pg_stat_activity WHERE state idle ORDER BY query_start;结果发现两个关键会话一个会话A正在执行ALTER TABLE t_order ADD INDEX idx_user_id (user_id)query_start显示25分钟前wait_event_type是Lock明确在等锁。另一个会话B来自一个后台批处理服务state是idle in transaction事务开始时间已经是两小时前。这一下就明白了会话B持有表上某些行的锁不释放会话A的DDL一直排队。4.2 定位等待源头并确认可回收为了确认会话B能不能安全终止我查了它的application_name、client_addr和backend_start发现它来自一个批处理节点的固定IP而这个批处理任务在代码设计上根本不应该保持一个事务超过两小时还不提交。合理推断是批处理线程被OOM Kill但数据库会话没有及时回滚。我决定终止它SELECT pg_terminate_backend(67890);执行后大约几秒会话A的锁等待解除那条ALTER TABLE立刻恢复执行很快就跑完了。再看应用日志Flyway已经打印出成功提交的迁移记录服务恢复正常。整个过程从定位到终止不到五分钟但前提是敢于判断这个会话是“孤儿事务”而不是正在跑的关键业务。这里有一个红线不要见锁就杀。如果持锁会话正在执行一个几百万行的批量更新强行终止可能会导致部分数据状态不一致后续对账成本极高。我在生产环境里因为判断失误杀过一个看似挂起的批量任务后来花了一整天才把数据补回来。所以终止前至少要确认三个信息会话来自哪个客户端、持锁事务已经存在多久、当前正在执行的操作是否还有心跳。4.3 修复之后的加固措施表面问题解决了但我知道如果不加防护这种问题迟早还会卷土重来。我做了三件事。第一给批处理代码增加事务超时。在Spring配置里使用Transactional(timeout 60)或数据库层面的锁超时参数确保任何事务都不可能无限期挂起。这样即使代码出现Bug数据库也会在超时后自动回滚并释放锁。第二调整Flyway启动参数。Spring Boot下的示例配置spring: flyway: enabled: true connect-retries: 3 connect-retries-interval: 10 lock-retry-count: 10 datasource: hikari: connection-timeout: 30000 maximum-pool-size: 15这里要解释一下lock-retry-count。Flyway在获取迁移锁失败时默认行为是等一定时间后再试一次。如果不设置重试次数或把间隔设得太长等待方可能表现得像“无限等待”。给一个合理上限后万一锁迟迟拿不到它会在限定的重试次数后直接抛出异常让服务快速失败并走到更明确的告警路径而不是默默挂死。第三在数据库侧加了一个小时级的巡检任务专门检查是否存在超过15分钟的idle in transaction会话。发现就发告警。这让“有人忘记提交事务”这种事不再靠运气发现。5. 预防措施与最佳实践5.1 Flyway与连接池配置建议接入了Flyway就别用默认配置直接上生产。默认参数在很多方面并不适合高并发、多实例和跨网络环境至少下面这些项要逐一过一遍。连接池的connection-timeout不要设成0也不要设得太长。我建议30到60秒启动阶段让Flyway拿不到连接就赶紧报错总比整个发布流程卡着强。maximum-pool-size也要考虑启动瞬间的竞争。如果你的服务启动时会同时做很多事可以适当调大不要卡着一个迁移连接占不出来拖死别人。Flyway自身的参数里connect-retries和connect-retries-interval控制的是连接数据库失败后的重试。如果网络偶尔抖动这两个参数能避免启动直接失败但如果数据库彻底不可用重试次数设太多反而会掩盖问题。我一般设3到5次间隔10秒。还有一个参数容易被人忽略spring.flyway.validate-on-migrate。如果设成falseFlyway会跳过校验阶段表面上看启动快了但一旦脚本checksum发生变化会在某个不可预料的时刻暴露出版本混乱问题。建议保留默认的true。5.2 迁移脚本编写的黄金守则脚本层面的预防比事后排查有价值得多。一个脚本里的地雷可能要在半年后的某次上线才爆。我的个人守则如下。第一每个迁移脚本只做一件事尽量小步。比如“建表、加索引、插初始数据”拆成三个版本而不是一个大而全的V3__init_all.sql。小步的好处是如果哪一步卡住你能在历史表和日志里清楚看到卡在哪个版本排查范围立刻缩小。第二绝不在迁移脚本里写循环、延时、外部调用。有些人喜欢在脚本里加一段数据初始化再通过Java代码访问外部服务。这个流程在正常环境没问题但外部服务一旦不可用迁移就卡死。依赖外部系统的初始化逻辑应该放到应用启动后的异步补偿任务里而不是放进Flyway脚本。第三谨慎使用带锁的DML。迁移脚本里如果有UPDATE语句默认加行锁问题不大但你如果写了SELECT ... FOR UPDATE并且这个查询后面还跟了别的逻辑就要小心事务生命周期。最好在脚本里显式提交或回滚不要依赖连接关闭来释放锁。第四多数据库兼容的团队建议在脚本头注释标明目标数据库方言并且尽量写跨库兼容的SQL。不同数据库在DDL锁行为上的差异很大同样的脚本在开发库用MySQL跑没问题生产库却是SQL Server很可能就是两种完全不同的锁等待结果。5.3 高可用环境下的并发迁移控制K8s多副本部署是Flyway锁等待的重灾区。虽然Flyway自带迁移锁但它只能防止“两个迁移同时跑”不能防止“一个迁移被外部因素拖住很久另一个实例等到怀疑人生”。更稳妥的办法是在部署编排上做互斥。我比较推荐的方案有两个。方案一用K8s的initContainer来执行数据库迁移。也就是在业务容器启动之前先让一个专门做迁移的容器跑完Flyway成功之后业务容器才被拉起。这样同一时刻最多只有一个Pod在执行迁移业务容器不会集体去抢锁。方案二在CI流水线里把数据库迁移作为独立Job先跑完这个Job再发布应用实例。这种方式适合迁移脚本比较多、发布节奏可控的传统模式也能把“迁移是否成功”从应用启动里剥离出来单独看日志和告警。如果你们已经有ZooKeeper或Redis等分布式协调组件也可以在外层套一个全局锁但这是额外自研成本一般团队没必要。优先用编排层的方案简单可靠也不依赖Flyway内部锁的某种具体实现。5.4 监控与告警体系所有偶发问题都要靠监控兜底。至少应该监控这些指标每次迁移的执行耗时、flyway_schema_history表里的版本数量、数据库活跃会话里是否存在超过N分钟的active会话、是否存在长时间idle in transaction的会话。这些指标可以通过数据库视图或自定义exporter拉取放到Grafana里做面板。我自己会在流水线中给迁移命令加一个shell超时比如timeout 900 mvn flyway:migrate如果15分钟内没有执行完就判定失败立即终止流水线并发出告警。这个做法看起来简单但真到了故障现场早一分钟知道“卡在迁移上”和花半小时去猜“为什么部署没反应”完全是两种体验。宁可快速失败也不要让整个发布流程在一个看不见希望的地方干等。行吧说回我最初讲的那个上线案例。我们后来给批处理服务加了事务超时DBA侧也挂了长事务巡检之后一年多再没遇到过Flyway卡死到需要半夜救火的情况。回想起来每次“无限等待”的根因都不复杂复杂的是你在慌乱中愿不愿意静下心去看数据库里的会话关系。我个人最大的体会就是Flyway只是那个被卡在门口的人你要做的不是催他快跑而是去看看是谁堵着门。这个思路适用于Flyway也适用于你将来会遇到的各种和锁、事务、连接池相关的诡异问题。