首页 / 资讯中心 / 文章详情

Flink Checkpoint源码阅读:从接口设计到核心抽象

Flink Checkpoint源码阅读:从接口设计到核心抽象 ★ FEATURED ARTICLE
把Flink的checkpoint源码前前后后翻了几遍最深的感受是如果一上来就钻进CheckpointCoordinator这个核心类十有八九会被里面一大坨状态机、调度逻辑、异常恢复分支绕晕。我自己的经验是先稳住从最上层的Checkpoint接口开始读把一次checkpoint到底长什么样这个骨架立起来再去追实现和调用链思路一下子就顺了。这篇就记录一下我读Checkpoint接口的完整过程和理解重点放在接口定义、类型体系、以及接口背后藏着的那几个核心设计决策上。标题里写着阅读一说明这是一个系列的起点。后面我计划继续写CheckpointCoordinator如何调度、PendingCheckpoint如何管理状态、以及各种失败恢复的源码路径。这一篇先把地基打牢不搞清楚接口层面的抽象后面看具体实现很容易被细节带偏。1. 读源码从接口入手先解决三个前置问题1.1 如果没有接口抽象Checkpoint会变成什么样可以设想一下最原始的实现方式直接在CheckpointCoordinator里写一个doCheckpoint()方法里面把所有逻辑——往哪个存储写、写哪些算子的状态、要不要做barrier对齐、失败之后怎么清理——全部堆在一起。这种写法不是不行但一旦遇到以下几种变化代码就会失控新增一种checkpoint类型比如从普通checkpoint扩展出savepoint变体调整状态存放的存储位置本机路径、分布式文件系统、对象存储checkpoint触发方式变化定时触发、外部手动触发、对齐结束触发。接口的核心理念就是把这些可能会变的部分和不变的主干逻辑剥离开。主干逻辑是触发、等待、完成/失败、清理。会变的部分是类型、元数据、计划、存放位置。Checkpoint接口就是为这个会变的部分定义一个稳定的访问入口。1.2 版本不同接口位置与命名会有差异我读的是Flink 1.14左右的源码分支。这个版本的Checkpoint接口位于flink-runtime模块的org.apache.flink.runtime.checkpoint包下。1.12、1.13的版本也基本一致只是个别方法会有增删。如果你手头是更老的版本比如1.9/1.10Checkpoint可能还不是一个独立的顶层接口而是作为CheckpointCoordinator的私有结构存在读起来会费劲很多。提示读源码之前先确认分支版本。同一个类在不同版本里可能行为完全不同用IDE看源码时千万注意当前maven依赖解析到的是哪个版本。1.3 接口为什么适合作为源码阅读的锚点接口有几个天然优势非常适合作为源码阅读的起点面积小一个接口通常只有十几个方法签名比实现类动辄上千行好消化得多方向性强每个方法名都在暗示某个环节需要什么信息便于横向对比有了接口定义之后去比对不同实现类的差异就能快速理解设计者的取舍。2. Checkpoint接口源码逐方法拆解2.1 核心方法分组识别、类型、计划、元数据、存储Checkpoint接口的方法可以分成大概五组每一组对应checkpoint生命周期中的一个关注点第一组身份识别long getCheckpointID()全局唯一的checkpoint编号单调递增。这个ID既是状态恢复时的依据也是监控里排查问题的主键。CheckpointType getCheckpointType()当前checkpoint的类型区分普通checkpoint、savepoint、增量/全量等。第二组类型与属性CheckpointProperties getCheckpointProperties()将类型展开为一组细化属性比如是否同步、是否保存点、是否强制全量快照。isSavepoint()一个便捷方法判断本次是不是savepoint触发。第三组执行计划CheckpointPlan getCheckpointPlan()描述本次checkpoint涉及哪些任务、哪些算子、哪些需要对齐、哪些需要提交。这是后续调度最核心的输入。第四组元数据CheckpointMetadata getCheckpointMetadata()包含所有算子的状态句柄、主状态master state的句柄、统计信息等。这个才是真正沉淀下来用于恢复的数据。第五组存储位置getStorageLocation()本次checkpoint数据文件要写到哪个存储路径。getStateStoreLocation()state store层面的具体位置和上者互相关联但有区别。如果只看接口本身第一印象是方法有点多。但把方法归类之后再读脉络就清晰了身份标识 行为和属性描述 执行计划 最终要持久化的元数据 落盘位置恰好覆盖了一次checkpoint从触发到完成的全部信息需求。2.2 关键设计点每个方法都返回只读快照式信息仔细看这些方法的返回类型会发现一个一致的设计倾向返回的都是只读的、不可变或近似不可变的信息对象。getCheckpointType()返回枚举getCheckpointProperties()返回不可变属性类getCheckpointMetadata()虽然有内部结构但通常也不会在接口层提供修改入口。这个设计是有道理的。Checkpoint接口的调用方非常杂调度线程要读计划RPC handler要读属性做校验监控模块要读元数据展示进度恢复管理器要读元数据做重启。如果这些读到的是可变对象任何一个调用方不小心改了一个字段都会造成跨线程的隐性问题。2.3 接口的忠实实现者PendingCheckpoint与CompletedCheckpoint接口有两个最重要的实现类正好对应checkpoint生命周期中的两个阶段实现类生命周期阶段核心差异PendingCheckpoint尚未完成正在等待所有task确认状态句柄还在收集过程中元数据不完整允许注册新的确认CompletedCheckpoint已完成所有状态已确认并固化元数据完整可被恢复流程直接使用会被写入外部存储所以Checkpoint接口的抽象程度相当高它不关心你处于哪个阶段只约定你能提供哪些信息。具体实现内部怎么维护确认计数、怎么处理超时、怎么序列化元数据全部封装在实现类内部。在读接口的时候我建议顺手把两个实现类的类名记住。后面读CheckpointCoordinator时你会看到它在两个类之间来回切换本质上就是一次checkpoint从进行中到已完成的状态跃迁。3. CheckpointType枚举与CheckpointProperties的联动逻辑3.1 一个类型值如何展开成一组行为开关CheckpointType定义了两大类SAVEPOINT和CHECKPOINT。但实际执行时很多行为不是简单的二选一而是由一组开关组合决定的。于是就有了CheckpointProperties这个展开后的属性集。读代码时会经常看到这样的调用CheckpointProperties props checkpoint.getCheckpointProperties(); if (props.isSavepoint()) { // 走savepoint特有逻辑 } else { // 走普通checkpoint逻辑 }这里的关键在于把类型判定的逻辑收敛到一个地方而不是散落四处。假如某天想加一种只保留元数据、不保留算子状态的checkpoint类型只需要在类型枚举和属性映射里增加新分支业务代码不用大改。3.2 shouldSavepoint、shouldDrain、forceFullSnapshot的配合关系CheckpointProperties里有一组布尔属性最核心的是这三个shouldSavepoint()表示本次是否是一次savepointshouldDrain()表示是否需要drain排空算子数据通常和stop-with-savepoint场景绑定forceFullSnapshot()表示是否强制全量快照常见于savepoint用于保证恢复时不需要依赖之前的增量。这三个属性不是互斥的存在组合场景。比如用户执行stop-with-savepoint那么shouldSavepoint和shouldDrain都会为true如果用户在配置里调整了savepoint的format或PSC-enhanced的约束forceFullSnapshot也可能为true。理解这个组合关系比单纯记住枚举值重要得多。3.3 为什么需要isSynchronous()这个便捷方法接口上还有一个isSynchronous()方法它本质上是shouldDrain()的封装或者与之一致。为什么需要单独的同步标志因为checkpoint触发有同步和异步两种路径定时器触发是异步的用户在调用stop-with-savepoint时是同步期望的。后续调度逻辑需要根据这个标志决定要不要阻塞等待、要不要在完成之后立刻触发回调。经验之谈读源码时遇到一个概念出现多个方法的情况不要急着觉得冗余。通常每个方法面向一个具体调用场景避免调用方自己去拼装判断逻辑。4. CheckpointPlan接口描述这次要检查哪些有状态实体4.1 为什么需要单独的Plan概念一个checkpoint不是简单的所有算子都存一份就完事。实际执行时需要知道哪些source任务需要注入barrier哪些算子是带状态的需要等待确认哪些算子只有数据没有状态不需要参与确认哪些算子涉及两阶段提交需要commit。这些信息如果全部塞到Checkpoint接口里接口会膨胀得不合理。所以Flink把这次任务计划单独提炼成CheckpointPlan。Checkpoint接口通过getCheckpointPlan()与它关联。4.2 Plan里核心方法表达的信息维度CheckpointPlan里最核心的几类信息是getTasksToCheckpoint()需要真正做状态快照的task集合getTasksToWaitFor()需要等待其确认的task集合getStreamTasksToCheckpoint()具体到stream task级别的列表getSourceTasks()/getRegionSubtasksToCheckpoint()用于确定barrier注入点和对齐范围。这几个集合之间存在包含、交叉关系。刚开始读时我一度以为getTasksToCheckpoint()返回的就是全部需要等待的task结果发现不是。有的task需要被注入barrier但不需要上报状态有的task需要参与checkpoint确认但不是状态源头。这些差异只有结合Plan才能准确表达。4.3 Plan从哪里来CheckpointPlan不是凭空生成的它是DefaultCheckpointPlanCalculator等组件根据JobGraph的拓扑结构、算子链operator chain、状态声明等信息计算出来的。这就引申出一个非常重要的视角Checkpoint接口是一次checkpoint的静态描述而Plan的计算过程才是动态逻辑的核心。接口读到这里其实已经走到了调度逻辑的门口——下一步就适合进入CheckpointCoordinator相关的代码了。5. 源码阅读中几个容易绕晕的地方5.1 方法名相似但职责不同的类别急着混为一谈读这块源码时我发现几组容易混淆的类/方法Checkpoint接口与**CheckpointCoordinator类**前者描述单次checkpoint后者负责所有checkpoint的调度管理CheckpointProperties与CheckpointOptions前者是类型推导出的行为属性后者是执行时的配置选项如存储格式、对齐方式getStorageLocation()与getStateStoreLocation()前者偏文件系统上的路径后者偏state backend内使用的具体存储位置/路径。这三组如果不提前区分读代码时很容在调用链上产生错觉。我自己的办法是遇到一个类先问一句它描述的是状态还是行为大多数混淆都能由此解开。5.2 状态句柄StateHandle的延迟创建与真实写入时机还有一个点容易误导大家CheckpointMetadata里携带的状态句柄在接口刚创建时往往并不包含真实的数据内容而只是描述如何访问/恢复状态的句柄对象。关键理解是真实的状态数据是在各个task执行快照时写入存储的句柄比如路径、偏移量、版本信息在写入完成后生成句柄被汇集到metadata里最终持久化到checkpoint的外部存储中。所以千万不要以为拿到Checkpoint接口就等于数据已经安全落盘。接口对象的创建时间点可能远早于数据真正completed的时间。这也是为什么PendingCheckpoint与CompletedCheckpoint的区分如此重要。5.3 不同接口实现类的生命周期边界来梳理一下生命周期边界读完这一块对整个checkpoint的时间线会有更直观的把握调度器发起一次checkpoint先创建PendingCheckpoint所有task逐步确认状态句柄逐步上报确认完成后PendingCheckpoint转化为CompletedCheckpointCompletedCheckpoint被写入外部系统如分布式文件系统旧的checkpoint按配置策略进行清理。Checkpoint接口横跨了1到3阶段但不同阶段的调用方只能看到与自己相关的部分。这种接口视角与实际生命周期不完全等价的情况在分布式系统源码里很常见提前有心理准备会好读很多。6. 从接口方法倒推一次checkpoint的完整链路6.1 起点触发一次checkpoint时接口如何被装配一个典型的定时checkpoint触发流程大致是这样CheckpointCoordinator的定时器触发生成一个CheckpointTriggerRequest通过CheckpointPlanCalculator计算本次的CheckpointPlan创建PendingCheckpoint并朝所有source task注入barrier后续所有逻辑都以这个Checkpoint对象的字段为核心展开。换句话说Checkpoint接口虽然是描述性的但它绑定在调度器的核心流程上。每一次触发都会生成一个具体的接口实例并且这个实例的状态会随着确认进度逐步变化。6.2 中途barrier传播与状态确认接口承担了什么在barrier传播过程中Checkpoint接口主要承担上下文传递的作用从getCheckpointPlan()里找到需要对齐的算子集合判断当前是普通checkpoint还是savepoint决定是否需要对齐上报状态时task会把状态句柄注册到PendingCheckpoint内部。这里有个很容易忽略的点接口方法没有暴露确认状态的入口。实际的确认操作都是通过PendingCheckpoint类完成的而不是通过Checkpoint接口。也就是说接口面向读取方设计而真正的写入能力在实现类中。6.3 终点completed之后接口的价值体现在哪里当checkpoint完成之后CompletedCheckpoint会被持久化。恢复流程启动时会读取最近的CompletedCheckpoint并通过getCheckpointMetadata()拿回所有算子的状态句柄再分发到对应task完成恢复。所以接口的价值并不仅仅存在于运行期它同样是恢复期的核心索引。这一层设计思想和很多流处理系统状态快照/恢复的通用模型是一致的。理解了它不止Flink再看其他系统的快照设计也会更轻松。7. 读Checkpoint接口源码的几个实用建议7.1 建议一不要逐行读要按信息维度读接口方法虽然不多但逐行读容易只见树木不见森林。建议按维度分组身份、类型、计划、元数据、存储。每读完一组停下来想一想这个维度的信息谁会消费在什么时机消费7.2 建议二动手画一张阶段-类-信息的小表格我自己读的时候做了一张很简单的表效果出奇地好生命周期阶段主要类关键信息触发CheckpointCoordinatorCheckpointPlan计划、类型进行中PendingCheckpoint确认计数、未完成列表完成CompletedCheckpoint元数据、状态句柄恢复读取CompletedCheckpoint分发句柄到各task这张表贴在IDE旁边后面读几千行实现代码时随时能把自己拉回主线上。7.3 建议三带着三个问题去读实现类读完接口之后进入PendingCheckpoint和CompletedCheckpoint之前先给自己提三个问题状态句柄是怎么进入metadata的确认完成的判定条件是什么写入外部存储时metadata如何序列化这三个问题会直接引出下一阶段要读的核心代码。我自己读下来的感受是带着问题读源码的效率比漫无目的地啃代码高出一倍不止。7.4 建议四多利用IDE的调用层次功能单独看接口没什么感觉但用IDE的Call Hierarchy功能查看getMetadata()或getCheckpointPlan()被哪些地方调用时你会立刻看到一张调用网络。这张网络图其实比任何注释都更能说明问题哪些模块对checkpoint元数据感兴趣、哪些模块需要读取执行计划。Flink的模块化设计风格在调用关系上体现得非常明显。8. 下一步阅读路线规划接口层读完之后自然而然就进入到下一环CheckpointCoordinator的调度逻辑。这块是真正的硬骨头涉及trigger、确认、超时、失败恢复、清理等一大堆状态流转建议顺着以下顺序去读CheckpointCoordinator的定时触发逻辑PendingCheckpoint如何管理多个task的确认状态CompletedCheckpoint的序列化与持久化失败恢复时如何从最近一次checkpoint重建状态。读Checkpoint接口只是万里长征第一步但这个第一步能帮你建立整体框架。后面每一步都是对这张框架图的细化与验证。这篇先到这里下一步我打算接着写CheckpointCoordinator的触发与调度流程顺便把过程中遇到的几个经典问题一起复盘掉。
阅读完成 · 觉得有帮助?
咨询建站