Flink HDFS Sink 如何保证 exactly-once 语义

本文深入源码分析,详细解释了 Apache Flink 中 BucketingSink 如何通过引入中间状态和最终状态,实现数据写入 HDFS 的 Exactly-Once 语义,确保数据的一致性和可靠性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

本文将从源码层面来分析在Flink中是如何保证sink数据到HDFS的exactly-once语义的。

Flink中sink数据到HDFS是由BucketingSink来完成。BucketingSink产生的HDFS文件路径格式如下,

/{base/path}/{bucket/path}/{part_prefix}-{parallel_task_index}-{count}{part_suffix}

其中,

  • {base/path},构造BucketingSink时指定的base路径;
  • {bucket/path},分桶路径,BucketingSink可以对数据进行分桶(也可以理解为分区),可以根据系统时间进行分桶,也可以根据数据进行分桶,通过实现Bucketer可以自定义分桶规则;
  • {part_prefix}{part_suffix},可自定义的字符串,{part_prefix}默认为part{part_suffix}默认为空;
  • {parallel_task_index},因为存在并行的sink task,因此写出去的文件需要用task index来区分;
  • {count},一个sink task在一个分桶下不可能只允许写入一个文件,BucketingSink会对文件进行滚动操作,有两种rolling策略,文件大小以及时间(与日志文件rolling策略类似)。通过BucketingSink#setBatchSizeBucketingSink#setBatchRolloverInterval可以分别设置该大小及时间。默认大小为384MB,时间为Long.MAX_VALUE{count}从0开始,根据文件rolling递增;

为了实现exactly-once语义BucketingSink产生的文件有3种不同的状态:

  • in-progress,分桶下正在被写入的文件,一个分桶只会有一个。文件名格式为{in_progress_prefix}{part_prefix}-{parallel_task_index}-{count}{part_suffix}{in_progress_suffix}
  • pendingin-progress状态的文件关闭后进入pending状态,文件重命名,等待Checkpoint。文件名格式为{pending_prefix}{part_prefix}-{parallel_task_index}-{count}{part_suffix}{pending_suffix}
  • finished,Checkpoint成功之后,pending状态的文件即可置为finished状态,文件重命名,该状态的文件即为最终产生的可用文件,文件名格式之前已经描述过了;

另外,in-progress状态文件关闭,进入pending状态,由两种机制触发,一个是上文已经提到过的rolling策略,另一个则是分桶inactive触发的文件关闭。BucketingSink会定期扫描所有分桶,当某个分桶超过一定时间没有写入,则会关闭该分桶下in-progress状态的文件,进入pending状态。通过BucketingSink#setInactiveBucketCheckIntervalBucketingSink#setInactiveBucketThreshold即可分别设置扫描周期以及分桶inactive时间阈值,默认都是60秒。

接下来开始进入源码分析,主要是这几个方法:

  • initializeState
  • open
  • invoke
  • snapshotState
  • notifyCheckpointComplete

先说明下为什么是这几个方法。每个Flink程序都会被转换成JobGraph,在运行时最终部署成一个个的StreamTask,每个StreamTask执行自己负责的OperatorChain。而所有的SinkFunction都是由StreamSink这个operator来运行。

/**
 * A {@link StreamOperator} for executing {@link SinkFunction SinkFunctions}.
 */
@Internal
public class StreamSink<IN> extends AbstractUdfStreamOperator<Object, SinkFunction<IN>> 
		implements OneInputStreamOperator<IN, Object> { ... }

StreamTask的执行是通过其invoke方法。invoke方法做的事情大致如下,

 *  -- invoke()
 *        |
 *        +----> Create basic utils (config, etc) and load the chain of operators
 *        +----> operators.setup()
 *        +----> task specific init()
 *        +----> initialize-operator-states()
 *        +----> open-operators()
 *        +----> run()
 *        +----> close-operators()
 *        +----> dispose-operators()
 *        +----> common cleanup
 *        +----> task specific cleanup()

其中,initialize operator states,open operators以及run是这里主要关注的三个操作,对应的代码块如下,

			synchronized (lock) {

				// both the following operations are protected by the lock
				// so that we avoid race conditions in the case that initializeState()
				// registers a timer, that fires before the open() is called.

				initializeState();
				openAllOperators();
			}

			// final check to exit early before starting to run
			if (canceled) {
				throw new CancelTaskException();
			}

			// let the task do its work
			isRunning = true;
			run();

initializeState方法会调用到BucketingSink#initializeStateopenAllOperators方法会调用到BucketingSink#openrun方法会调用到BucketingSink#invoke

另外两个方法,snapshotState以及notifyCheckpointComplete是跟Checkpoint相关的,StreamTask执行Checkpoint时,会调用operator的snapshotState方法,最终会调用到BucketingSink#snapshotState。整个Job Checkpoint成功后会发送通知BucketingSink#notifyCheckpointComplete会被调用。

