1. 从一次作业卡顿说起:为什么要啃Checkpoint触发这块源码
第一次真正被Flink的Checkpoint机制"教育",是在一个实时数仓项目里。当时作业跑得挺稳,数据量也不算夸张,但每隔一段时间就会看到Checkpoint超时告警,紧接着作业开始反压,端到端延迟从几百毫秒飙到十几秒。排查了半天,发现不是状态太大,也不是磁盘太慢,而是Checkpoint的触发节奏和作业的实际负载节奏对不上——上游数据洪峰来的时候,Checkpoint正好也在疯狂触发,两件事抢资源,直接把TaskManager的线程池打满了。
那次之后我就意识到,光会调env.enableCheckpointing(60000)这种API是远远不够的。你得知道这个60000毫秒背后,Flink内部到底是谁在计时、谁在发起、谁在协调、谁在确认。只有把触发链路摸清楚,遇到"Checkpoint为什么不触发""为什么触发了却一直不完成""为什么间隔和配置对不上"这类问题,才能快速定位。
这篇内容就是把我读Flink Checkpoint触发过程源码的笔记和实战理解整理出来。核心围绕三个问题展开:谁在周期性触发Checkpoint、触发之后经历了哪些阶段、每个阶段有哪些容易被忽略的细节。适合已经用过Flink Checkpoint API、但对其内部调度机制还比较模糊的开发和运维同学。读完之后,你至少能做到:看到Checkpoint相关日志和指标时,脑子里有一条清晰的时间线,而不是一堆孤立的数字。
需要说明的是,Flink版本迭代较快,不同大版本在类名和线程模型上有差异。下面涉及的具体类名以较常见的1.13到1.17区间为参考,思路是通用的,你在自己版本里对照着找对应实现即可。
2. CheckpointCoordinator:周期触发的"心脏"到底长什么样
2.1 触发源头不在TaskManager,而在JobManager
很多人下意识以为Checkpoint是每个Task自己定时发起的,其实不是。真正的发起方是JobManager侧的CheckpointCoordinator(检查点协调器)。每个执行中的作业,在JobManager里都有一个对应的CheckpointCoordinator实例,它才是整个Checkpoint流程的总指挥。
这个设计的原因很直接:Checkpoint要求的是全局一致性。如果让每个Task各自为政地发起,那各个Task的快照时间点就对不齐,恢复出来的状态就是错的。所以必须有一个中心化的协调者,统一决定"什么时候开始、这一轮包含哪些Task、什么时候算完成"。
CheckpointCoordinator在作业启动时被创建,它持有几个关键引用:
- ExecutionGraph:作业的执行图,知道有哪些ExecutionJobVertex、哪些并行子任务。
- CheckpointIDCounter:负责生成单调递增的Checkpoint ID。
- CompletedCheckpointStore:存放已完成的Checkpoint元数据。
- CheckpointStatsTracker:负责统计信息,就是你在大屏上看到的那些Checkpoint指标。
- ScheduledExecutor:真正用来做周期调度的线程池。
理解这个结构很重要,因为后面所有的触发逻辑,都是围绕这几个组件转的。
2.2 周期调度靠的不是Timer,而是ScheduledExecutor
Checkpoint的周期性触发,核心入口是CheckpointCoordinator里的一个调度任务。当你调用enableCheckpointing(interval)时,最终会走到startCheckpointScheduler()方法,它会向一个ScheduledExecutorService提交一个周期性任务。
这里有个容易踩的认知坑:很多人以为Flink用的是java.util.Timer或者某种基于时间轮的调度。实际上它用的是标准JDK的ScheduledExecutorService,通过scheduleAtFixedRate或者"执行完再延迟调度"的方式来实现。
为什么这个细节重要?因为它直接决定了触发间隔的语义。Flink的Checkpoint间隔(interval)指的是"上一轮Checkpoint触发之后,间隔多久触发下一轮",而不是"上一轮完成之后"。这个区别在状态大、Checkpoint耗时长的时候会非常明显。
举个具体例子:假设interval配成60秒,某一轮Checkpoint因为状态大跑了50秒才完成。那么下一轮并不是在完成后再等60秒,而是在上一轮触发后60秒就开始了——也就是上一轮完成后只过了10秒,下一轮就来了。如果Checkpoint耗时超过interval,就会出现"上一轮还没结束,下一轮触发时间已经到了"的情况。
注意:这种情况下Flink不会无脑叠加触发,而是会检查当前是否还有正在进行的Checkpoint。如果有,这一轮触发会被跳过或者延迟,具体行为取决于配置和版本。这也是为什么"Checkpoint间隔"和"实际两次Checkpoint之间的间隔"经常对不上。
2.3 触发前的三道"门槛检查"
调度任务被唤醒后,并不是立刻就开始发Checkpoint,而是要先过几道检查。这些检查是很多人排查"Checkpoint为什么不触发"时最容易忽略的地方。
第一道是最小间隔检查。Flink有一个minPauseBetweenCheckpoints配置,用来保证两轮Checkpoint之间至少有一段"喘息时间"。如果上一轮刚结束没多久,即使周期到了,也会被推迟。这个参数的意义在于:避免Checkpoint一个接一个地跑,把作业的正常处理时间全挤没了。
第二道是并发Checkpoint数量检查。默认情况下,同一时刻只允许一个Checkpoint在进行(maxConcurrentCheckpoints默认为1)。如果上一轮还没完成,新一轮就会被跳过。这个检查是防止多个Checkpoint同时抢资源导致雪崩。
第三道是是否处于"已触发但未完成"的挂起状态。如果Coordinator发现当前有pending的Checkpoint,且不允许并发,那这一轮就直接放弃。
这三道检查串起来,就解释了为什么你配置的interval是10秒,但实际日志里两次Checkpoint可能隔了30秒甚至更久。不是调度器坏了,而是它在老老实实地执行这些保护逻辑。
2.4 一个完整的触发调用链
把上面的流程串成一条调用链,大致是这样的(类名以常见版本为参考):
ScheduledExecutor周期性唤醒调度任务。- 调度任务调用
CheckpointCoordinator.triggerCheckpoint()。 - 方法内部先做
minPauseBetweenCheckpoints、并发数等检查。 - 检查通过后,生成新的CheckpointID,创建
PendingCheckpoint对象。 - 向所有需要参与Checkpoint的Task(通过ExecutionGraph获取)发送
TriggerCheckpoint消息。 - 等待各Task的响应(Acknowledge),进入下一阶段。
这条链路里,第5步是"触发"这个动作真正对外产生影响的时刻。在此之前都是内部判断,在此之后才进入分布式协调阶段。理解这条链路,你在看日志时就能对上号:Triggering checkpoint X这条日志,就是第5步发出的。
3. 从触发到完成:一轮Checkpoint的完整生命周期拆解
3.1 PendingCheckpoint:一个"待完成"的账本
当CheckpointCoordinator决定发起一轮Checkpoint时,它会创建一个PendingCheckpoint对象。你可以把它理解成一个"账本",记录这一轮Checkpoint的所有信息:
- 这一轮的Checkpoint ID。
- 需要参与的所有Task(ExecutionVertex)列表。
- 已经确认完成的Task集合。
- 已经确认失败的Task集合。
- 这一轮Checkpoint的元数据(比如存储路径、触发时间戳)。
这个账本的核心作用是追踪进度。每收到一个Task的确认消息,就把对应的Task从未完成集合移到已完成集合。当所有Task都确认完成时,这个PendingCheckpoint就被"提交"(commit),转成CompletedCheckpoint。
为什么要有这么个账本?因为Checkpoint是分布式的,几十上百个Task的确认消息是异步、乱序到达的。没有账本,Coordinator根本不知道还差谁没确认。这也是排查"Checkpoint卡住不完成"的关键——去看PendingCheckpoint里到底还有哪些Task没确认。
3.2 TriggerCheckpoint消息发出后,Task侧做了什么
Coordinator发出TriggerCheckpoint消息后,消息通过网络传到各个TaskManager,最终由Task的CheckpointBarrierHandler和相关的执行逻辑处理。Task侧的动作大致分几步:
- 收到触发消息,检查自己是否处于可做Checkpoint的状态(比如是否正在恢复、是否已经关闭)。
- 对齐Barrier(如果开启了Exactly-Once且是Aligned模式)。这一步是等待所有输入通道的Barrier都到达,保证快照的一致性。
- 执行状态快照。把当前operator的状态做一次快照,写到配置的状态后端(比如文件系统、RocksDB)。
- 上报确认。把快照结果(成功或失败、状态句柄等)通过
AcknowledgeCheckpoint消息回传给Coordinator。
这里有个非常关键的细节:Barrier对齐是Checkpoint耗时的大头。如果某个输入通道数据来得慢,Barrier迟迟不到,整个Task就得一直等。这就是为什么反压严重时Checkpoint特别容易超时——不是快照慢,是对齐慢。
3.3 确认消息的收集与"完成"判定
Coordinator收到Task的AcknowledgeCheckpoint后,会调用PendingCheckpoint的相应方法,把该Task标记为已完成。每次标记后都会检查一次:是不是所有Task都完成了?
如果是,就进入提交流程:
- 把PendingCheckpoint转成CompletedCheckpoint。
- 将CompletedCheckpoint存入CompletedCheckpointStore。
- 通知所有Task"这一轮Checkpoint已全局完成"(发送
NotifyCheckpointComplete消息)。 - 清理过期的旧Checkpoint(根据保留策略)。
如果某个Task上报的是失败,或者超时了,那这一轮Checkpoint就会被判定为失败,PendingCheckpoint被丢弃,并触发相应的失败处理(比如根据配置决定是否让作业失败)。
3.4 超时机制:谁在盯着时间
Checkpoint有一个checkpointTimeout配置,默认10分钟。这个超时不是Task自己判断的,而是Coordinator在创建PendingCheckpoint时,同时注册了一个超时检查。如果超过这个时间还没完成,Coordinator会主动把这轮Checkpoint标记为超时失败。
这个机制的意义在于:防止某一轮Checkpoint因为个别Task卡死而永远挂在那里,导致后续Checkpoint全部被阻塞。超时后,这轮被清理,下一轮才有机会开始。
实操心得:
checkpointTimeout不要设得太短。有些同学看到Checkpoint慢,就把超时从10分钟改成1分钟,结果Checkpoint频繁超时失败,作业反而更不稳定。正确做法是先搞清楚慢在哪(对齐慢还是快照慢),再针对性优化,而不是简单调小超时。
3.5 各阶段耗时对应的指标
Flink的Checkpoint指标里,有几个时间字段特别有用,能帮你判断瓶颈在哪:
| 指标字段 | 含义 | 瓶颈指向 |
|---|---|---|
triggerTimestamp | 触发时间点 | 调度是否准时 |
syncDuration | 同步阶段耗时 | Barrier对齐、快照同步部分 |
asyncDuration | 异步阶段耗时 | 状态异步上传、RocksDB刷盘 |
alignmentDuration | 对齐耗时 | 反压、慢通道 |
startDelay | 从触发到实际开始的延迟 | 资源竞争、线程繁忙 |
看懂这张表,你就能从指标反推源码里的哪个阶段出了问题。比如alignmentDuration特别大,那基本就是反压导致Barrier对齐慢,跟状态大小关系不大。
4. 那些让Checkpoint"不按预期走"的配置与边界情况
4.1 interval、timeout、minPause三者的相互拉扯
这三个参数是Checkpoint配置里最容易配错的一组。它们的关系可以这样理解:
- interval:理想情况下两轮Checkpoint触发之间的间隔。
- minPauseBetweenCheckpoints:两轮之间至少留出的空闲时间。
- checkpointTimeout:单轮Checkpoint允许的最长耗时。
它们之间的约束是:如果minPauseBetweenCheckpoints设置得比interval还大,那实际间隔就以minPause为准。而如果单轮耗时接近甚至超过interval,那实际触发频率会被并发限制和minPause进一步压低。
我见过一个典型误配:interval设30秒,minPause设60秒。结果运维同学一直纳闷为什么Checkpoint是60秒一次而不是30秒。原因就是minPause把节奏卡住了。所以配置时一定要让这几个值互相自洽。
4.2 并发Checkpoint:什么时候该开,什么时候别碰
maxConcurrentCheckpoints默认是1。有些场景下会把它调大,比如状态很大、单轮Checkpoint很慢,希望"用并发换吞吐"。但这个操作要非常谨慎。
并发Checkpoint意味着同一时刻有多轮Checkpoint在进行,它们会同时占用网络带宽、磁盘IO和CPU。如果作业本身资源就紧张,开并发只会让每轮都更慢,甚至互相拖垮。而且并发Checkpoint对状态后端的压力是叠加的。
我的经验是:除非你明确知道瓶颈在单轮Checkpoint的串行等待上,且资源有富余,否则不要轻易开并发。大多数情况下,优化单轮Checkpoint的耗时(比如调大状态后端缓冲区、优化对齐)比开并发更有效。
4.3 非对齐Checkpoint:绕过Barrier对齐的代价
Flink后来引入了Unaligned Checkpoint(非对齐检查点),核心思路是在Barrier到达之前,先把通道里"在途"的数据也一起快照下来,从而不用等Barrier对齐。这对反压场景下的Checkpoint非常友好,能大幅缩短对齐时间。
但代价也很明显:非对齐Checkpoint会把在途数据也纳入快照,导致快照体积变大,状态后端的写入压力增加。所以它不是万能药,适合"反压严重、对齐是主要瓶颈"的场景。如果作业本身不反压,用对齐模式反而更省资源。
从源码角度看,非对齐模式下Task侧处理Barrier的逻辑走的是不同分支,CheckpointBarrierHandler会有对应的实现类。读源码时注意区分这两条路径。
4.4 作业刚启动时的"第一次Checkpoint"
有个细节很多人没注意:作业刚启动时,第一次Checkpoint的触发时间点。由于调度器是周期性触发的,第一次触发可能是在启动后一个interval,也可能更早,取决于具体实现。这意味着作业启动后的一段时间内是没有可用Checkpoint的。
如果这时候作业挂了,就只能从更早的Savepoint或者空状态恢复。所以对于状态很重要的作业,建议启动后手动触发一次Savepoint,或者确认第一次Checkpoint已经完成再放心跑。
5. 排查Checkpoint触发异常的实战思路
5.1 先看日志,定位卡在哪一环
排查Checkpoint问题,第一步永远是看JobManager日志。关键日志有几类:
Triggering checkpoint X:触发成功。Completed checkpoint X:完成。Checkpoint X expired:超时。Received acknowledge for checkpoint X from task Y:某个Task确认。Decline checkpoint X:某个Task拒绝。
如果看到大量Triggering但没有对应的Completed,说明卡在等待确认阶段。这时候去看是哪些Task没发Received acknowledge,那些Task就是问题源头。
5.2 用指标区分"对齐慢"还是"快照慢"
日志只能告诉你卡住了,指标才能告诉你为什么卡。重点看alignmentDuration和syncDuration:
- 如果
alignmentDuration占了大头,问题在Barrier对齐,通常是反压或某个通道数据倾斜。 - 如果
syncDuration或asyncDuration占大头,问题在状态快照本身,可能是状态太大或状态后端性能不足。
这个区分非常关键,因为两者的优化方向完全不同。对齐慢要去查反压和数据分布,快照慢要去查状态大小和后端配置。
5.3 几个高频"坑"的对照表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| Checkpoint间隔比配置大很多 | minPause或并发限制 | 检查minPauseBetweenCheckpoints |
| Checkpoint频繁超时 | 对齐慢或状态大 | 看alignmentDuration和状态大小 |
| 某几个Task总是拖后腿 | 数据倾斜 | 看各subtask的确认时间 |
| 作业启动后很久没Checkpoint | 首次触发延迟 | 确认调度器已启动 |
| Checkpoint完成但恢复失败 | 状态句柄问题 | 检查状态后端和存储路径 |
5.4 一个真实的排查案例复盘
回到开头那个项目。当时的现象是Checkpoint超时告警频繁,但作业本身没挂。我先看指标,发现alignmentDuration异常大,而syncDuration正常。这就排除了状态大小的问题,指向对齐。
接着看各subtask的确认时间,发现有几个subtask明显比其他慢。进一步查数据分布,发现上游某个key的热点导致个别subtask处理压力远大于其他。Barrier到了这些慢subtask就得等,整个Checkpoint就被拖住了。
解决方案分两步:短期先把checkpointTimeout适当调大,避免误杀;长期做数据重分布,把热点key打散。调整之后,对齐时间从几十秒降到几秒,Checkpoint恢复正常。
这个案例说明一个道理:Checkpoint问题往往不是Checkpoint本身的问题,而是作业数据处理问题的外在表现。只盯着Checkpoint配置调,是治标不治本。
6. 读源码这件事,我自己的几点体会
读Flink Checkpoint源码,最大的收获不是记住了几个类名,而是建立了一种"时间线思维"。以前看到Checkpoint告警,脑子里是一团乱麻;现在能自动把它拆成"触发—对齐—快照—确认—提交"几个阶段,然后逐个排除。
另外一点体会是:源码要和指标、日志对照着读。光看代码容易陷入细节,不知道哪个分支在实际运行中更关键;光看指标又不知道背后的机制。两者结合,才能既知道"是什么",又知道"为什么"。
如果你也想深入这块,我的建议是从CheckpointCoordinator的triggerCheckpoint方法入手,顺着调用链往下读,遇到不认识的类就查它的职责。读的时候手边开着官方文档的配置说明,对照着看每个参数在代码里是怎么生效的。这样读一遍,比泛泛地看十篇文章都管用。
最后分享一个小技巧:在测试环境里把Checkpoint间隔调得很短(比如5秒),然后观察日志和指标的变化,能很快把源码里的逻辑和实际行为对应起来。这种"主动制造现象"的读码方式,比被动看代码效率高得多。