年前在客户现场排查一个挺有意思的问题:一套Flink集群配置了ZooKeeper高可用(High Availability),两个JobManager也都起来了,但作业一提交就失败,报错信息指向JobManager没有正确恢复集群元数据。查到最后发现,HA存储目录权限不对,JobGraph压根没有持久化成功,Standby节点接管后等于是个空壳集群。这类问题在Flink JobManager高可用(High Availability)的日常运维里太常见了,很多人以为配了ZooKeeper就是高可用,其实组件、持久化、数据生命周期、还有1.17之后引入的JobResultStore,每一环都可能出问题。
这篇文章我准备结合生产环境里的实战经验,把JobManager高可用从头到尾拆一遍:为什么必须做HA、选举和持久化怎么配合、数据在哪个阶段写到哪里、故障切换时具体发生了什么,以及JobResultStore怎么用。内容偏向原理加实操,适合正在维护Flink生产集群、设计容灾方案、或者准备系统梳理HA机制的朋友。
1. 为什么JobManager是"单点"里最不能挂的一个
1.1 JobManager的双重身份:控制大脑加调度中心
很多初学者会把Flink集群理解成"JobManager管作业,TaskManager管计算,两者各司其职"。这个说法没错,但它低估了JobManager的工作量。JobManager内部有Dispatcher、ResourceManager、JobMaster三个核心组件,三个组件合在一起,几乎承担了集群控制面的全部职责。
Dispatcher接收客户端提交的作业,负责调度JobMaster的创建,也是用户与集群交互的入口。ResourceManager负责集群资源的统一管理,包括Slot的申请、分配和释放。TaskManager启动后要主动向他注册,申请Slot也要走这里。JobMaster是每个作业的大脑,维护作业的ExecutionGraph,负责把Task部署到TaskManager,同时协调Checkpoint、故障恢复、作业状态转换等。
也就是说,作业能不能提交、能不能调度、能不能做Checkpoint、故障后能不能恢复,全部依赖JobManager。TaskManager再强,也只是一群等待指令的工人,工人之间不会自己排产和协作。
1.2 没有HA时,一次宕机到底会损失什么
先看一张表,理解一下JobManager挂掉后集群各部分的真实状态:
| 组件 | JM挂掉后的表现 | 实际影响 |
|---|---|---|
| TaskManager | 进程仍然活着,Slot继续被占用 | 无法接收新任务,心跳报错,逐渐与集群失联 |
| 运行中的作业 | ExecutionGraph无人维护 | Checkpoint停止,状态无法推进,作业最终失败 |
| 客户端 | 连接断开 | 无法查询状态、无法取消作业、无法提交新作业 |
| Checkpoint元数据 | 由JM管理 | 没有新的Completed Checkpoint产生,旧状态停留在本地或远端存储 |
没有HA的情况下,JobManager进程一挂,整个集群几乎立刻进入"脑死亡"状态。TaskManager短时间内不知道JobManager已经没了,仍然占着Slot,等心跳超时后才开始清理。这期间所有作业都没有人管理,Checkpoint也不做了,数据源还在继续生产数据,状态延迟越来越大,等运维手动拉起JobManager,作业基本已经处于失败或者不可恢复的状态。
更麻烦的是,默认情况下JobManager会把一些关键元数据写在本地磁盘。如果节点本身出问题(比如磁盘损坏、机器被回收),这个本地副本也会一起丢。手动重启之后,JobManager发现没有可恢复的JobGraph,只能创建一个空集群,所有作业都要从零提交,所有状态都要依赖最后那一次Checkpoint手工找回。
所以做Flink生产集群,HA不是可选项,是必选项。它不是为了让"JobManager不挂",而是为了让"挂了以后能快速、自动地恢复集群控制能力"。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JobManager高可用的三大支柱:选举、持久化、重连
2.1 Leader选举:谁拿到临时节点,谁就是老大
Flink的JobManager高可用,第一个核心机制是Leader选举。当前支持两种实现:ZooKeeper和Kubernetes。
以ZooKeeper为例,所有候选JobManager进程启动后会去ZooKeeper的指定路径下竞争一个临时节点。这个路径默认是/flink/leader/rest_server_lock和/flink/leader/jobmanager。竞争规则很简单:谁成功创建了那个临时节点,谁就是当前的Leader,其他节点成为Standby。
这里有几个关键点要理解:
- 临时节点由ZooKeeper的Session生命周期管理。如果Leader的ZooKeeper客户端会话超时或者断连,临时节点会自动消失。此时Standby节点通过Watcher机制立刻感知到Leader节点消失,马上发起新一轮竞选。
- 竞选用的是临时节点加序号机制。每个候选者都尝试创建节点,但创建成功的只有一个;如果发现节点已存在,就放弃本轮竞选并继续监听。
- SessionTimeout的设置很关键。如果设置得太小,网络抖动会让正常存活的JobManager被误判为故障,触发频繁切换;如果设置得太大,真正的故障被发现和恢复的时间就会拉长。生产环境我通常设置在20秒到40秒之间,具体要看网络质量。
Kubernetes模式下的原理类似,只是把ZooKeeper换成了Kubernetes的ConfigMap和Lease机制。通过Kubernetes API创建Lease对象,持有Lease的JobManager就是Leader。这种方式省掉了外部ZooKeeper依赖,但要求JobManager运行在Kubernetes集群内,并且需要RBAC权限。
2.2 持久化存储:HA目录里到底存了什么
Leader选举只是解决了"谁来当Leader"的问题,真正保证恢复效果的是持久化存储。Flink规定高可用模式下必须配置一个共享存储目录,也就是high-availability.storageDir。
这个目录必须是所有JobManager都能访问的共享文件系统,通常是HDFS、S3、OSS或者NFS。它里面存的东西可以用一句话概括:JobManager在内存里维护的、恢复集群所必需的元数据。
具体来说包括:
- JobGraph:作业的完整执行计划,除了用户代码之外的所有调度信息。
- ExecutionGraph的序列化快照:用于恢复作业的运行状态。
- Checkpoint的元数据句柄:指向最近一次Completed Checkpoint在Checkpoint存储中的位置。
- JobResultStore数据:作业终结后的结果记录(后面专门讲)。
- 其他运行时元数据,包括正在运行的作业列表、Slot分配情况等。
没有这个共享目录,Standby节点即使选上了Leader,也不知道之前作业长什么样、Checkpoint最新到哪个版本,恢复过程根本无从开始。
2.3 客户端和TaskManager如何重新找到新集群
第三个支柱是重连机制。选举完成、新Leader产生后,客户端和TaskManager需要知道新地址。
在ZooKeeper模式下,Leader在竞选成功后,会把自身地址写入ZooKeeper节点。ZooKeeper里维护了类似/flink/leader/rest_server这样的地址节点。客户端的RestClient、TaskManager的RPC客户端都会监听这个节点,一旦地址发生变化,就连接新Leader。
因此有些同学会看到这样的现象:JobManager都切换了,但客户端日志里还在报"Connection refused",过几秒后自动恢复。这是重连机制在起作用,不是Bug。只要等待时间不超过RPC重试策略的窗口,客户端一般都能自动连上新的JobManager。
3. 数据生命周期:从提交前到作业结束,每一步都在哪落盘
3.1 提交阶段:JobGraph和用户Jar落盘时刻
要理解HA恢复为什么能做到"作业不丢",必须从作业提交那一刻跟踪数据的流向。
客户端提交作业时,会先构建StreamGraph,再翻译成JobGraph。JobGraph包含作业的算子结构、并行度、连接关系、依赖的Jar包引用。客户端把JobGraph提交给JobManager的Dispatcher后,JobManager会立刻把JobGraph序列化写入HA存储目录。
这个过程很关键:只有JobGraph持久化完成,提交才算成功。如果客户端提交成功但持久化失败,Standby节点起来后无法重建这个作业,最终表现就是"作业提交后莫名其妙消失"。
我遇到过一次客户反馈:作业在Web UI上能看到,但JobManager一重启作业就没了。查下来发现,HA存储目录挂载的是一个容量很小的NFS卷,JobGraph写入时刚好磁盘满了,异常被吞掉,客户端却以为提交成功了。所以生产环境里一定要监控HA存储目录的容量和剩余空间,别把它当成缓存目录。
用户Jar情况比较特殊。JobGraph里只是引用了Jar包的下载地址,真正的Jar包通常要放到共享文件系统里(比如HDFS的/flink/lib或专门的user-jars目录)。如果Jar包没有共享,Standby节点重启后虽然能读到JobGraph,却无法加载用户代码,恢复立即失败。
3.2 运行阶段:Checkpoint元数据与状态句柄的持续写入
作业进入运行状态后,JobManager的角色从"提交接收方"变成"运行协调方"。任务调度、状态查询、Checkpoint协调都在内存里进行,但有一个东西会持续写持久化存储:Checkpoint元数据。
这里需要区分两个存储位置:
- Checkpoint数据本身(状态快照),由TaskManager写入
state.checkpoints.dir指定的Checkpoint存储。 - Checkpoint元数据(指向上一个数据位置的句柄、Checkpoint编号、各Task的状态句柄),由JobManager写入HA存储目录。
当一次Checkpoint经历Barrier对齐、状态快照、确认完成之后,JobManager会把元数据保存到HA存储目录。恢复时JobManager读取元数据,再根据句柄去Checkpoint存储拉取真正的数据。所以HA存储和Checkpoint存储必须同时具备,缺一个都无法恢复。
这里普遍存在一个误解:有人会认为Checkpoint就是高可用的全部。实际上Checkpoint只负责保存状态,没有人管理"哪些状态属于哪个作业、上一个成功的Checkpoint在哪",状态就是一堆孤立文件。JobManager在内存里维护的元数据,以及HA存储里的持久化副本,才是把这些文件组织起来的线索。
3.3 结束阶段:作业结果也需要"留档"
作业运行结束以后也有数据生命周期,只是很多人忽略了。
在Flink 1.17之前,作业最终是成功、失败还是被取消,这个结论只存在于JobManager内存和日志里。如果作业运行完之后JobManager重启,历史结果就丢了。对于流作业可能无所谓,但对于批作业和任务编排场景,这是一个很大的问题。
你想想看:一个批作业处理完当天数据,正常结束。下游调度系统依赖"这个作业已经成功"这个事实来决定是否触发下一个作业。如果JobManager重启后结果丢失,调度系统查不到成功记录,可能会重新触发作业,导致重复计算。这是JobResultStore组件要解决的核心问题,下面单独用一章展开。
4. 现场还原:一次Standby自动接管的状态恢复全流程
4.1 故障发生的头几秒:临时节点消失、Watcher触发、选举开始
假设现在Leader JobManager所在机器突然宕机,接下来几秒内会发生一系列事件。
第一步,ZooKeeper因为Session超时,删除Leadership对应的临时节点,同时所有Standby JobManager的Watcher被触发。
第二步,多个Standby节点几乎同时尝试创建临时节点。ZooKeeper保证只有一个节点能创建成功,这个成功的节点就是新的Leader。
第三步,新Leader在本地启动Dispatcher、ResourceManager、JobMaster等组件,然后从HA存储目录加载持久化的元数据。
这个过程中最容易被忽略的是时间窗口。从JobManager故障到新Leader选举完成,通常只需要几秒。但之后恢复元数据、重新调度作业、等待TaskManager重新注册,都会消耗额外时间。所以故障切换不是瞬间完成,整体恢复时间可能是几十秒到几分钟。
4.2 新Leader恢复JobGraph并重新调度
新Leader从HA存储读取JobGraph,拿到作业的完整执行计划。然后它会尝试为作业向ResourceManager申请Slot。
这里的分水岭来了:新Leader并不关心TaskManager是"上一代Leader见过的那些",它只关心当前集群里有没有足够的空闲Slot。如果原来的TaskManager还活着,会重新通过心跳向新Leader注册,释放Slot后提供给ResourceManager分配;如果TaskManager已经因为心跳超时退出了,新Leader只能等待新的TaskManager启动并注册。
Standalone模式下,TaskManager不会自动拉起。如果是YARN或者Kubernetes原生模式,ResourceManager会重新申请容器资源,拉起新的TaskManager。所以在Standalone环境做HA,建议把TaskManager设置成自动重启,否则JobManager恢复后只能看着作业处于RESTARTING状态干等。
4.3 从最近一次Completed Checkpoint恢复的取舍
Slot资源就绪后,JobMaster开始恢复ExecutionGraph,把各个Task重新部署到TaskManager上,然后触发状态恢复流程。
状态恢复的起点是最近一次Completed Checkpoint。JobManager从HA存储读取最新Checkpoint元数据,通知各TaskManager去Checkpoint存储加载自己的状态快照。
这里要说清楚一个关键概念:从Checkpoint恢复,通常意味着"状态回到Checkpoint时刻,数据流要从对应位点重新消费"。如果Checkpoint之后数据源又产生了新数据,那么这些数据会被重新读取一遍。在端到端一致性上,如果Sink不能保证幂等或者没有事务支持,最终效果是"至少一次"而不是"精确一次"。
很多面试里问"Flink的精确一次是怎么做到的",本质是三层配合:
- Source端把消费位点和状态一起写入Checkpoint,恢复时从位点继续。
- 状态后端通过增量快照和(可能)对齐机制保证快照一致。
- Sink端要么幂等,要么用两阶段提交协议,保证重复写入不会造成错误结果。
所以JobManager恢复只是把作业"接回轨道",端到端一致性还要看整个链路的配合。
4.4 恢复过程中常见的"二次故障"
恢复期间最容易出现的是"二次故障"。比如新Leader刚开始重新调度,某个TaskManager因为网络分区迟迟没有注册,Slot申请超时;或者Checkpoint存储里的某个文件已经被过期清理策略删除,导致加载状态失败;又或者多个作业同时恢复,挤爆了HA存储目录的带宽。
生产环境里我倾向于给恢复设置足够的超时余量。akka.ask.timeout、jobmanager.execution.recovery.timeout这些参数要按集群规模调整,不要用默认值硬扛。默认值在小集群上没问题,几十个作业同时恢复的时候很容易打满RPC连接导致连锁失败。
另外,如果有几十个作业同时需要恢复,建议在业务侧实现"错峰提交",或者在JobManager侧观察恢复时的CPU和IO情况。我曾经遇到过恢复风暴:一个Flink集群跑了一百多个作业,JobManager挂了以后,Standby节点恢复时同时加载一百多个JobGraph,内存直接撑爆,二次宕机。后来通过分批恢复、以及调大JobManager内存和RPC线程池才算稳定。
5. JobResultStore实战:终结状态持久化与结果查询
5.1 没有JobResultStore之前,作业结果放在哪
开始用JobResultStore之前,我先带你看清楚它解决的问题。
在Flink 1.17之前,一个作业的最终结果(成功、失败、取消)只存在于JobManager内存和日志中。JobManager重启后,这些信息就丢了。对于单个流作业来说,你有没有保存"上次作业成功"这个事实,影响不大;但对于批作业、DAG编排、以及需要审计结果的平台方,这个事实至关重要。
举个具体场景:夜间批处理链路上有100个作业,第60个作业失败,整个链路中断。运维修好问题后想从第60个重跑,但JobManager重启过,系统无法自动判断"第60个之前哪些作业成功了、哪些没成功",只能整条链路重跑,浪费大量计算资源。
JobResultStore就是为这种场景设计的。它把作业终结状态持久化到文件系统,使得JobManager重启后,仍然可以查询历史作业的最终结果,并且不会因为恢复机制重复执行"已完成"的作业。
5.2 JobResultStore的目录结构、存储格式与读取方式
JobResultStore的数据通常存储在HA存储目录下的job-result-store子目录里。建议显式配置high-availability.job-result-store.dir,把它放到独立的路径,不要和Checkpoint目录混在一起。
目录结构大致是这样的:
code复制/flink/recovery/job-result-store/
├── 8f1b2c3d-xxxx-xxxx-xxxx-xxxxxxxxxxxx.json
├── a1b2c3d4-yyyy-yyyy-yyyy-yyyyyyyyyyyy.json
└── ...
每个已完成作业对应一个以JobID命名的JSON文件。文件内容的核心字段包括:
| 字段 | 含义 |
|---|---|
| jobId | 作业的唯一标识 |
| result | 作业终结结果,通常是SUCCESS、FAILED、CANCELED、UNKNOWN |
| checkpointId | 作业结束时关联的最近一次Completed Checkpoint |
| failureCause | 失败原因摘要 |
| lastModification | 最后修改时间 |
实际读取时,可以直接遍历这个目录解析JSON,也可以使用JobManager的REST API来查询最近完成的作业信息。如果你用的是Flink 1.17之后的版本,Web UI的Completed Jobs列表底层数据也来自JobResultStore。
我自己做排查时最常用的是这个组合:先用REST API确认作业最终结果,如果结果不是SUCCESS,再去翻failureCause字段,直奔失败原因,比在日志里捞半天快得多。
5.3 关键配置项和清理策略
JobResultStore的核心配置项主要有两个:
| 配置项 | 说明 |
|---|---|
job-result-store.enabled |
是否启用JobResultStore,默认true |
high-availability.job-result-store.dir |
JobResultStore数据存储路径,不设置时使用默认位置 |
还有一个容易被忽视的问题:清理策略。Flink目前不会自动清理JobResultStore目录里的历史结果文件。作业每天跑一次,一年就有365个JSON文件,日积月累会占用不少存储空间,而且目录里的文件会越来越多。
生产环境建议建一个定时任务,对超过N天的JobResult文件做归档或删除。但删除前要考虑审计需求,如果有平台需要追溯历史作业结果,建议先归档到冷存储,再清理原目录,不要直接删。
5.4 实际排查场景:如何用JobResultStore确认"上次作业到底成功没有"
最近一次实际排查中,客户反馈:批作业在A天跑完后,因为上游数据修正,需要重跑。重跑之前,客户想确认A天作业是不是真的成功了,结果JobManager已经因为夜间发布重启过,Web UI里历史作业记录全没了。
我远程上去,直接在HA存储的job-result-store目录下找到对应作业的JSON文件,读取result字段,值是SUCCESS,checkpointId清楚记录着最后一次成功Checkpoint的编号。这样既确认了作业确实成功,还拿到了重跑时可以对齐的状态位点。
这一个操作就解决了"JobManager重启后历史结果丢失"的痛点。如果你还在用1.16或更早版本,并且有大量批作业编排场景,升级到1.17以上版本后,这个功能会明显省心。
6. HA配置模板与踩坑记录
6.1 Standalone加ZooKeeper的最小配置模板
最后给出一份我实测常用的Standalone模式HA配置,可以直接抄作业。
在conf/flink-conf.yaml里配置:
yaml复制high-availability: zookeeper
high-availability.storageDir: hdfs://nameservice/flink/ha
high-availability.zookeeper.quorum: zk1:2181,zk2:2181,zk3:2181
high-availability.zookeeper.path.root: /flink
high-availability.zookeeper.session-timeout: 30000
high-availability.zookeeper.client.acl: open
jobmanager.memory.process.size: 4096m
然后在conf/masters文件里配置多个JobManager地址:
code复制jm01:8081
jm02:8081
这里的high-availability.storageDir必须是HDFS、S3、OSS这类共享存储,不能用本地路径。两个JobManager机器上都要能访问到这个路径,权限要一致,避免一个节点写的文件另一个节点读不了。
如果集群规模不大,也可以用NFS作为共享存储。但NFS的并发性能一般,不要让它同时承担太多作业的Checkpoint写入,建议HA目录和Checkpoint目录分开。
6.2 Kubernetes原生HA的差异与权限准备
如果集群跑在Kubernetes里,可以考虑用Kubernetes原生HA,省掉ZooKeeper的运维成本。配置很简单:
yaml复制high-availability: kubernetes
high-availability.storageDir: s3://flink-ha-bucket/ha
但有一个前置条件:JobManager的ServiceAccount必须拥有读写ConfigMap和Lease的权限。Kubernetes HA模式下,Leader选举和元数据存储都依赖ConfigMap,RBAC权限不够的话,JobManager会一直报错无法选主。
社区实践里,Kubernetes模式更适合已经深度容器化的团队。如果公司基础设施还没有很强的Kubernetes运维能力,用ZooKeeper模式更稳,毕竟网上资料多、踩坑案例也多。
6.3 我实测中踩过的几个坑
第一个坑是HA存储目录权限不一致。两个JobManager挂载了不同的NFS路径,A写入的JobGraph在B上只读,导致切换后Standby无法恢复作业。解决方案是统一挂载路径,并且用一个专门用户运行所有JobManager进程。
第二个坑是ZooKeeper节点污染。多次故障切换后,/flink路径下残留大量旧节点,虽然不影响选主,但会在ZooKeeper里留下很多垃圾数据。解决办法是定期用ZooKeeper客户端清理旧节点,或者在变更大版本时换一个新的high-availability.zookeeper.path.root。
第三个坑是JobResultStore目录和Checkpoint目录混用。如果不显式配置high-availability.job-result-store.dir,不同版本Flink对默认位置的解析可能不一样,升级后可能出现找不到历史结果的情况。我在升级1.17之后就吃过这个亏,最后老老实实显式配置,把JobResultStore独立出来,升级后结果文件不受影响。
第四个坑和常见排查问题相关:JDBC连接器在JobManager恢复期间频繁抛出异常。这不是HA本身的问题,而是恢复期间TaskManager正在重建连接池,RPC链路还没完全就绪导致的。遇到这类报错不要急着改连接器,先确认JobManager是否还在恢复流程中,等集群稳定后再看连接器是否正常工作。
最后再分享一个我自己的检查习惯:每次升级Flink版本或者调整HA相关配置,我都会在测试环境故意用kill -9干掉JobManager,观察Standby节点的接管时间、作业恢复时间、Checkpoint加载是否成功、JobResultStore里结果是否可查。这套演练做熟了,真出故障的时候心里才有底,不会在凌晨三点盯着Web UI发呆。