下面就来看下这些方法的具体实现。

initializeState

initializeState主要做了两件事:

  1. 调用initFileSystem初始化Hadoop的FileSystem
  2. 调用handleRestoredBucketState从Checkpoint/Savepoint中恢复状态信息;

我们往下先看看其他方法再回过头来看状态恢复的实现,即handleRestoredBucketState方法。

open

open方法也比较简单,主要是利用ProcessingTimeService注册定时器,定时检查上文提到的inactive的分桶。

invoke

invoke方法主要做了四件事,如下注释,

	@Override
	public void invoke(T value) throws Exception {
	    /******* 第一步,使用Bucketer获取当前数据所属的分桶 *******/
		Path bucketPath = bucketer.getBucketPath(clock, new Path(basePath), value);

		long currentProcessingTime = processingTimeService.getCurrentProcessingTime();

	    /******* 第二步,获取分桶状态,若分桶不存在,则生成分桶状态信息 *******/
		BucketState<T> bucketState = state.getBucketState(bucketPath);
		if (bucketState == null) {
			bucketState = new BucketState<>(currentProcessingTime);
			state.addBucketState(bucketPath, bucketState);
		}

	    /******* 第三步,对分桶当前正在写入的文件执行rolling策略 *******/
		if (shouldRoll(bucketState, currentProcessingTime)) {
			openNewPartFile(bucketPath, bucketState);
		}

	    /******* 第四步,数据写入文件 *******/
		bucketState.writer.write(value);
		bucketState.lastWrittenToTime = currentProcessingTime;
	}

分桶状态(BucketState)信息包括:

  • currentFile,该分桶当前正在被写入,即in-progress状态的文件;
  • currentFileValidLength,该文件的有效长度;
  • creationTime,该文件的创建时间;
  • lastWrittenToTime,该分桶最后一次写入的时间;
  • partCounter,上文提到的文件名称格式中的{count}值;
  • pendingFiles,该分桶下处于pending状态的文件;
  • pendingFilesPerCheckpoint,等待Checkpoint成功通知的文件;

来看下文件新建跟写入的实现。由openNewPartFile新建文件,该方法主要做两件事:

  1. 调用closeCurrentPartFile方法,如果当前分桶有处于in-progress状态的文件,则调用Writer#close方法关闭该文件,并且将该文件重命名,置为pending状态,并修改分桶状态的pendingFiles信息;
  2. 构造新的in-progress状态的文件名,调用Writer#open打开文件,修改分桶状态的currentFilecreationTime等信息;

文件的写入则是调用Writer#write来完成。可以看到这里分别使用了Writeropenwriteclose方法来实现文件的创建,写入以及关闭。Writer的默认实现为StringWriter,下面来看下该实现。

StringWriter

  • open,调用由BucketingSink初始化的FileSystemcreate方法来创建文件,得到FSDataOutputStream
  • write,调用FSDataOutputStream#write方法写入数据;
  • close,首先根据配置(syncOnFlush)调用FSDataOutputStreamhsync或者hflush来flush数据(二者区别请参考API文档),然后调用FSDataOutputStream#close方法关闭文件;

snapshotState

snapshotState主要是以下三步,

  1. 对所有分桶调用Writer#flush将缓存的数据写出去,并记录文件长度,更新currentFileValidLength信息;
  2. 修改所有分桶的pendingFilesPerCheckpoint信息,记录本次Checkpoint该分桶对应的pendingFiles,并将pendingFiles置空;
  3. 将所有分桶状态信息保存到OperatorStateStore当中,以便后续进行持久化

notifyCheckpointComplete

notifyCheckpointComplete主要做的事情就是根据snapshotState中记录的pendingFilesPerCheckpoint信息,将所有等待Checkpoint成功的pending状态的文件重命名,置为最终的,也是可用的finished状态。

handleRestoredBucketState

现在可以回过头来看下,状态恢复是如何实现,从而保证了exactly-once语义的。状态恢复实现,上文我们已经提到,是在handleRestoredBucketState方法。

	private void handleRestoredBucketState(State<T> restoredState) {
		Preconditions.checkNotNull(restoredState);

		for (BucketState<T> bucketState : restoredState.bucketStates.values()) {

			/******* Checkpoint成功时`pendingFiles`应该是空的 *******/
			// we can clean all the pending files since they were renamed to
			// final files after this checkpoint was successful
			// (we re-start from the last **successful** checkpoint)
			bucketState.pendingFiles.clear();

			/******* 处理上一次Checkpoint成功时处于`in-progress`状态的文件 *******/
			handlePendingInProgressFile(bucketState.currentFile, bucketState.currentFileValidLength);

			// Now that we've restored the bucket to a valid state, reset the current file info
			bucketState.currentFile = null;
			bucketState.currentFileValidLength = -1;
			bucketState.isWriterOpen = false;

			/******* 处理分桶状态的`pendingFilesPerCheckpoint`信息 *******/
			handlePendingFilesForPreviousCheckpoints(bucketState.pendingFilesPerCheckpoint);

			bucketState.pendingFilesPerCheckpoint.clear();
		}
	}

