一、数据处理语义
流处理引擎通常为用户的应用程序提供三种数据处理语义,核心差异在于数据处理的重复度和准确性,具体如下:
-
最多一次(At-Most-Once):用户的数据只会被处理一次,无论处理成功与否,都不会重试、不会重发。适用于对数据准确性要求极低的场景(如非核心日志统计),优势是处理速度快、无冗余。
-
至少一次(At-Least-Once):保证数据或事件至少被处理一次。若中间发生错误、数据丢失,会从源头重新发送数据进入处理系统,因此同一事件可能被重复处理。适用于允许少量重复、但不允许数据丢失的场景(如普通消息推送)。
-
精确一次(Exactly-Once):每一条数据只会被精确处理一次,不多也不少。是最严格的语义,适用于对数据准确性要求极高的场景(如金融交易、计费统计)。
1.1 端到端(End to End)的精确一次
端到端的精确一次,指的是 Flink 应用从 Source 端(数据入口)开始到 Sink 端(数据出口)结束,整个链路的数据都能保证精确一次处理,核心注意点:
-
Flink 自身仅能保证内部处理的精确一次,无法直接保证外部系统(如 Kafka、数据库、文件系统)的"精确一次"语义;
-
要实现端到端精确一次,需满足两个条件:外部系统必须支持精确一次语义,同时借助 Flink 提供的分布式快照 和两阶段提交机制协同实现。
二、分布式快照理论
2.1 定义
分布式快照(Distributed Snapshot)是指在分布式系统中,捕获所有参与进程的本地状态,以及进程之间通道(channel)状态的全局一致状态。
其核心作用是记录分布式系统在某个时间点的全局状态 ,该状态包含两部分:每个进程的本地状态 (如算子的中间计算结果)和通道中的消息状态(如正在传输中的数据)。
类比理解:给正在进行的马拉松比赛拍一张全局全景照片,要求照片能精确反映某个瞬间所有选手的位置和状态,且比赛不能停止、相机快门速度有限------分布式快照就是在不中断系统运行的前提下,捕捉到一致的全局状态。
2.2 Chandy-Lamport算法(经典理论模型)
Chandy-Lamport算法是分布式快照的理论基石,Flink 的 Checkpoint 机制直接受其启发。该算法由 K. Mani Chandy 和 Leslie Lamport 在1985年提出,核心优势是不停止系统运行,就能获取一致的全局快照。
2.2.1 关键概念
-
标记消息(Marker):特殊的控制消息,用于划分快照边界,标记"属于当前快照的数据"和"属于下一个快照的数据";
-
进程状态:每个计算节点(如 Flink 的 TaskManager)的内存状态,对应 Flink 算子的本地状态;
-
通道状态:节点之间传输中的消息集合,对应 Flink 数据流中未被算子处理的缓冲消息。
2.2.2 原理及步骤
该算法的核心思想是:通过插入标记消息(marker)来划分快照的边界,具体步骤如下:
-
初始化:任意一个进程发起快照,首先记录自身的本地状态,然后向所有输出通道发送 Marker 消息;
-
传播:当某个进程首次收到 Marker 消息时,执行三个操作:① 记录自身的本地状态;② 标记该输入通道为空(后续该通道的消息将被记录为下一个快照的内容);③ 向所有输出通道发送 Marker 消息;
-
终止:当所有进程都收到 Marker 消息并完成本地状态记录后,全局一致的快照捕获完成。
此时得到的全局快照,对应分布式系统的一个一致割集(consistent cut),即快照中每个进程的状态和消息,都反映了某个全局一致的时间点状态。
2.2.3 一致性保证的关键特性
| 特性 | 说明 |
|---|---|
| 因果一致性 | 如果事件 e 在快照中,则 e 的所有因果前驱事件也必须在快照中(确保状态不出现"断连") |
| 非侵入性 | 无需停止系统运行即可捕获快照,不影响业务正常处理 |
| 异步性 | 各节点可独立记录状态,不需要全局时钟同步,降低分布式系统的协调成本 |
三、Flink Checkpoint 机制
3.1 Checkpoint 简介
Flink 的 Checkpoint(检查点)是基于分布式快照的概念实现的,其核心作用是:
-
定期为所有算子的状态创建一致性快照,并将快照持久化存储(如分布式文件系统、本地磁盘);
-
当系统发生故障(如节点宕机、任务失败)时,Flink 可将任务恢复到最近一次成功快照的状态,避免数据丢失和重复计算;
-
通常所说的"做 Checkpoint",就是指触发一次分布式快照,将当前所有算子的状态保存下来。
注:本文后续源码及机制分析基于 Flink 1.17 版本。
3.2 核心概念:Barrier(数据栅栏/屏障)
3.2.1 定义
Barrier 是 Flink 实现 Checkpoint 的核心载体,本质是数据流中一种特殊的标记,由 Source 节点定期插入到数据流中。
每个 Barrier 都带有一个唯一的检查点 ID,将数据流划分为两个明确的部分:
-
属于当前检查点的数据:在 Barrier 之前的数据流;
-
属于下一个检查点的数据:在 Barrier 之后的数据流。
3.2.2 Barrier 对象源码
java
// flink-runtime/src/main/java/org/apache/flink/streaming/runtime/io/checkpointing/CheckpointBarrier.java
public class CheckpointBarrier implements CheckpointBarrierHandler.Event {
private final long id; // Checkpoint ID(唯一标识)
private final long timestamp; // Barrier 创建时间戳
private final CheckpointOptions options; // Checkpoint 配置选项
private final boolean isExactlyOnceMode; // 是否启用精确一次语义标志
}
3.2.3 Barrier 携带信息
-
Checkpoint ID:唯一标识当前检查点,用于区分不同的快照;
-
是否是 Exactly-Once 的标志:标记当前检查点对应的语义类型;
-
创建时间:记录 Barrier 生成的时间,用于后续快照生命周期管理;
-
Checkpoint 选项:包含检查点的存储路径、超时时间等配置。
3.2.4 Barrier 工作原理
Barrier 的核心工作流程是"注入-传播-触发快照",具体如下:
-
Flink 的检查点协调者(JobManager 中的组件)定期触发检查点;
-
Source 节点接收到触发指令后,生成对应 ID 的 Barrier,并将其插入到数据流中;
-
Barrier 随着数据流向下游所有算子传递,传递过程中触发每个算子执行状态快照;
-
当所有算子都完成快照并确认后,当前检查点全局完成。
3.2.5 Barrier 插入时机
Barrier 由 Source 算子定期注入,注入频率由 Checkpoint 的间隔时间决定,核心逻辑在 SourceFunction 接口中,由 JobManager 通过 RPC 触发:
java
// flink-streaming-java/src/main/java/org/apache/flink/streaming/api/functions/source/SourceFunction.java
// Source算子周期性地注入Barrier
public interface SourceFunction<T> {
// JobManager通过RPC触发Source创建Barrier
// CheckpointCoordinator → TaskManager → SourceTask
}
3.2.6 Barrier 的处理
Barrier 在 DAG 图中的传播路径如下(以简单拓扑为例):
Source1 → Map → KeyBy → Window → Sink
│ │ │ │ │
Barrier→ Barrier→ Barrier→ Barrier→ Barrier
│ │ │ │ │
↓ ↓ ↓ ↓ ↓
JobManager收集所有Task的确认
Flink 中由 CheckpointBarrierHandler 负责处理 Barrier,有两种核心实现,对应不同的处理语义:
-
BarrierBuffer:用于 Exactly-Once 语义,会缓存"快流"(先收到 Barrier 的通道)的后续数据,等待"慢流"(未收到 Barrier 的通道),实现多流对齐;
-
BarrierTracker:用于 At-Least-Once 语义,不缓存数据,收到 Barrier 后仅记录,继续处理后续数据,不进行多流对齐。
3.3 协调者(Checkpoint Coordinator)
协调者(Checkpoint Coordinator)是 JobManager 中的核心组件,负责整个 Checkpoint 过程的发起、协调和确认,核心职责如下:
-
发起快照:定期(如每 10 秒)向所有 Source 任务发送指令,通知其"开始做快照 N,请注入 Barrier N";
-
收集确认:等待所有任务(最终需所有 Sink 任务)报告"快照 N 已完成",接收各任务返回的快照句柄(指向持久化状态的指针);
-
最终确认:收到所有任务的确认后,将本次快照标记为全局完成,并(可选)通知各任务清理旧的快照(避免存储冗余)。
四、Flink Checkpoint 工作流程
Flink Checkpoint 的工作流程核心差异在于"语义类型",分别对应 Exactly-Once 和 At-Least-Once 两种场景,以下详细拆解。
4.1 Exactly-Once 语义
Exactly-Once 语义的核心处理关键点:多流对齐,快流等慢流,快流缓存数据------通过多流对齐确保所有通道的 Barrier 都到达后,再触发快照,避免数据重复或丢失。
4.1.1 场景假设
假设 Flink 作业拓扑结构如下(双流输入,模拟真实业务中的多源数据处理):
SourceA−> Union−>Map−>KeyBy−>Reduce−>SinkSourceB−>/SourceA -> \ Union -> Map -> KeyBy -> Reduce -> Sink SourceB -> /SourceA−> Union−>Map−>KeyBy−>Reduce−>SinkSourceB−>/
数据流时间线及分配(模拟两通道数据异步到达):
时间轴: t1 t2 t3 t4 t5 t6 t7 t8
数据流: [ea1] [eb1] [ea2] [BarrierA(id=1)] [eb2] [ea3] [BarrierB(id=1)] [eb3]
输入通道: A B A A B A B B
4.1.2 算子行为分析
各算子在 Exactly-Once 语义下的核心行为的如下:
-
SourceA 和 SourceB:分别产生数据,在 Checkpoint 触发时,注入对应 ID 的 Barrier;
-
Union 算子:合并两个通道的数据流,将数据和 Barrier 一并转发给下游 Map 算子;
-
Map 算子:接收数据和 Barrier,处理数据并触发快照(核心对齐逻辑);
-
KeyBy 算子:仅负责数据分区,将数据按 key 发送到下游 Reduce 算子,自身不维护状态,不做快照,仅转发 Barrier;
-
Reduce 算子:有状态算子,收到 Barrier 后进行多流对齐,完成自身状态快照;
-
Sink 算子:有状态算子,收到 Barrier 后完成快照,若需端到端精确一次,还需执行两阶段提交的预提交操作。
4.1.3 Map 算子处理时间线分析
Map 算子作为有状态算子,其 Barrier 处理和快照触发逻辑最具代表性,具体时间线如下:
-
t1:收到 ea1 → 处理 ea1,输出 transformed(ea1),发送给 KeyBy 算子;
-
t2:收到 eb1 → 处理 eb1,输出 transformed(eb1),发送给 KeyBy 算子;
-
t3:收到 ea2 → 处理 ea2,输出 transformed(ea2),发送给 KeyBy 算子;
-
t4:收到 barrierA(id=1) → ① 开始对齐检查点1,等待来自通道 B 的 Barrier(id=1);② 暂停处理来自通道 A 的后续数据(ea3 会被缓冲);③ 未收到所有输入通道 Barrier 前,不向下游转发 Barrier;
-
t5:收到 eb2 → 处理 eb2,输出 transformed(eb2),发送给 KeyBy 算子(eb2 是 BarrierB 之前的数据,正常处理);
-
t6:收到 ea3 → 被缓存,不处理(通道 A 的 Barrier 已到达,后续数据需缓存等待对齐);
-
t7:收到 barrierB(id=1) → ① 所有输入通道 Barrier 到达,对齐完成;② 向下游 KeyBy 算子转发 Barrier(id=1)(表示所有属于快照1的数据已处理完毕);③ 处理缓存的数据(ea3),恢复后续数据处理;
-
t8:收到 eb3 → 处理 eb3,输出 transformed(eb3),发送给 KeyBy 算子(Barrier 已转发,后续数据正常处理)。
4.1.4 KeyBy 算子处理分析
KeyBy 算子的核心作用是数据分区,其 Barrier 处理逻辑简单:
-
自身不维护状态,因此不做状态快照;
-
将 Barrier 广播到所有下游任务(与数据分区不同:数据按 key 分组发送,Barrier 需广播,确保所有下游任务都能触发快照)。
4.1.5 Reduce 算子处理分析
Reduce 算子是有状态算子,其处理逻辑与 Map 算子一致:
-
收到 Barrier 后,先进行多流对齐(等待所有输入通道的 Barrier 到达);
-
对齐完成后,触发自身状态快照(记录每个 key 的聚合结果、Key Group 划分信息等);
-
向 downstream 转发 Barrier,恢复数据处理。
4.1.6 Sink 算子处理分析
Sink 算子是数据流的出口,其处理逻辑直接影响端到端语义:
-
收到 Barrier 后,先进行多流对齐,完成自身状态快照;
-
若需实现端到端 Exactly-Once,需在快照时执行两阶段提交的第一阶段(预提交),将数据写入临时位置(不可见);
-
等待 JobManager 确认全局快照完成后,执行第二阶段(提交),将数据写入最终位置(可见)。
4.1.7 算子快照内容分析
不同算子的快照内容不同,核心是"记录 Barrier 之前的所有状态",具体如下:
-
Source 算子:记录当前读取的位置(如 Kafka 的 offset)。例如,SourceA 快照记录读到 ea2 之后的位置,SourceB 记录读到 eb2 之后的位置;
-
Map 算子:若有状态(如计数器),快照内容为 Barrier 之前所有数据处理后的状态(如 count=4,对应 ea1、eb1、ea2、eb2);无状态则不记录额外内容;
-
Reduce 算子:记录每个 key 的聚合状态、Key Group 划分信息、序列化的状态数据等(基于 Barrier 之前的所有数据);
-
Sink 算子:记录已写入外部系统的状态(如 Kafka 事务 ID、临时文件路径),若为两阶段提交,还会记录预提交的事务状态。
4.1.8 全局一致快照
全局一致快照是 Checkpoint 的最终成果,由 JobManager 统一收集和管理,核心内容包括:
-
Checkpoint ID:唯一标识当前快照;
-
所有算子的状态句柄:包括算子 ID、状态大小、状态数据位置(文件路径 + 偏移量)、对应 Checkpoint ID;
-
元数据:快照创建时间戳、Checkpoint 配置(如间隔、超时时间)等。
以检查点1为例,其全局一致性状态需满足:所有算子的快照都基于 BarrierA 和 BarrierB 之前的所有数据(ea1、eb1、ea2、eb2),确保恢复后的数据处理逻辑一致。
4.1.9 故障恢复
当系统发生故障时,Flink 从 Checkpoint 恢复的流程如下:
-
JobManager 获取最近一次完成的 Checkpoint 状态(全局快照);
-
将每个算子的状态句柄分发给对应的 TaskManager,算子从持久化存储中读取状态数据并恢复:
-
Source 算子:重置到快照中记录的 offset(如 SourceA 从 ea3 开始重放,SourceB 从 eb3 开始重放);
-
Map 算子:恢复状态(如 count=4);
-
Reduce 算子:恢复每个 key 的聚合状态;
-
Sink 算子:恢复预提交的事务状态,等待后续提交或回滚。
-
-
所有算子恢复完成后,Source 算子开始重放 Barrier 之后的数据,作业恢复正常运行。
4.2 At-Least-Once 语义
At-Least-Once 语义的核心关键点:会进行 Barrier 对齐,但不会在等待对齐时缓存数据------因此可能出现"Barrier 之后的数据被提前处理"的情况,故障恢复时会重复处理部分数据。
4.2.1 场景假设
拓扑结构和数据流与 Exactly-Once 语义一致,仅算子处理逻辑不同。
4.2.2 算子行为分析
与 Exactly-Once 语义的核心差异在 Map 算子(有状态算子),其他算子行为基本一致:
-
SourceA、SourceB、Union、KeyBy、Reduce、Sink 算子的核心职责不变;
-
Map 算子:收到第一个 Barrier 时,不触发快照、不缓存数据,继续处理后续所有数据,直到所有输入通道的 Barrier 都到达后,才触发快照。
4.2.3 Map 算子处理时间线分析
-
t1-t3:与 Exactly-Once 一致,处理 ea1、eb1、ea2 并转发;
-
t4:收到 barrierA(id=1) → 记录 Barrier 到达,但不触发快照,继续处理后续数据;
-
t5:收到 eb2 → 正常处理并转发;
-
t6:收到 ea3 → 正常处理并转发(不缓存,与 Exactly-Once 核心差异);
-
t7:收到 barrierB(id=1) → 所有 Barrier 到达,触发快照;
-
t8:收到 eb3 → 正常处理并转发。
关键问题:快照触发时,ea3 已被处理并转发,若此时发生故障,恢复后 SourceA 会从 ea3 开始重放,导致 ea3 被重复处理------这也是 At-Least-Once 语义"允许重复"的核心原因。
4.2.4 全局快照内容分析
检查点1的全局状态与 Exactly-Once 存在差异,核心是"包含部分 Barrier 之后的数据":
-
SourceA 状态:读取到 ea2(包含);
-
SourceB 状态:读取到 eb2(包含);
-
Map 状态:已处理 ea1、eb1、ea2、eb2、ea3,状态计数为 5;
-
Reduce 状态:聚合了上述 5 条数据的结果;
-
Sink 状态:已写入上述 5 条数据的处理结果。
4.2.5 故障恢复
恢复流程与 Exactly-Once 基本一致,但存在数据重复处理:
-
故障前已处理:ea1、eb1、ea2、eb2、ea3;
-
恢复后,SourceA 从 ea3 开始重放,SourceB 从 eb3 开始重放;
-
ea3 会被重复处理,因此最终 Sink 端会出现 ea3 的重复数据。
4.3 Exactly-Once 和 At-Least-Once 的区别
两者的核心区别在于:数据是否在 Barrier 对齐期间被缓存,具体对比如下:
-
相同点:两者都会等待所有输入通道的 Barrier 到达后,才触发快照;
-
不同点:
-
Exactly-Once(使用 BarrierBuffer):收到第一个 Barrier 后,缓存该通道后续数据,对齐完成后释放缓存,无重复数据;
-
At-Least-Once(使用 BarrierTracker):收到 Barrier 后仅记录,不缓存数据,继续处理所有数据,可能出现重复处理。
-
五、两阶段提交协议与 Exactly-Once 语义
Flink 内部的 Checkpoint 仅能保证"内部处理的精确一次",要实现端到端的 Exactly-Once,必须结合两阶段提交协议(2PC),确保外部系统的写入操作也能满足原子性。
5.1 两阶段提交协议(2PC)概述
两阶段提交协议(Two Phase Commit,简称 2PC)是一种分布式一致性协议,核心作用是确保分布式系统中所有参与者(如 Flink 算子、外部数据库)对事务的提交或中止达成一致决定,避免出现"部分提交、部分未提交"的不一致状态。
在 Flink 中,2PC 用于确保:Checkpoint 完成时,所有算子对外部系统的写入操作要么全部提交,要么全部回滚,从而实现端到端的 Exactly-Once 语义。
5.1.1 协议的两个阶段
阶段1:准备阶段(投票阶段)
-
协调者(Flink 的 JobManager)询问所有参与者(如 Sink 算子)是否可以提交事务;
-
参与者执行事务操作(如将数据写入临时位置),但不正式提交;
-
参与者向协调者回复"同意提交"或"拒绝提交"(若执行失败则拒绝)。
阶段2:提交阶段(执行阶段)
-
若所有参与者都回复"同意提交",协调者向所有参与者发送"正式提交"指令;
-
参与者收到指令后,正式提交事务(如将临时数据移动到最终位置);
-
参与者向协调者回复"提交完成",事务结束。
补充:若有任意一个参与者回复"拒绝提交",协调者会向所有参与者发送"中止"指令,所有参与者回滚已执行的操作,事务中止。
5.1.2 2PC 的 ACID 数据保证
-
原子性(Atomicity):所有参与者要么都提交事务,要么都回滚,不存在部分提交的情况(2PC 最核心的保证);
-
一致性(Consistency):事务执行后,分布式系统从一个一致状态转换到另一个一致状态(如数据不丢失、不重复);
-
隔离性(Isolation):2PC 本身不直接保证隔离性,需依赖底层系统(如数据库、Kafka)的隔离机制;
-
持久性(Durability):事务一旦提交,更改会永久保存,即使系统发生故障也不会丢失。
5.2 2PC 在 Flink 中的实现
Flink 中将两阶段提交的逻辑封装到了 TwoPhaseCommitSinkFunction 抽象类中,开发者只需实现该类中的 4 个核心方法,即可实现端到端的 Exactly-Once 语义,具体方法如下:
-
beginTransaction:开启事务前,在外部系统的临时目录/临时空间创建资源(如临时文件、临时事务),后续数据写入该临时资源; -
preCommit:预提交阶段,刷写缓冲区数据,关闭临时资源(不可再写入),并启动下一个检查点的新事务; -
commit:正式提交阶段,将预提交的临时资源原子性移动到最终位置(如将临时文件重命名为正式文件),数据对外可见; -
abort:中止阶段,删除临时资源,回滚已执行的操作。
注:2PC 需搭配支持事务的 Source 和 Sink(如 Kafka 0.11+ 版本),才能实现完整的端到端 Exactly-Once。
5.2.1 典型 Sink 的 2PC 实现示例
1. 文件 Sink
java
// flink-connectors/flink-connector-files/src/main/java/org/apache/flink/connector/file/sink/
public class FileSink<IN> implements TwoPhaseCommittingSink<IN, FileSinkCommittable> {
// 阶段1:预提交(对应snapshotState)
public List<FileSinkCommittable> prepareCommit() {
// 1. 刷新所有缓冲区数据,确保数据写入临时文件
// 2. 将临时文件从".in-progress"重命名为".pending"(不可再写入)
// 3. 返回待提交的信息(不实际提交,数据仍不可见)
return Collections.singletonList(new FileSinkCommittable(...));
}
// 阶段2:提交(对应notifyCheckpointComplete)
public void commit(List<FileSinkCommittable> committables) {
// 将".pending"后缀的临时文件,原子性重命名为最终文件名
// 此时数据对用户可见,事务正式完成
for (FileSinkCommittable committable : committables) {
File pendingFile = new File(committable.getPendingPath());
File finalFile = new File(committable.getFinalPath());
pendingFile.renameTo(finalFile);
}
}
}
2. Kafka Sink
java
// flink-connectors/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/sink/
public class KafkaWriter<IN> {
private KafkaProducer<byte[], byte[]> producer;
public List<KafkaCommittable> prepareCommit(boolean flush) {
// 阶段1:预提交
// 1. 刷新生产者缓冲区,确保数据写入Kafka事务
producer.flush();
// 2. 开启新事务,为下一个检查点做准备
String newTransactionId = generateTransactionId(nextCheckpointId);
producer.beginTransaction();
// 3. 返回当前事务的可提交信息(此时事务未正式提交,数据不可见)
return Collections.singletonList(
new KafkaCommittable(producer, transactionId)
);
}
public void commit(List<KafkaCommittable> committables) {
// 阶段2:提交
// 正式提交Kafka事务,数据对消费者可见
for (KafkaCommittable committable : committables) {
committable.getProducer().commitTransaction();
}
}
}
3. 数据库 Sink(如 JDBC)
java
public class JdbcSink<IN> {
private Connection connection;
private String transactionId;
public List<JdbcCommittable> prepareCommit(boolean flush) {
// 阶段1:预提交
// 1. 执行所有INSERT/UPDATE语句(数据写入数据库,但未提交)
// 2. 不执行connection.commit(),事务处于未提交状态
// 3. 返回事务ID和连接信息,用于后续提交
return Collections.singletonList(
new JdbcCommittable(connection, transactionId)
);
}
public void commit(List<JdbcCommittable> committables) {
// 阶段2:提交
// 正式提交数据库事务,数据永久生效
for (JdbcCommittable committable : committables) {
Connection conn = committable.getConnection();
try {
conn.commit(); // 正式提交事务
} catch (SQLException e) {
conn.rollback(); // 提交失败则回滚
throw new RuntimeException("Commit failed", e);
}
}
}
}
5.3 Exactly-Once 语义与两阶段提交的协同
5.3.1 Sink 算子内部结构
Flink 中执行 Sink 逻辑的核心算子是 StreamSinkOperator,其内部包含三个核心组件,用于协同实现 2PC:
java
// flink-streaming-java/src/main/java/org/apache/flink/streaming/api/operators/sink/
// StreamSinkOperator是执行Sink逻辑的算子
public class StreamSinkOperator<InputT> extends AbstractStreamOperator<Object>
implements OneInputStreamOperator<InputT, Object> {
private transient SinkWriter<InputT> sinkWriter; // 负责数据写入(临时资源)
private transient Committer<?> committer; // 负责事务提交
private transient ListState<byte[]> committerState; // 存储待提交事务信息
// 处理元素的核心方法
public void processElement(StreamRecord<InputT> element) throws Exception {
// 直接将数据写入SinkWriter(临时资源,未正式提交)
sinkWriter.write(element.getValue(), context);
}
}
5.3.2 Sink 算子与两阶段提交的协同流程
Sink 算子在 Barrier 对齐后,会与 Checkpoint 协同执行两阶段提交,核心分为两个步骤,分别对应 Checkpoint 的两个关键时机:
-
步骤1:Checkpoint 快照时,执行 2PC 第一阶段(预提交)
-
对应 Sink 算子的
snapshotState()方法; -
执行
sinkWriter.prepareCommit(),完成预提交(刷写缓冲区、关闭临时资源); -
将预提交信息(如临时文件路径、事务 ID)保存到
committerState(算子状态),相当于"投票同意提交"; -
此时数据仍在外部系统的临时位置,对用户不可见。
-
-
步骤2:全局快照完成后,执行 2PC 第二阶段(正式提交)
-
JobManager 确认所有算子都完成快照后,向所有 Sink 算子发送
notifyCheckpointComplete()通知; -
Sink 算子收到通知后,从
committerState中读取预提交信息; -
执行
committer.commit(),完成正式提交(临时资源转正式资源); -
数据对外可见,事务完成。
-
5.3.3 协同流程源码示例
java
// flink-streaming-java/src/main/java/org/apache/flink/streaming/api/operators/sink/
public class StreamSinkOperator<InputT> extends AbstractStreamOperator<Object> {
// ========== 阶段1: 在快照中预提交(2PC 准备阶段) ==========
@Override
public void snapshotState(StateSnapshotContext context) throws Exception {
// 1. 执行预提交操作,获取待提交信息
List<Committable> committables = sinkWriter.prepareCommit(false);
// 2. 将预提交信息序列化后,保存到算子状态(相当于"投票")
pendingCommits.add(serializeCommittables(committables));
// 3. 此时数据处于预提交状态:文件在临时目录、Kafka事务未提交、数据库事务未提交
}
// ========== 阶段2: 在通知中完成提交(2PC 提交阶段) ==========
@Override
public void notifyCheckpointComplete(long checkpointId) throws Exception {
// 1. 仅当全局快照完成后,才执行正式提交
// 2. 从算子状态中读取当前检查点的预提交信息
List<Committable> committables = getPendingCommits(checkpointId);
// 3. 执行正式提交,数据对外可见
committer.commit(committables);
}
}
5.3.4 示例:双流输入的 Sink 算子 2PC 执行流程
基于前文的双流拓扑(SourceA、SourceB → Union → Sink),结合数据流时间线,Sink 算子的 2PC 执行流程如下:
-
t1-t3:Sink 处理 ea1、eb1、ea2,将数据写入临时位置(不可见);
-
t4:收到 BarrierA,开始 Barrier 对齐,继续处理后续数据;
-
t5:处理 eb2,写入临时位置;
-
t6:收到 ea3,因 Barrier 未对齐,缓存 ea3(不写入);
-
t7:收到 BarrierB,对齐完成,触发 Checkpoint 快照:
-
调用
snapshotState(),执行prepareCommit(),预提交 ea1、eb1、ea2、eb2; -
预提交信息保存到
committerState; -
释放缓存的 ea3,继续处理并写入临时位置。
-
-
t7 之后:JobManager 确认所有算子快照完成,发送
notifyCheckpointComplete(1); -
Sink 算子收到通知,执行
commit(),将预提交的数据(ea1、eb1、ea2、eb2)移动到最终位置,对外可见。
六、At-Least-Once 语义下的 Sink 算子行为
6.1 核心特点:无需两阶段提交
At-Least-Once 语义允许数据重复处理,因此无需复杂的两阶段提交机制,Sink 算子的行为更简单:
-
数据直接写入外部系统,无需临时资源,写入后立即对外可见;
-
无需实现
TwoPhaseCommittingSink接口,没有prepareCommit和commit的分离; -
快照仅记录当前的写入位置(如文件偏移量、Kafka offset),用于故障恢复时重放数据。
6.2 Sink 算子的快照实现
java
// 在AT-LEAST-ONCE下,Sink的快照通常很简单
public class SimpleSinkOperator<IN> extends AbstractStreamOperator<Object> {
@Override
public void snapshotState(StateSnapshotContext context) throws Exception {
// 1. 刷新缓冲区,确保所有已处理的数据都写入外部系统(立即可见)
// 2. 记录当前的写入位置(如文件路径+偏移量、Kafka offset)
// 3. 无预提交阶段,数据已直接可见
state.update(currentWritePosition);
}
}
6.3 示例:At-Least-Once 语义下的 Sink 时间线
基于前文的数据流(ea1、eb1、ea2、barrierA、eb2、ea3、barrierB、eb3),Sink 算子的时间线如下:
-
t1: 收到ea1(通道0) → 立即写入外部系统(可见);
-
t2: 收到eb1(通道1) → 立即写入外部系统(可见);
-
t3: 收到ea2(通道0) → 立即写入外部系统(可见);
-
t4: 收到barrierA(通道0) → 记录通道0收到Barrier,不中断数据处理,继续监听其他通道;
-
t5: 收到eb2(通道1) → 立即写入外部系统(可见);
-
t6: 收到ea3(通道0) → 立即写入外部系统(可见);
// 注意:在EXACTLY-ONCE下ea3会被缓冲,但At-Least-Once语义下直接处理,无缓存逻辑;
-
t7: 收到barrierB(通道1) → 所有输入通道Barrier全部到齐,触发检查点1快照;
→ 调用Sink算子的snapshotState()方法;
→ 记录当前写入位置(如ea3、eb2的写入偏移量/ID),完成快照;
-
t8: 收到eb3(通道1) → 立即写入外部系统(可见)。
七、Checkpoint 参数配置
默认情况下,Flink 的 Checkpoint 机制处于关闭状态,需在程序中手动开启并配置相关参数,以适配不同业务场景的需求。核心配置代码及说明如下:
java
// 1. 开启检查点机制,并指定检查点之间的时间间隔(单位:毫秒)
// 示例:每1000毫秒触发一次检查点
env.enableCheckpointing(1000);
// 2. 可选配置:设置检查点语义(默认 Exactly-Once,可切换为 At-Least-Once)
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
// 3. 可选配置:设置两个检查点之间的最小时间间隔(避免检查点密集执行,单位:毫秒)
// 示例:两次检查点之间至少间隔500毫秒
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(500);
// 4. 可选配置:设置检查点执行超时时间(超时未完成则取消该次检查点,单位:毫秒)
// 示例:检查点执行超时时间为60000毫秒(1分钟)
env.getCheckpointConfig().setCheckpointTimeout(60000);
// 5. 可选配置:设置最大并发执行的检查点数量
// 示例:最多允许1个检查点并发执行(避免资源竞争)
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);
// 6. 可选配置:将检查点持久化到外部存储(避免集群重启后状态丢失)
// ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION:取消作业时保留检查点
// ExternalizedCheckpointCleanup.DELETE_ON_CANCELLATION:取消作业时删除检查点
env.getCheckpointConfig().enableExternalizedCheckpoints(
ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);
// 7. 可选配置:恢复优先级(有更近的保存点时,优先回退到检查点)
env.getCheckpointConfig().setPreferCheckpointForRecovery(true);
八、Checkpoint 优化方案
8.1 异步存储(Asynchronous Snapshotting)
异步存储是指在触发状态快照时,不阻塞主线程的数据处理,将状态持久化的操作放到后台异步执行,核心目的是减少检查点对业务处理性能的影响。
8.1.1 工作原理
-
RocksDB状态后端的异步快照 :
① 检查点触发时,RocksDB会创建一个数据库的一致视图(避免快照过程中数据被修改);
② 后台线程异步将RocksDB的SST文件复制到检查点存储目录;
③ 主线程无需等待存储完成,继续处理新的数据、写入新的MemTable,不影响吞吐和延迟。
-
内存状态后端的异步快照 :
采用"写时复制"(Copy-On-Write)技术,快照时复制当前内存状态的副本,主线程继续修改原内存状态,副本异步写入持久化存储,避免阻塞。
8.1.2 核心优点
-
高吞吐:数据处理不中断,主线程始终专注于业务计算;
-
低延迟:Barrier传播、数据处理不受状态存储操作的影响;
-
高资源利用率:CPU计算与I/O存储操作并行执行,充分利用集群资源。
8.2 增量存储(Incremental Checkpointing)
增量存储是指每次检查点仅保存自上一次检查点以来发生变化的状态部分,而非全量状态,适用于状态量大(如TB级)的场景,可大幅优化存储和传输开销。
8.2.1 核心对比(与完整存储)
假设一个作业有100GB的状态,完整存储与增量存储的差异如下:
-
完整存储:每次检查点需保存100GB状态,网络传输100GB,存储占用为"检查点数量 × 100GB",资源消耗巨大;
-
增量存储:仅保存变化的状态(如10GB),网络传输和存储占用大幅降低,适合长期运行、状态持续增长的作业。
8.2.2 优点与挑战
-
优点 :
① 存储空间大幅减少(通常减少50%-90%);
② 检查点执行速度更快,复制和传输的数据量更少;
③ 对大型状态友好,可支撑TB级状态的高效快照。
-
挑战 :
① 依赖链管理:每个增量检查点依赖上一次的检查点(全量或增量),形成依赖链;
② 清理策略:需智能清理过期检查点,同时保留依赖链,避免恢复失败;
③ 恢复复杂度:恢复时需整合所有相关的增量检查点和基础全量检查点,重建完整状态。
8.2.3 工作原理(以RocksDB为例)
RocksDB采用LSM树(日志结构合并树)存储数据,天然适合增量存储:
检查点触发时,仅复制自上一次检查点以来新增或修改的SST文件,未变化的SST文件直接复用,无需重复存储和传输,大幅提升效率。
九、保存点机制(Savepoints)
保存点(Savepoints)是Checkpoint机制的特殊实现,核心作用是支持作业的计划维护(如集群升级、作业重构),通过手动触发的方式将状态持久化到指定路径,避免维护过程中状态丢失。
9.1 核心特性
-
手动触发:需通过命令行或API手动触发,而非自动定期执行;
-
持久化存储:状态保存到指定的外部存储路径,不会被Flink自动清理;
-
标准化格式:状态格式统一,支持跨版本、跨作业的恢复(如作业拓扑微调后仍可恢复)。
9.2 常用命令示例
触发保存点并指定存储路径(Flink命令行):
bash
# 语法:bin/flink savepoint <jobId> [<targetDirectory>]
# 示例:触发ID为abc123的作业的保存点,存储到/hdfs/flink/savepoints目录
bin/flink savepoint abc123 /hdfs/flink/savepoints
更多命令(如从保存点恢复作业、删除保存点)可参考Flink官方文档。
十、Checkpoint 与 Savepoint 对比
两者均用于状态持久化和故障恢复,但核心用途、触发方式等存在显著差异,具体对比如下:
| 特性 | Checkpoint(检查点) | Savepoint(保存点) |
|---|---|---|
| 核心目的 | 容错恢复,应对系统故障(如节点宕机) | 计划维护、版本升级、作业重构 |
| 触发方式 | 自动、定期(由配置的间隔时间控制) | 手动、按需(通过命令行/API触发) |
| 生命周期 | 临时状态,可被Flink自动清理(如超过保留数量) | 持久状态,直到手动删除 |
| 性能影响 | 经过优化,对业务性能影响小 | 可能影响性能(全量快照,无增量优化) |
| 状态格式 | 可能为Flink内部格式,不保证跨版本兼容 | 统一标准化格式,支持跨版本、跨作业兼容 |
| 恢复灵活性 | 仅支持同一作业的恢复(拓扑不变) | 可恢复为不同作业(拓扑微调后仍可恢复) |