可以看到主要是根据Checkpoint成功后持久化下来的分桶状态信息进行处理,in-progress状态的文件以及pendingFilesPerCheckpoint信息。下面来看下对应的handlePendingInProgressFile以及handlePendingFilesForPreviousCheckpoints这两个方法。

handlePendingInProgressFile

处于in-progress状态的文件,在该次Checkpoint成功之后,故障发生之前(这里假设状态恢复是由于发生故障引起),有以下几种可能,

  • 仍然处于in-progress状态,没有继续写入,文件有效长度仍然是Checkpoint时由snapshotState方法记录下的currentFileValidLength
  • 仍然处于in-progress状态,发生了写入,文件有效长度大于记录下的currentFileValidLength
  • 已经关闭并处于pending状态;
  • 已经处于finished状态;

无论处于以上哪种状态,现在需要做的,就是将文件的有效长度恢复到Checkpoint成功时记录的currentFileValidLength。因为恢复后的in-progress状态的文件不再继续写入(产生新文件来写入后续数据),因此先将该文件重命名置为finished状态(已经是finished状态则省略这一步),然后有两种做法,

  1. 如果文件系统支持truncate,则直接将文件truncate到currentFileValidLength这个有效长度即可;
  2. 不支持truncate的话,则新建一个{valid_length_prefix}{part_prefix}-{parallel_task_index}-{count}{part_suffix}{valid_length_suffix}文件,将currentFileValidLength写入该文件,后续在读取数据文件时需要先读取这个记录了该文件有效长度的文件以确保数据的有效性,否则可能读取到重复的数据,这样就不能保证exactly-once语义了;

handlePendingFilesForPreviousCheckpoints

这个方法主要是处理分桶状态的pendingFilesPerCheckpoint信息,当Checkpoint成功(snapshotState方法)持久化下来的pendingFilesPerCheckpoint,保存的是等待Checkpoint成功通知的处于pending状态的文件,这些文件在Checkpoint成功之后,故障发生之前,有下面两种可能:

  1. Checkpoint成功,但是Checkpoint成功通知之前发生了故障。那么此时这些文件应该仍处于pending状态,因为从pengding转为finished状态是在Checkpoint成功通知到才会发生(也是就成功执行了notifyCheckpointComplete方法)。针对这种情况,现在需要将这些文件置为finished状态;
  2. Checkpoint成功,也成功通知到之后才发生了故障。此时这些文件已经处于finished状态,无需额外操作;

这里需要额外说明,Checkpoint是否成功,在发送通知,即调用notifyCheckpointComplete之前就已经确定了。notifyCheckpointComplete即使发生了异常也不会导致Checkpoint失败,参考CheckpointListener的API文档,

This method is called as a notification once a distributed checkpoint has been completed. Note that any exception during this method will not cause the checkpoint to fail any more.

exactly-once语义

经过源码层面的分析可以看到,对exactly-once语义的保证,是通过引入中间状态(in-progresspending)和最终可用状态(finished)来实现的,是一种两阶段提交(2PC)的方案。当故障发生时,对处于中间状态的数据进行回滚或者提交(initializeState方法)以保证数据的有效性。数据只有流转(Checkpoint成功)到最终状态才是可用的。

结语

本文通过对源码的分析来了解BucketingSink对exactly-once语义的保证。另外,这里记录下一个潜在的问题,就是数据写入的性能问题,当前的设计,每个分桶下只有一个正在写入,即in-progress状态的文件,并且是在invoke方法同步写入,在数据量大的情况下,数据写入的性能一定是不容乐观的。后面可能会有这方面相关的优化,例如异步化,stay tuned o(∩_∩)o

<think>好的,我现在需要回答用户关于Flink如何保证Exactly-Once语义的问题。用户之前询问了FlinkSQL双流Inner Join的触发机制,现在转向Exactly-Once的实现机制。我需要结合用户提供的引用内容,以及之前的对话历史来构建回答。 首先,用户引用了五个资料,这些资料都涉及FlinkExactly-Once语义,包括检查点、屏障、状态一致性、端到端保证等。我需要确保将这些要点整合到回答中。 用户的问题比较宽泛,询问实现机制,所以需要结构清晰地分步骤或模块解释。根据引用内容,FlinkExactly-Once主要通过检查点机制、屏障对齐、状态后端和端到端集成来实现。 我需要先介绍Exactly-Once的重要性,然后分核心机制详细说明,可能包括检查点、屏障、状态一致性、端到端保证,最后给出优化建议。同时,用户之前的回答中提到了Watermark和状态管理,可能需要在相关内容中适当关联,但当前问题更关注Exactly-Once,所以需要专注于该主题。 另外,用户提供的引用中提到检查点保存状态到持久化存储,如分布式文件系统,以及需要外部系统的事务支持。这些点需要涵盖在端到端部分。同时,引用5提到消费者的事务性提交,比如使用MySQL,这也需要提到。 需要注意避免使用Markdown格式,保持口语化,但按照用户之前的示例,回答需要结构清晰,分点列出。可能需要使用技术术语,但要解释清楚,比如“屏障对齐”是什么,检查点如何工作等。 还需要生成相关问题,参考之前的示例,问题应围绕实现机制、配置优化、故障处理等展开。 最后,确保引用正确的来源,用户提供的引用编号是[^1]到[^5],需要在回答中适当标注引用,例如在检查点机制处引用[^4],在端到端部分引用[^5]等。 现在开始组织内容: 1. 概述Exactly-Once的重要性,引用用户提供的资料中的说明。 2. 核心机制分点:检查点和屏障、状态一致性、分布式快照、端到端保证。 3. 每个机制详细说明,结合引用内容。 4. 优化建议,如检查点间隔、状态后端选择等。 5. 相关问题生成,如检查点配置、端到端实现、故障恢复等。 需要确保技术细节准确,比如检查点的工作流程,屏障如何插入和传递,两阶段提交协议的作用等。同时,避免过于冗长,保持清晰简洁。</think>#### Flink Exactly-Once语义实现机制详解 Flink通过**分布式快照、屏障传播、状态一致性**和**端到端事务集成**四大核心机制实现Exactly-Once语义,确保即使在节点故障时,每条数据也能被精准处理一次。 --- ### 一、核心实现机制 #### 1. **检查点(Checkpoint)与屏障(Barrier)** - **分布式快照** Flink周期性地生成全局一致性检查点,将算子状态持久化到HDFS/S3等存储中[^4]。触发检查点时,JobManager向Source节点插入**屏障(Barrier)**,屏障随数据流向下游传播,分割不同批次的状态快照。 - **屏障对齐** 算子收到所有输入流的屏障后,才会提交当前状态快照。例如: ```plaintext Source1 --Barrier n--> Operator --> Barrier n到达后冻结状态 Source2 --Barrier n--> ``` 该机制防止快照期间数据交叉导致状态不一致[^1]。 #### 2. **状态一致性保障** - **异步快照** 状态后端(如RocksDB)在持久化时,采用**写时复制(Copy-On-Write)**技术,确保快照生成不影响实时处理性能。 - **状态恢复** 故障时,Flink回滚到最近完整的检查点,重新处理屏障后的数据,并通过唯一ID(如Kafka的offset)避免重复消费。 #### 3. **两阶段提交协议(2PC)** 在端到端场景中,Flink通过`TwoPhaseCommitSinkFunction`实现: 1. **预提交阶段**:Sink将数据写入外部系统(如Kafka事务),但不提交。 2. **提交阶段**:检查点完成后,提交所有预写入的事务[^5]。 例如Kafka生产者配置: ```java producer.setTransactionTimeout("1h"); // 长于检查点间隔 ``` --- ### 二、端到端Exactly-Once实现条件 | 组件 | 要求 | 示例 | |---------------|-----------------------------|-------------------------| | **Source** | 支持重放(如Kafka保留offset) | Kafka Consumer | | **计算层** | 检查点机制+状态一致性 | Flink Stateful Operators| | **Sink** | 支持事务提交/幂等写入 | MySQL事务/Kafka生产者事务 | --- ### 三、性能优化建议 1. **检查点间隔**:平衡容错开销与恢复速度(通常1-5分钟)。 2. **状态后端选择**:高频更新用`RocksDB`,小状态用`HashMapStateBackend`。 3. **对齐超时**:`checkpointConfig.setAlignedCheckpointTimeout(ms)`避免长时间屏障等待。 --- ### 四、故障恢复示例 假设第$n$个检查点完成后发生故障: 1. 重启Job并从检查点$n$恢复状态。 2. Source重置到对应offset(如Kafka offset$O_n$)。 3. Sink回滚未提交的事务,重新处理数据。 $$恢复时间 \propto 状态大小 / 网络带宽 + 未完成检查点处理时间$$ --- #### 相关问题 1. 如何配置Flink检查点以平衡性能与可靠性? 2. 使用Kafka作为Sink时如何避免事务超时? 3. 在无重放能力的Source场景中能否实现Exactly-Once? 4. Flink的检查点与Spark的Stage重算机制有何本质区别?
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值