Flink学习-DataStream-HDFSConnector(StreamingFileSink)

Flink学习-DataStream-HDFSConnector(StreamingFileSink)

Flink系列文章

  • 更多Flink系列文章请点击Flink系列文章

  • 更多大数据文章请点击大数据好文推荐

摘要

本文主要介绍Flink中的DataStream之HDFSConnector(StreamingFileSink),包含概念介绍、源码解读、实际Demo,已经更新到最新的Flink 1.10。

可参考:

  • Flink-1.10-StreamingFileSink
  • Flink-master-StreamingFileSink
    有详细使用配置等文档
  • Flink-master-StreamingFileSink-API

更多Flink API内容可参考Flink学习3-API介绍-SQL

1 Hadoop FileSystem Connector

可参考 Hadoop FileSystem Connector

注意:官网说了,这个版本的BucketingSink已经在Flink1.9中废弃了,会在后面的release版本中移除。建议用StreamingFileSink。

BucketingSink其实就是按时间和parallel-task为粒度来写到定义的base-path目录下的各个文件中,可按时间和大小进行滚动,可自定义时间粒度。

2 File System

可参考File Systems

2.1 概述

Flink使用文件系统来读写数据,比如app运行结果、容错信息、恢复等。这里说的文件系统有很多种,用路径前的URI Schema表示,包括本地(如file:///home/user/text.txt)、Hadoop及兼容的(如hdfs://namenode:50010/data/user/text.txt)、亚马逊S3、阿里云 OSS等。

2.2 文件系统实例创建时机

文件系统实例在每个进程启动时实例化一次,然后进行缓存/池化,以避免每次流创建时的文件系统配置开销,并强制执行某些约束,例如连接/流限制。

2.3 文件系统的选择

2.3.1 本地文件系统

  • Flink内嵌,可直接使用
  • 支持NFS/SAN
  • URI schema以file//开头

2.3.2 HDFS/Hadoop兼容的其他文件系统

只要是Flink找不到支持的文件系统目录,则会统一回退到Hadoop schema模式。只要Flink运行时和Hadoop的jar包在classpath就能使用该类文件系统。

将Hadoop配置放在和Hadoop类库相同的路径可使得Hadoop文件系统采用该配置。也可以通过环境变量HADOOP_CONF_DIR来设定Hadoop配置,或是参考Flink configuration指定fs.hdfs.hadoopconf表示Hadoop配置目录来让Flink查找core-site.xmlhdfs-site.xml(官方不推荐这种方式,已经deprecated)。

2.3.3 目前支持的可插拔的文件系统

详见Pluggable File Systems

包括亚马逊S3、阿里云 OSS等

使用可插拔的其他文件系统时,请将对应Jar包从$FLINK_HOME/opt拷贝到$FLINK_HOME/plugins

例子:

mkdir $FLINK_HOME/plugins/s3-fs-hadoop
cp $FLINK_HOME/opt/flink-s3-fs-hadoop-1.9.0.jar $FLINK_HOME/plugins/s3-fs-hadoop/

2.3.4 新增可插拔的其他文件系统

详见Adding a new pluggable File System implementation

2.4 文件系统配置

请参考FileSystem Common Configurations

2.4.1 默认Schema

$FLINK_HOME/conf/flink-conf.yaml文件的fs.default-scheme项设置,不设置时默认将没有URI schema的地址都解析为本地文件系统file:///。比如要改为某个HDFS schema,可设为:hdfs://mynamenode:12345。则当指定路径为/user/chengc时,其实就是hdfs://mynamenode:12345/user/chengc

2.4.2 限制连接数

Flink支持限制连接到文件系统连接数,以免打满甚至挤垮文件系统连接handler(比如同一时段内大量Flink作业做checkpoint写文件到HDFS,需要发送大量连接)。

配置如下:

# 最多允许并发打开的stream数,超过就阻塞直到有其他stream关闭
fs.<scheme>.limit.total: (数字, 0/-1 代表无限制)
fs.<scheme>.limit.input: (数字, 0/-1 代表无限制)
fs.<scheme>.limit.output: (number, 数字, 0/-1 代表无限制)
# 如果开启stream的时间超过此值,就fail
fs.<scheme>.limit.timeout: (milliseconds, 0 代表无穷大)
# 如果某个streams在此时间内未有任何读写,则强制关闭,以免失效stream占满资源
fs.<scheme>.limit.stream-timeout: (milliseconds, 0 代表无穷大)

以上限制在每个TaskManager /文件系统的基础上执行。 由于文件系统的创建是按schema和权限进行的,因此不同的权限具有独立的连接池。 例如hdfs://myhdfs:50010hdfs://anotherhdfs:4399将具有单独的池。

3 Streaming File Sink

3.1 概述

3.1.1 简介

可参考:

  • Flink-1.9-StreamingFileSink
  • Flink-master-StreamingFileSink
    有详细使用配置等文档
  • Flink-master-StreamingFileSink-API
  • Bucket
    StreamingFileSink可向由Flink FileSystem抽象支持的文件系统写入分区文件(因为是流式写入,数据被视为无界)。该分区行为可配,默认按时间,具体来说每小时写入一个Bucket,该Bucket包括若干文件,内容是这一小时间隔内流中收到的所有record。
  • PartFile
    每个Bukcket内部分为多个PartFile来存储输出数据,该Bucket生命周期内接收到数据的sink的每个子任务至少有一个PartFile。而额外文件滚动由可配的滚动策略决定,默认策略是根据文件大小和打开超时(文件可以被打开的最大持续时间)以及文件最大不活动超时等决定是否滚动。Bucket和SubTask、PartFile关系如下图:
    在这里插入图片描述
  • Checkpoint
    当使用StreamingFileSink时,必须开启Checkpoint,因为只能在Checkpoint成功后正确关闭PartFile;如果没有开启Checkpoint,则会导致PartFile永远处于in-progresspendiing状态,不能被下游系统安全读取。

3.1.2 源码

3.1.2.1 重要概念

  • Bucket
    管理一个Bucket目录的对象实例

  • Buckets
    管理多个Bucket实例

  • Bucket、Checkpoint和exactly once
    StreamingFileSink将受到的元素按一定规则发送到对应Bucket,他与Checkpoint机制集成后提供exactly once语义保证。关于此原理,请点击这里

    Bucket的新建其实是在每条消息来时调用Buckets#onElement方法时。具体来说,会根据该条消息的Value调用bucketAssigner#getBucketId方法得到BucketId,然后判断这个Bucket路径是否已经存在,不存在就用bucketFactory#getNewBucket创建一个Bucket并放入Buckets.activeBuckets缓存起来。

  • BucketAssigner
    StreamingFileSink会使用BucketAssigner来决定每个输入的element应该输出到哪个Bucket。默认使用DateTimeBucketAssigner,可以通过StreamingFileSink#withBucketAssigner进行设置。

  • bucketCheckInterval
    默认StreamingFileSink会1分钟检查关闭in-progress part file,可以通过StreamingFileSink#withBucketCheckInterval方法进行设置。

    个人认为这个名字取得不贴切,我之前一直以为是用来设置检查是否需要新建Bucket的时间间隔。

  • PartFile
    PartFile默认使用DefaultRollingPolicy策略来滚动。使用BulkEncoding时只能用OnCheckpointRollingPolicy

  • BulkWriter.Factory
    批量模式下的Wrtier工厂,如ParquetWriterFactory,可创建输出Writer

  • BucketFactory
    Bucket工厂,创建Bucket实例

3.1.2.2 StreamingFileSink重要属性和构造方法

// IN为输入元素的类型
public class StreamingFileSink<IN>
		extends RichSinkFunction<IN>
		implements CheckpointedFunction, CheckpointListener, ProcessingTimeCallback {

	private static final long serialVersionUID = 1L;

	// -------------------------- state descriptors ---------------------------

	private static final ListStateDescriptor<byte[]> BUCKET_STATE_DESC =
			new ListStateDescriptor<>("bucket-states", BytePrimitiveArraySerializer.INSTANCE);

	private static final ListStateDescriptor<Long> MAX_PART_COUNTER_STATE_DESC =
			new ListStateDescriptor<>("max-part-counter", LongSerializer.INSTANCE);

	// ------------------------ configuration fields --------------------------

	// 检查是否需要关闭in-progress part file的时间间隔
	private final long bucketCheckInterval;
	
	// 用来构建Bucekt的基类,目前分为BulkFormatBuilder和RowFormatBuilder
	// IN为输入元素的类型
	private final StreamingFileSink.BucketsBuilder<IN, ?> bucketsBuilder;

	// --------------------------- runtime fields -----------------------------

	// 管理所有活跃的Buckets,负责所有Bucket相关操作
	// IN为输入元素的类型
	private transient Buckets<IN, ?> buckets;

	// 定义当前processingTime,并处理相关操作(比如已注册的Timer,会在未来运行)
	private transient ProcessingTimeService processingTimeService;

	// --------------------------- State Related Fields -----------------------------

	// 保存所有ActiveBucket状态
	private transient ListState<byte[]> bucketStates;

	// 保存Part文件计数器状态。具体来说,就是各个Bucket中文件数量最大值
	private transient ListState<Long> maxPartCountersState;

	/**
	 * 构建StreamingFileSink的构造函数
	 */
	protected StreamingFileSink(
			final StreamingFileSink.BucketsBuilder<IN, ?> bucketsBuilder,
			final long bucketCheckInterval) {
		
		// bucketCheckInterval必须大于0
		Preconditions.checkArgument(bucketCheckInterval > 0L);
		
		this.bucketsBuilder = Preconditions.checkNotNull(bucketsBuilder);
		this.bucketCheckInterval = bucketCheckInterval;
	}

3.1.2.3 StreamingFileSink.BucketsBuilder

	// ------------------------------------------------------------------------

	// --------------------------- Sink Builders  -----------------------------

	/**
	 * Creates the builder for a {@code StreamingFileSink} with row-encoding format.
	 * @param basePath the base path where all the buckets are going to be created as sub-directories.
	 * @param encoder the {@link Encoder} to be used when writing elements in the buckets.
	 * @param  the type of incoming elements
	 * @return The builder where the remaining of the configuration parameters for the sink can be configured.
	 * In order to instantiate the sink, call {@link RowFormatBuilder#build()} after specifying the desired parameters.
	 */
	public static <IN> StreamingFileSink.RowFormatBuilder<IN, String> forRowFormat(
			final Path basePath, final Encoder<IN> encoder) {
		return new StreamingFileSink.RowFormatBuilder<>(basePath, encoder, new DateTimeBucketAssigner<>());
	}

	/**
	 * 以批量编码模式创建一个StreamingFileSink.BulkFormatBuilder
	 * @param basePath bucket所在的basePath
	 * @param writerFactory 写入bucket中文件的BulkWriter.Factory
	 * @param  输入元素的类型
	 * @return StreamingFileSink.BulkFormatBuilder,可继续使用配置其他参数如.withBucketAssigner.
	 * 最后调用build方法即可
	 */
	public static <IN> StreamingFileSink.BulkFormatBuilder<IN, String> forBulkFormat(
			final Path basePath, final BulkWriter.Factory<IN> writerFactory) {
		return new StreamingFileSink.BulkFormatBuilder<>(basePath, writerFactory, new DateTimeBucketAssigner<>());
	}

	/**
	 * 用来构建Bucekt的基类,目前分为BulkFormatBuilder和RowFormatBuilder
	 */
	protected abstract static class BucketsBuilder<IN, BucketID> implements Serializable {

		private static final long serialVersionUID = 1L;
			
		// 根据子任务inedx创建Bukcet方法
		abstract Buckets<IN, BucketID> createBuckets(final int subtaskIndex) throws IOException;
	}

	/**
	 * A builder for configuring the sink for row-wise encoding formats.
	 */
	@PublicEvolving
	public static class RowFormatBuilder<IN, BucketID> extends StreamingFileSink.BucketsBuilder<IN, BucketID> {

		private static final long serialVersionUID = 1L;

		private final long bucketCheckInterval;

		private final Path basePath;

		private final Encoder<IN> encoder;

		private final BucketAssigner<IN, BucketID> bucketAssigner;

		private final RollingPolicy<IN, BucketID> rollingPolicy;

		private final BucketFactory<IN, BucketID> bucketFactory;

		RowFormatBuilder(Path basePath, Encoder<IN> encoder, BucketAssigner<IN, BucketID> bucketAssigner) {
			this(basePath, encoder, bucketAssigner, DefaultRollingPolicy.create().build(), 60L * 1000L, new DefaultBucketFactoryImpl<>());
		}

		private RowFormatBuilder(
				Path basePath,
				Encoder<IN> encoder,
				BucketAssigner<IN, BucketID> assigner,
				RollingPolicy<IN, BucketID> policy,
				long bucketCheckInterval,
				BucketFactory<IN, BucketID> bucketFactory) {
			this.basePath = Preconditions.checkNotNull(basePath);
			this.encoder = Preconditions.checkNotNull(encoder);
			this.bucketAssigner = Preconditions.checkNotNull(assigner);
			this.rollingPolicy = Preconditions.checkNotNull(policy);
			this.bucketCheckInterval = bucketCheckInterval;
			this.bucketFactory = Preconditions.checkNotNull(bucketFactory);
		}

		public StreamingFileSink.RowFormatBuilder<IN, BucketID> withBucketCheckInterval(final long interval) {
			return new RowFormatBuilder<>(basePath, encoder, bucketAssigner, rollingPolicy, interval, bucketFactory);
		}

		public StreamingFileSink.RowFormatBuilder<IN, BucketID> withBucketAssigner(final BucketAssigner<IN, BucketID> assigner) {
			return new RowFormatBuilder<>(basePath, encoder, Preconditions.checkNotNull(assigner), rollingPolicy, bucketCheckInterval, bucketFactory);
		}

		public StreamingFileSink.RowFormatBuilder<IN, BucketID> withRollingPolicy(final RollingPolicy<IN, BucketID> policy) {
			return new RowFormatBuilder<>(basePath, encoder, bucketAssigner, Preconditions.checkNotNull(policy), bucketCheckInterval, bucketFactory);
		}

		public <ID> StreamingFileSink.RowFormatBuilder<IN, ID> withBucketAssignerAndPolicy(final BucketAssigner<IN, ID> assigner, final RollingPolicy<IN, ID> policy) {
			return new RowFormatBuilder<>(basePath, encoder, Preconditions.checkNotNull(assigner), Preconditions.checkNotNull(policy), bucketCheckInterval, new DefaultBucketFactoryImpl<>());
		}

		/** Creates the actual sink. */
		public StreamingFileSink<IN> build() {
			return new StreamingFileSink<>(this, bucketCheckInterval);
		}

		@Override
		Buckets<IN, BucketID> createBuckets(int subtaskIndex) throws IOException {
			return new Buckets<>(
					basePath,
					bucketAssigner,
					bucketFactory,
					new RowWisePartWriter.Factory<>(encoder),
					rollingPolicy,
					subtaskIndex);
		}

		@VisibleForTesting
		StreamingFileSink.RowFormatBuilder<IN, BucketID> withBucketFactory(final BucketFactory<IN, BucketID> factory) {
			return new RowFormatBuilder<>(basePath, encoder, bucketAssigner, rollingPolicy, bucketCheckInterval, Preconditions.checkNotNull(factory));
		}
	}

	/**
	 * 用来配置批量编码格式的Sink,如Parquet
	 */
	@PublicEvolving
	public static class BulkFormatBuilder<IN, BucketID> extends StreamingFileSink.BucketsBuilder<IN, BucketID> {

		private static final long serialVersionUID = 1L;

		private final long bucketCheckInterval;

		private final Path basePath;
		
		// 批量Wrtier工厂,如ParquetWriterFactory,可创建输出Writer
		private final BulkWriter.Factory<IN> writerFactory;
		
		// Bucket分配者,StreamingFileSink会使用BucketAssigner来决定
		// 每个输入的element应该输出到哪个Bucket。默认使用DateTimeBucketAssigner
		private final BucketAssigner<IN, BucketID> bucketAssigner;

		// 批量Bucket工厂,创建Bucket实例
		private final BucketFactory<IN, BucketID> bucketFactory;
		
		// 两个BulkFormatBuilder构造函数
		BulkFormatBuilder(Path basePath, BulkWriter.Factory<IN> writerFactory, BucketAssigner<IN, BucketID> assigner) {
			this(basePath, writerFactory, assigner, 60L * 1000L, new DefaultBucketFactoryImpl<>());
		}

		private BulkFormatBuilder(
				Path basePath,
				BulkWriter.Factory<IN> writerFactory,
				BucketAssigner<IN, BucketID> assigner,
				long bucketCheckInterval,
				BucketFactory<IN, BucketID> bucketFactory) {
			this.basePath = Preconditions.checkNotNull(basePath);
			this.writerFactory = writerFactory;
			this.bucketAssigner = Preconditions.checkNotNull(assigner);
			this.bucketCheckInterval = bucketCheckInterval;
			this.bucketFactory = Preconditions.checkNotNull(bucketFactory);
		}
		
		// 指定withBucketCheckInterval
		public StreamingFileSink.BulkFormatBuilder<IN, BucketID> withBucketCheckInterval(long interval) {
			return new BulkFormatBuilder<>(basePath, writerFactory, bucketAssigner, interval, bucketFactory);
		}

		// 指定withBucketAssigner
		public <ID> StreamingFileSink.BulkFormatBuilder<IN, ID> withBucketAssigner(BucketAssigner<IN, ID> assigner) {
			return new BulkFormatBuilder<>(basePath, writerFactory, Preconditions.checkNotNull(assigner), bucketCheckInterval, new DefaultBucketFactoryImpl<>());
		}

		// 指定BucketFactory,默认实现为DefaultBucketFactoryImpl
		StreamingFileSink.BulkFormatBuilder<IN, BucketID> withBucketFactory(final BucketFactory<IN, BucketID> factory) {
			return new BulkFormatBuilder<>(basePath, writerFactory, bucketAssigner, bucketCheckInterval, Preconditions.checkNotNull(factory));
		}

		// 使用BulkFormatBuilder构建StreamingFileSink
		public StreamingFileSink<IN> build() {
			return new StreamingFileSink<>(this, bucketCheckInterval);
		}

		// 根据子任务inedx创建Bukcet方法
	    // 注意批量模式只能用OnCheckpointRollingPolicy
	    // 本方法由initializeState方法调用
		@Override
		Buckets<IN, BucketID> createBuckets(int subtaskIndex) throws IOException {
			return new Buckets<>(
					basePath,
					bucketAssigner,
					bucketFactory,
					new BulkPartWriter.Factory<>(writerFactory),
					OnCheckpointRollingPolicy.build(),
					subtaskIndex);
		}
	}

3.1.2.4 StreamingFileSink重要方法

	/**
	 * 实现自CheckpointedFunction的方法。
	 * 该接口为stateful转换函数的核心接口,维护跨流记录的有状态函数。
	 * 该接口特点是在管理`keyed state`和`operator state`时提供最大的弹性。
	 * 
	 * 会在分布式程序执行期间创建并行函数实例时调用本方法,
	 * 经典使用场景:
	 * 函数使用此方法来设置其State存储数据结构。
	 * 
	 * context是用来初始化本算子的上下文
	 * 
	 * 1.这里StreamingFileSink的各子任务实例分别调用本方法,创建了Buckets管理实例。
	 * 2.如果是初次启动,会分别注册独有的任务实例级别的两个ListState:
	 * 	2.1 记录 Bucket状态的的bucketStates
	 * 	2.2 记录 PartFile数量状态的maxPartCountersState
	 *  
	 * 3.如果本次从checkpoint内恢复,则会从两个state中恢复:
	 * 	3.1 恢复maxPartCounter,设置为所有Bucket的文件数量的最大值
	 * 	3.2 恢复bucketStates,使用其中记录的所有Bucket信息来分别重建ActiveBucket,
	 * 		对每个Bucket具体做如下恢复操作:
	 * 		3.2.1 新建Bucket对象
	 * 		3.2.2 将snapshot阶段持久化的inprogress文件恢复,并继续将该文件作为写入目标inprogress文件
	 * 		3.2.3 将snapshot阶段已经变为pending状态的文件提交,变为finished状态
	 * 		3.2.4 如果activeBuckets中存在该bucketID的bucket,就与之merge;否则放入activeBuckets
	 * 		3.2.5 merge过程将当前Bucket inprogress文件持久化转为pending状态,并放入旧有Bucket的pendingPartsForCurrentCheckpoint保存。注意,当前Bucket没有再加入activeBuckets不再被管理了。
	 * 	
	 */
	@Override
	public void initializeState(FunctionInitializationContext context) throws Exception {
		final int subtaskIndex = getRuntimeContext().getIndexOfThisSubtask();
		this.buckets = bucketsBuilder.createBuckets(subtaskIndex);

		final OperatorStateStore stateStore = context.getOperatorStateStore();
		bucketStates = stateStore.getListState(BUCKET_STATE_DESC);
		maxPartCountersState = stateStore.getUnionListState(MAX_PART_COUNTER_STATE_DESC);

		if (context.isRestored()) {
			buckets.initializeState(bucketStates, maxPartCountersState);
		}
	}
	
	/**
	 * 实现自CheckpointListener的notifyCheckpointComplete方法
	 * 本方法会在收到分布式Checkpoint成功完成通知后调用
	 * 会在AbstractUdfStreamOperator#notifyCheckpointComplete中调用
	 *  
	 * 注意!该方法执行过程中发生异常也不会导致Checkpoint失败,因为Checkpoint已经完成
	 * 
	 * 这里StreamingFileSink使用本方法来通知Bucket实例完成活跃的Bucket的文件滚动
	 * 即Pending状态的PartFile->Finished
	 * 具体来说,遍历ActiveBuckets,对每一个Bucket都做以下处理:
	 * 	1.遍历其pendingPartsPerCheckpoint,将所有pending状态文件恢复并提交,转为finish状态
	 * 	2.从pendingPartsPerCheckpoint中移除该原pending文件
	 * 	3.如果该bucket不再有文件写入或pending文件,则从ActiveBuckets中移除该Bucket
	 * 至此,checkpoint完成,主要是将pending状态文件转为了finish状态,对读可见
	 */
	@Override
	public void notifyCheckpointComplete(long checkpointId) throws Exception {
		buckets.commitUpToCheckpoint(checkpointId);
	}

	/**
	 * 实现自CheckpointedFunction的方法。
	 * 当开始触发分布式Checkpoint快照时,将调用此方法。 
	 * 这作为函数的钩子,以确保在初始化函数时通过FunctionInitializationContext
	 * 在之前或者现在由FunctionSnapshotContext本身提供的方式来暴露所有State。
	 * 
	 * context:是用来为算子制作快照的上下文
	 * 
	 * 这里StreamingFileSink做的事情如下:
	 * 1. 清理了bucketStates和maxPartCountersState
	 * 2. 更新bucketStates,具体来说会将对所有activeBuckets中的每个Bucket做以下处理:
	 * 	2.1 对于CheckpointRollingPolicy,如果存在inprogressFile,就会关闭该文件,触发ParquetWriter.close
	 * 	2.2 对于shouldRollOnCheckpoint,如果存在inprogressFile且文件大小超过阈值就关闭文件
	 * 	2.3 关闭inprogressFile时,会将现临时文件名和未来要重命名的文件名记入pendingPartsForCurrentCheckpoint list。此时,此时该文件处于pending状态,等待checkpoint时被提交以使得下游消费者可读(此时不可读)
	 * 	2.4 关闭文件后,再以该次checkpointId为key,pendingPartsForCurrentCheckpoint为value记入pendingPartsPerCheckpoint,然后清空重置pendingPartsForCurrentCheckpoint list,以便下次checkpoint时使用
	 * 	2.5 如果此时恰好又有记录写入了该Bucket的新PartFile或该文件没有滚动,则会做该文件持久化,但此时对读不可见。
	 * 	2.6 用以上信息组装BucketState,序列化后放入bucketStates保存
	 * 3. 更新maxPartCountersState为当前maxPartCounter
	 */
	@Override
	public void snapshotState(FunctionSnapshotContext context) throws Exception {
		Preconditions.checkState(bucketStates != null && maxPartCountersState != null, "sink has not been initialized");

		buckets.snapshotState(
				context.getCheckpointId(),
				bucketStates,
				maxPartCountersState);
	}
		
	/**
	 * 继承自AbstractRichFunction的方法。
	 * 表示该函数的初始化方法。
	 * 在真正执行类似map join之类的算子之前调用本方法,所以适合做一次性配置工作。
	 * 
	 * parameters:传递给该函数的配置对象可用于配置和初始化。 
	 * 该配置包含程序组合中在功能上配置的所有参数。
	 * 
	 * 这里StreamingFileSink只是利用此方法做了ProcessingTime Timer注册工作
	 * 具体来说,注册了在下一个bucketCheckInterval间隔后的时间触发onProcessingTime事件
	 */
	@Override
	public void open(Configuration parameters) throws Exception {
		super.open(parameters);
		this.processingTimeService = ((StreamingRuntimeContext) getRuntimeContext()).getProcessingTimeService();
		long currentProcessingTime = processingTimeService.getCurrentProcessingTime();
		processingTimeService.registerTimer(currentProcessingTime + bucketCheckInterval, this);
	}

	/**
	 * 实现自ProcessingTimeCallback的方法。
	 * 会在配置processingTimeService.registerTimer的时间到了时触发
	 * 如果由于某种原因延迟了触发(如触发计时器被阻止,JVM由于GC而STW),
	 * 则为此函数提供的timestamp将仍然是计划触发的原始timestamp。
	 * 
	 * timestamp:配置触发的时间戳
	 * 
	 * 这里StreamingFileSink先将活跃Bucket做适当处理
	 * 比如DefaultRollingPolicy根据当前ProcessingTime和配置的阈值比较确定是否滚动PartFile
	 * 而OnCheckpointRollingPolicy永远不做操作因为只会在checkpoint时roll file。
	 * 
	 * 最后,重新根据时间注册bucket timer
	 */
	@Override
	public void onProcessingTime(long timestamp) throws Exception {
		final long currentTime = processingTimeService.getCurrentProcessingTime();
		buckets.onProcessingTime(currentTime);
		processingTimeService.registerTimer(currentTime + bucketCheckInterval, this);
	}


	/**
	 * 实现自SinkFunction的方法。
	 * 用来写入数据到Sink。
	 * 每条记录输入时都会调用本方法。
	 * 
	 * value:输入的记录
	 * context:输入记录的额外上下文信息
	 * 
	 * 这里StreamingFileSink将做以下事情:
	 * 1.更新bucketerContext状态。
	 * 2.bucketAssigner调用getBucketId方法,根据value和bucketerContext获取bucketId
	 * 3.获取或创建这个Bucket实例
	 * 4.将该条value写入这个bucket
	 * 5.写入时按需决定是否滚动part-file。
	 * 	5.1 首次进入会根据part-file路径来创建RecoverableFsDataOutputStream,然后创建 
	 * 		PartFileWriter(BulkPartWriter)-> BulkWriter(ParquetBulkWriter)-> ParquetWriter
	 * 	5.2 其他时候,OnCheckpointRollingPolicy不会滚动文件;
	 * 		DefaultRollingPolicy会判断inprogress文件大小是否超过阈值,如果超过就关闭原文件开启新part-file
	 * 	5.3 滚动文件后,将本Bucket的partCounter加1
	 * 6.使用PartFileWriter(BulkPartWriter)-> BulkWriter(ParquetBulkWriter)-> ParquetWriter写入数据
	 * 7.最后,比较本次写入的Bucket part文件数和所有Buckets中最大值,如果更大则更新maxPartCounter
	 */
	@Override
	public void invoke(IN value, SinkFunction.Context context) throws Exception {
		buckets.onElement(value, context);
	}

	/**
	 * 继承自AbstractRichFunction的方法。
	 * 表示该函数的关闭方法。
	 * 在最后一次执行类似map join之类的算子之后调用本方法,所以适合做一次性配置工作。
	 * 本方法一般用来作资源清理工作。
	 * 
	 * 这里StreamingFileSink只是利用此方法做activeBuckets关闭工作
	 * 具体来说是优雅关闭所有in-progress的partFile
	 */
	@Override
	public void close() throws Exception {
		if (buckets != null) {
			buckets.close();
		}
	}
}

3.1.2.5 数据写入

初始时,会初始化ParquetWriter,以及InternalParquetRecordWriter,此时会调用initStore:

private void initStore() {
  // 构建ColumnChunk的Writer
  pageStore = new ColumnChunkPageWriteStore(compressor, schema, props.getAllocator());
  // 构建RowGroup的Writer,初始化后allocatedSize=5280 byte,bufferedSize=0
  columnStore = props.newColumnWriteStore(schema, pageStore);
  MessageColumnIO columnIO = new ColumnIOFactory(validating).getColumnIO(schema);
  this.recordConsumer = columnIO.getRecordWriter(columnStore);
  writeSupport.prepareForWrite(recordConsumer);
}

上面说过,StreamingFileSink.invoke方法负责写入数据,调用栈为:

  • StreamingFileSink.invoke(IN value, SinkFunction.Context context)
  • Buckets.onElement(final IN value, final SinkFunction.Context context)
  • Bucket.write(IN element, long currentTime)
  • (PartFileWriter)BulkPartWriter.write(IN element, long currentTime)
  • (BulkWriter)ParquetBulkWriter.addElement(T datum)
  • ParquetWriter.write(T object)
  • InternalParquetRecordWriter.write(T value)
    public void write(T value) throws IOException, InterruptedException {
      // 实现对象与Parquet模式之间的转换,这里使用的是`AvroWriteSupport`
      // 内部使用(RecordConsumer)MessageColumnIO来将数据转换,将数据写入内存中
      // 调用后会增加bufferedSize和allocatedSize
      writeSupport.write(value);
      // record数量自增
      ++ recordCount;
      checkBlockSizeReached();
    }
    

数据在write阶段格式转换、写入内存,随后会在一定时机(比如checkpoint,或在写入时发现数据累积超过一个动态调整的阈值(根据当前批record数,区间100-10000)且当前RowGroup已使用的Buffer中的已编码的二进制数据大小超过超过了parquet.block.size(默认128MB) )将内存中的RowGroup刷入磁盘中:

private void flushRowGroupToStore()
      throws IOException {
  recordConsumer.flush();
  LOG.info("Flushing mem columnStore to file. allocated memory: {}", columnStore.getAllocatedSize());
  if (columnStore.getAllocatedSize() > (3 * rowGroupSizeThreshold)) {
    LOG.warn("Too much memory used: {}", columnStore.memUsageString());
  }

  if (recordCount > 0) {
    // 开启Parquet文件一个Block(RowGroup)
    parquetFileWriter.startBlock(recordCount);
    // 将所有RowGroup数据刷入磁盘
    columnStore.flush();
    pageStore.flushToFileWriter(parquetFileWriter);
    recordCount = 0;
    // 该Block结束
    parquetFileWriter.endBlock();
    this.nextRowGroupSize = Math.min(
        parquetFileWriter.getNextRowGroupSize(),
        rowGroupSizeThreshold);
  }
	
  columnStore = null;
  pageStore = null;
}

注意这里columnStore的内存占用并未被立刻回收,而是会等待GC。

有两类日志:

  • 只有Flushing mem columnStore to file. allocated memory: 117884452
    跟随Checkpoint触发,每个Bucket的每个RollPartFile触发一次

  • mem size 134577395 > 134217728: flushing 2220100 records to disk.
    Flushing mem columnStore to file. allocated memory: 117884452

    这种就是检查到数据累积超过一个动态调整的阈值导致的flush。

3.2 BucketAssigner-桶分配策略

3.2.1 BucketAssigner

桶分配逻辑定义了如何将数据结构化后写入BasePath中的子目录。

具体来说,StreamingFileSink使用BucketAssigner来确定每条输入的数据应该被放入哪个Bucket,最简单的方式就是用本地系统时间来确定Bucket。而且StreamingFileSink可以同时管理和写入若干活跃的Bucket。

/**
 * @param  输入元素的类型
 * @param  getBucketId方法返回的BucketId类型,必须重写hashCode和equals方法。
 /
public interface BucketAssigner extends Serializable {

	/**
	 * 根据element判断,返回应该放入的BucketId。
	 * @param element 目标element
	 * @param context StreamingFileSink使用的SinkFunction.Context
	 *
	 * @return 返回应该放入的BucketId.
	 * 最终确定的Bucket路径是由初始化StreamingFileSink时传入的BasePath和BucketId连接而成
	 */
	BucketID getBucketId(IN element, BucketAssigner.Context context);

	/**
	 * @return 一个SimpleVersionedSerializer,可以序列化/反序列化BucketId类型的元素。
	 */
	SimpleVersionedSerializer<BucketID> getSerializer();

	/**
	 * BucketAssigner用来获取输入元素的额外信息的Context
	 * 
	 * 请注意只能在BucketAssigner#getBucketId方法内使用,不要保存然后在后面使用!
	 */
	@PublicEvolving
	interface Context {
		/**
		 * 返回当前ProcessingTime.
		 */
		long currentProcessingTime();

		/**
		 * 返回当前EventTime WaterMark
		 */
		long currentWatermark();

		/**
		 * 返回当前输入元素的时间戳,或该元素没有分配时间戳时返回null
		 */
		@Nullable
		Long timestamp();
	}
}

3.2.2 指定BucketAssigner

我们可以在StreamingFileSink上调用 .withBucketAssigner(assigner)来实现自定义的BucketAssigner, Flink 有两个内置的 :

  • DateTimeBucketAssigner
    默认使用,基于时间的分配器
  • BasePathBucketAssigner
    将所有PartFile存储在BasePath中(此时只有单个全局Bucket)

3.2.3 BasePathBucketAssigner

将所有PartFile存储在BasePath中(此时只有单个全局Bucket)。

先看看BasePathBucketAssigner的源码,方便继续学习DateTimeBucketAssigner

@PublicEvolving
public class BasePathBucketAssigner<T> implements BucketAssigner<T, String> {

	private static final long serialVersionUID = -6033643155550226022L;

	/**
	 * BucketId永远为"",即Bucket全路径为用户指定的BasePath
	 */
	@Override
	public String getBucketId(T element, BucketAssigner.Context context) {
		return "";
	}

	/**
	 * 用SimpleVersionedStringSerializer来序列化BucketId
	 */
	@Override
	public SimpleVersionedSerializer<String> getSerializer() {
		// in the future this could be optimized as it is the empty string.
		return SimpleVersionedStringSerializer.INSTANCE;
	}

	@Override
	public String toString() {
		return "BasePathBucketAssigner";
	}
}

3.2.4 DateTimeBucketAssigner

Row格式和Bulk格式编码都使用DateTimeBucketAssigner作为默认BucketAssigner。 默认情况下,DateTimeBucketAssigner 基于系统默认时区每小时以格式yyyy-MM-dd--HH来创建一个Bucket,Bucket路径为/{basePath}/{dateTimePath}/

  • basePath是指StreamingFileSink.forRowFormat(new Path(outputPath)时的路径
  • dateTimePath中的日期格式和时区都可在初始化DateTimeBucketAssigner时配置
public class DateTimeBucketAssigner<IN> implements BucketAssigner<IN, String> {
	
	private static final long serialVersionUID = 1L;

	// 默认的时间格式字符串
	private static final String DEFAULT_FORMAT_STRING = "yyyy-MM-dd--HH";

	// 时间格式字符串
	private final String formatString;

	// 时区
	private final ZoneId zoneId;
	
	// DateTimeFormatter被用来通过当前系统时间和DateTimeFormat来生成时间字符串
	private transient DateTimeFormatter dateTimeFormatter;

	/**
	 * 使用默认的`yyyy-MM-dd--HH`和系统时区构建DateTimeBucketAssigner
	 */
	public DateTimeBucketAssigner() {
		this(DEFAULT_FORMAT_STRING);
	}

	/**
	 * 通过能被SimpleDateFormat解析的时间字符串和系统时区
	 * 来构建DateTimeBucketAssigner
	 */
	public DateTimeBucketAssigner(String formatString) {
		this(formatString, ZoneId.systemDefault());
	}

	/**
	 * 通过默认的`yyyy-MM-dd--HH`和指定的时区
	 * 来构建DateTimeBucketAssigner
	 */
	public DateTimeBucketAssigner(ZoneId zoneId) {
		this(DEFAULT_FORMAT_STRING, zoneId);
	}

	/**
	 * 通过能被SimpleDateFormat解析的时间字符串和指定的时区
	 * 来构建DateTimeBucketAssigner
	 */
	public DateTimeBucketAssigner(String formatString, ZoneId zoneId) {
		this.formatString = Preconditions.checkNotNull(formatString);
		this.zoneId = Preconditions.checkNotNull(zoneId);
	}

	/**
	 * 使用指定的时间格式和时区来格式化当前ProcessingTime,以获取BucketId
	 */
	@Override
	public String getBucketId(IN element, BucketAssigner.Context context) {
		if (dateTimeFormatter == null) {
			dateTimeFormatter = DateTimeFormatter.ofPattern(formatString).withZone(zoneId);
		}
		return dateTimeFormatter.format(Instant.ofEpochMilli(context.currentProcessingTime()));
	}

	@Override
	public SimpleVersionedSerializer<String> getSerializer() {
		return SimpleVersionedStringSerializer.INSTANCE;
	}

	@Override
	public String toString() {
		return "DateTimeBucketAssigner{" +
			"formatString='" + formatString + '\'' +
			", zoneId=" + zoneId +
			'}';
	}
}

3.3 PartFile

3.3.1 概述

前面提到过,每个Bukcket内部分为多个部分文件,该Bucket内接收到数据的sink的每个子任务至少有一个PartFile。而额外文件滚动由可配的滚动策略决定。

  • 关于顺序性
    对于任何给定的Flink子任务,PartFile索引都严格增加(按创建顺序)。 但是,这些索引并不总是顺序的。 当作业重新启动时,所有子任务的下一个PartFile索引将是max PartFile索引+ 1,其中max是指在所有子任务中对所有计算的索引最大值。
    return new Path(bucketPath, outputFileConfig.getPartPrefix() + '-' + subtaskIndex + '-' + partCounter + outputFileConfig.getPartSuffix());
    

3.3.2 生命周期

PartFile生命周期有三个状态:

  • In-progress
    PartFile正在被写入
  • Pending
    由于RollingPolicy而被关闭的PartFile,等待被提交以使得下游消费者可读(此时不可读)
  • Finished
    到了下一个Chekpoint成功后,Pending PartFile转变为Finished状态,对下游下消费者安全可读,此后该文件不再会被修改。

在每个活跃的Bucket期间,每个Writer的子任务在任何时候都只会有一个单独的In-progress PartFile,但可有多个Peding和Finished状态文件。

一个Sink的两个Subtask的PartFile分布情况实例如下:

  1. 初始状态,两个inprogress文件正在被两个subtask分别写入
    └── 2019-08-25--12
        ├── part-0-0.inprogress.bd053eb0-5ecf-4c85-8433-9eff486ac334
        └── part-1-0.inprogress.ea65a428-a1d0-4a0b-bbc5-7a436a75e575
    
  2. part-1-0因文件大小超过阈值等原因发生滚动时,变为Pending状态等待完成但此时不会被重命名。注意此时Sink会创建一个新的PartFile即part-1-1
    └── 2019-08-25--12
        ├── part-0-0.inprogress.bd053eb0-5ecf-4c85-8433-9eff486ac334
        ├── part-1-0.inprogress.ea65a428-a1d0-4a0b-bbc5-7a436a75e575
        └── part-1-1.inprogress.bc279efe-b16f-47d8-b828-00ef6e2fbd11
    
  3. 待下次checkpoint成功后,part-1-0完成变为Finished状态,被重命名:
    └── 2019-08-25--12
        ├── part-0-0.inprogress.bd053eb0-5ecf-4c85-8433-9eff486ac334
        ├── part-1-0
        └── part-1-1.inprogress.bc279efe-b16f-47d8-b828-00ef6e2fbd11
    
  4. 下一个Bucket周期到了,创建新的Bucket目录,不影响之前Bucket内的的in-progress文件,依然要等待文件RollingPolicy以及checkpoint来改变状态:
    └── 2019-08-25--12
        ├── part-0-0.inprogress.bd053eb0-5ecf-4c85-8433-9eff486ac334
        ├── part-1-0
        └── part-1-1.inprogress.bc279efe-b16f-47d8-b828-00ef6e2fbd11
    └── 2019-08-25--13
        └── part-0-2.inprogress.2b475fec-1482-4dea-9946-eb4353b475f1
    

3.3.3 PartFile命名设置

默认,PartFile命名规则如下:

  • In-progress / Pending
    part--.inprogress.uid
  • Finished
    part--

比如part-1-17表示1号子任务已完成的17号文件。

可以使用OutputFileConfig来改变前缀和后缀,代码示例如下:

val config = OutputFileConfig
 .builder()
 .withPartPrefix("prefix")
 .withPartSuffix(".ext")
 .build()
            
val sink = StreamingFileSink
 .forRowFormat(new Path(outputPath), new SimpleStringEncoder[String]("UTF-8"))
 .withBucketAssigner(new KeyBucketAssigner())
 .withRollingPolicy(OnCheckpointRollingPolicy.build())
 .withOutputFileConfig(config)
 .build()

得到的PartFile示例如下:

└── 2019-08-25--12
    ├── prefix-0-0.ext
    ├── prefix-0-1.ext.inprogress.bd053eb0-5ecf-4c85-8433-9eff486ac334
    ├── prefix-1-0.ext
    └── prefix-1-1.ext.inprogress.bc279efe-b16f-47d8-b828-00ef6e2fbd11

3.3.4 RollingPolicy-PartFile滚动策略

RollingPolicy即滚动策略,定义了指定的in-progress状态PartFile在何时关闭,并将其变为pending状态,随后变为finished状态。

finished状态的PartFile是指那些已经准备好被读取且保证包含的数据有效(即使出错数据也不会再回退)。

滚动策略与Checkpoint的时间间隔(pending文件会在下一个Checkpoint上变为finished)相结合,可控制PartFile对下游消费者可用的时效性,以及这些PartFile的大小和数量。

Flink内置滚动策略:

  • DefaultRollingPolicy
  • OnCheckpointRollingPolicy
    需要注意的是,使用Bulk Encoding时,Filnk1.9版本的文件滚动就只能使用OnCheckpointRollingPolicy的策略,该策略在每次checkpoint时滚动part-file。

3.3.5 PartFile序列化编码

StreamingFileSink支持两种编码格式:

  • 行编码格式
    StreamingFileSink.forRowFormat(basePath, rowEncoder)
  • 批量编码格式(如Parquet)
    StreamingFileSink.forBulkFormat(basePath, bulkWriterFactory)

3.3.5.1 Row Encoding

此时,StreamingFileSink会以每条记录为单位进行编码和序列化。

必须配置项:

  • 输出数据的BasePath
  • 序列化每行数据写入PartFile的Encoder

使用RowFormatBuilder可选配置项:

  • 自定义RollingPolicy
    默认使用DefaultRollingPolicy来滚动文件,可自定义
  • bucketCheckInterval
    默认1分钟。该值单位为毫秒,指定按时间滚动文件间隔时间

例子如下:

import org.apache.flink.api.common.serialization.SimpleStringEncoder
import org.apache.flink.core.fs.Path
import org.apache.flink.streaming.api.functions.sink.filesystem.StreamingFileSink

// 1. 构建DataStream
val input: DataStream[String] = ...
// 2. 构建StreamingFileSink,指定BasePath、Encoder、RollingPolicy
val sink: StreamingFileSink[String] = StreamingFileSink
    .forRowFormat(new Path(outputPath), new SimpleStringEncoder[String]("UTF-8"))
    withRollingPolicy(
        DefaultRollingPolicy.builder()
            .withRolloverInterval(TimeUnit.MINUTES.toMillis(15))
            .withInactivityInterval(TimeUnit.MINUTES.toMillis(5))
            .withMaxPartSize(1024 * 1024 * 1024)
            .build())
    .build()
// 3. 添加Sink到InputDataSteam即可
input.addSink(sink)

以上例子构建了一个简单的拥有默认Bucket构建行为(继承自BucketAssigner的DateTimeBucketAssigner)的StreamingFileSink,每小时构建一个Bucket,内部使用继承自RollingPolicy的DefaultRollingPolicy,以下三种情况任一发生会滚动PartFile:

  • PartFile包含至少15分钟的数据
  • 在过去5分钟内没有接收到新数据
  • 在最后一条记录写入后,文件大小已经达到1GB

除了使用DefaultRollingPolicy,也可以自己实现RollingPolicy接口来实现自定义滚动策略。

3.3.5.2 Bulk Encoding

3.3.5.2.1 概述

要使用批量编码,请将StreamingFileSink.forRowFormat()替换为StreamingFileSink.forBulkFormat(),注意此时必须指定一个BulkWriter.Factory而不是行模式的Encoder。BulkWriter在逻辑上定义了如何添加、fllush新记录以及如何最终确定记录的bulk以用于进一步编码。

需要注意的是,使用Bulk Encoding时,Filnk1.9版本的文件滚动就只能使用OnCheckpointRollingPolicy的策略,该策略在每次checkpoint时滚动part-file。

Flink有三个内嵌的BulkWriter:

  • ParquetAvroWriters
    有一些静态方法来创建ParquetWriterFactory
  • SequenceFileWriterFactory
  • CompressWriterFactory
3.3.5.2.2 Parquet

Flink有内置方法可用于为Avro数据创建Parquet writer factory。

要使用ParquetBulkEncoder,需要添加以下Maven依赖:

<dependency>
  <groupId>org.apache.flinkgroupId>
  <artifactId>flink-parquet_2.11artifactId>
  <version>1.11-SNAPSHOTversion>
dependency>

<dependency>
	<groupId>org.apache.avrogroupId>
	<artifactId>avroartifactId>
	<version>1.8.2<version>
dependency>

<dependency>
	<groupId>org.apache.parquetgroupId>
	<artifactId>parquet-avroartifactId>
	<exclusions>
		<exclusion>
			<groupId>org.apache.hadoopgroupId>
			<artifactId>hadoop-clientartifactId>
		exclusion>
		<exclusion>
			<groupId>it.unimi.dsigroupId>
			<artifactId>fastutilartifactId>
		exclusion>
	exclusions>
	<version>1.10.0version>
dependency>

从Kafka读数据,并写入Parquet文件例子:

import org.apache.flink.streaming.api.functions.sink.filesystem.StreamingFileSink
import org.apache.flink.formats.parquet.avro.ParquetAvroWriters
import org.apache.avro.Schema

  def main(args: Array[String]) {
 	// read parameter from command line
    val parameter = ParameterTool.fromArgs(args)

    // 1. set up the streaming execution environment
    val env = StreamExecutionEnvironment.getExecutionEnvironment
      // checkpoint every 5 minute
      .enableCheckpointing(5 * 60 * 1000)
	  .setStateBackend(new RocksDBStateBackend(path, true))
	  
    val checkpointConfig = env.getCheckpointConfig
    checkpointConfig.setMinPauseBetweenCheckpoints(2 * 60 * 1000)
    checkpointConfig.setCheckpointTimeout(3 * 60 * 1000)
    checkpointConfig.enableExternalizedCheckpoints(ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION)

    // 2. kafka consumer`s config
    val kafkaConsumerConfig = new Properties()
    kafkaConsumerConfig.setProperty("bootstrap.servers", parameter.get("bootstrap-servers","192.168.1.1:9092"))
    kafkaConsumerConfig.setProperty("group.id", parameter.get("groupid","Kafka2hive"))
    kafkaConsumerConfig.setProperty("auto.offset.reset", parameter.get("offset","latest"))
    // 动态分区默认关闭,需要设置,以下表示10秒探测一次
    kafkaConsumerConfig.setProperty("flink.partition-discovery.interval-millis", "10000")

    // 3. create a kafka consumer
    val kafkaConsumer = new FlinkKafkaConsumer(
      "test_topic",
      new JSONKeyValueSilentDeserializationSchema,
      kafkaConsumerConfig)
    // 设置为用groupId放在ZK的offset,如果找不到就用配置文件中的auto.offset.reset来决定
    // 注意,该方法不影响此行为:从checkpoint/savepoint恢复时用存在这里面的offset消费
    .setStartFromGroupOffsets()
    // 1.9.0版本必须设这个值,虽然默认true,但是实测中并未提交offset到kafka
    // 设为true后已经观察到offset正常提交到kafka,便于监控
    .setCommitOffsetsOnCheckpoints(true)

    // 4. create the stream with kafka source, test_topic must return Student!
    val kafkaStream: DataStream[Student] = env
      .addSource(kafkaConsumer)

	 // 5. 构建StreamingFileSink,指定BasePath和序列化Encoder
	val sink: StreamingFileSink[Student] = StreamingFileSink
	    .forBulkFormat(outputBasePath, ParquetAvroWriters.forReflectRecord(classOf[Student]))
	    .withBucketAssigner(new EventDateTimeBucketAssigner("yyyyMMdd"))
	    .build()
	
	// 6. 添加Sink到InputDataSteam即可
    kafkaStream.addSink(sink)

    // 7. execute program
    env.execute("Kafka to Parquet")
  }   

重点如下:

  • JSONKeyValueSilentDeserializationSchema
    该类十分重要。他继承自KafkaDeserializationSchema(他可用来访问Kafka消息的key、value和元数据。),负责解析FlinkKafkaConsumer从Kafka中读取到的ConsumerRecord。具体来说,会调用其deserialize方法。我们可以自定义处理逻辑,将其转为一个特定类型,再交由下一个算子处理。这里,我们是解析为了一个自定义Java Bean再由StreamingFileSink

    关于此类,有几点值得注意:

    • 如果出现读Kafka数据中文乱码,可尝试按如下方式解析:
    new String(record.value, StandardCharsets.UTF_8)
    
    • 一定要做异常处理,出错时务必返回null来跳过该条异常记录。否则会造成报错=>程序重启=>继续消费这条记录=>再次报错=>再次重启。。。的死循环中。
  • ParquetAvroWriters是以Avro格式来定义Parquet元数据,写入Parquet文件。

  • 如果要配合Hive使用,则在建表时直接指定STORED AS parquet即可(具体看hive版本)

  • Bucket为按yyyyMMdd天格式生成,可以和Hive表分区目录配合。而且这里我们是自定义的EventDateTimeBucketAssigner,其他都跟BucketAssigner相同除了getBucketId方法自己实现根据特定字段获取BucketId:

public class EventDateTimeBucketAssigner implements BucketAssigner<Student, String> {
	@Override
    public String getBucketId(Student element, Context context) {
        if (dateTimeFormatter == null) {
            dateTimeFormatter = DateTimeFormatter.ofPattern(formatString).withZone(zoneId);
        }
        try {
        	// Student类中的特定字段的get方法
            Long eventTime = element.getTime();
            if(eventTime != null && eventTime > 0){
                return dateTimeFormatter.format(Instant.ofEpochMilli(eventTime));
            }
        } catch (Exception e) {
            LOGGER.error("an error happened while dateTimeFormatter.format context.timestamp():", e);
        }
        return dateTimeFormatter.format(Instant.ofEpochMilli(context.currentProcessingTime()));
    }
}
  • ParquetAvroWriters.forReflectRecord(classOf[Student])
    Student类来生成该Avro的schema,用来生成Parquet的元数据。定义Student类时,请extends Serializable。如果有字段为空,务必记得使用来自Avro项目的@Nullable注解允许有空值,否则遇到空值将会报错!

    如果使用scala,可以使用@BeanProperty字段注解。

    关于Parquet更多内容,可参考:

    • HDFS-Parquet

如果要写入Avro以外的Parquet兼容的数据格式,请实现ParquetBuilder接口来创建ParquetWriterFactory。

如果使用Parquet后还想要压缩,需要自己实现,没有现成API,可参考:

  • Flink-StreaimingFileSink-自定义序列化-Parquet批量压缩
  • StreamingFileSink Avro batch size and compression
  • Flink 如何读取 kafka 数据后以 gzip 压缩格式写入hdfs?
  • How to do failure tolerance for Flink to sink data to hdfs as gzip compression?
3.3.5.2.3 Hadoop SequenceFile

Maven依赖:

<dependency>
  <groupId>org.apache.flinkgroupId>
  <artifactId>flink-sequence-fileartifactId>
  <version>1.11-SNAPSHOTversion>
dependency>

例子:

import org.apache.flink.streaming.api.functions.sink.filesystem.StreamingFileSink
import org.apache.flink.configuration.GlobalConfiguration
import org.apache.hadoop.conf.Configuration
import org.apache.hadoop.io.LongWritable
import org.apache.hadoop.io.SequenceFile
import org.apache.hadoop.io.Text;

val input: DataStream[(LongWritable, Text)] = ...
val hadoopConf: Configuration = HadoopUtils.getHadoopConfiguration(GlobalConfiguration.loadConfiguration())
val sink: StreamingFileSink[(LongWritable, Text)] = StreamingFileSink
  .forBulkFormat(
    outputBasePath,
    // 还有别的参数重载的构造函数,来指定压缩配置
    new SequenceFileWriterFactory(hadoopConf, LongWritable.class, Text.class))
	.build()

input.addSink(sink)
3.3.5.2.4 Compress

可以以指定的Hadoop压缩格式批量将记录压缩:

val sink = StreamingFileSink
      .forBulkFormat(new Path("file:///Users/chengc/compress"),
      CompressWriters.forExtractor(new DefaultExtractor[String]).withHadoopCompression("Gzip"))
      .withBucketAssigner(new DateTimeBucketAssigner("yyyyMMdd"))
      .build()

目前没有在生产环境使用,在本地报错java.io.NotSerializableException: org.apache.hadoop.io.compress.GzipCodec

相关连接:

  • HadoopCompressionBulkWriter fails with ‘java.io.NotSerializableException’
  • FLINK-16371-BulkWriter Fix Hadoop Compression BulkWriter #11307

3.3.6 PartFile与Exactly Once

PartFile的三种状态可以和Chekpoint机制联合提供Exactly Once语义和容错性。

具体来说,StreamingFileSInk会在异常情况下回滚至最后一次成功Checkpoint时的状态。恢复时,最后一次成功snapshot时的pending状态文件会被转移至finished,而其他in-progress状态文件会被回滚(这样就不包含我们恢复的checkpoint之后到达的数据),也就说那部分pendingin-progress文件就被永远忽略了,永远对读不可见。

想想,这个时候由于offset保存在checkpoint,虽然有部分数据被重复消费,但由于这部分数据被忽略永远对读不可见,所以仍然是Exactly Once

3.4 注意事项

  • Flink的Sink和UDF不能区分正常作业结束(比如是有限输入流)还是失败导致的终止
    因此就算是正常执行完的作业的最后一个in-progress文件也不会被转为finished状态。
  • Flink和StreamingFileSink永远不会覆盖已提交的数据。、
    鉴于此,当尝试从旧的检查点/保存点还原时(该检查点/保存点认定一个文件为in-progress状态,但后续成功执行的检查点已经将该文件提交),Flink将拒绝继续进行操作,并且由于无法找到该in-progress文件而抛出异常。

3.5 常见问题

3.5.1 少类、找不到方法

有几种情况:

  • 确实少类
    注意看看maven依赖时,该类所在包的scope,以及生产环境的classpath。

  • 优先加载了hadoop环境
    我遇到了一个Avro包版本冲突的情况,找不到方法。在jobmanager的启动日志里可以发现,先加载$FLINK_HOME/lib下的包,然后是hadoop下的包(包含低版本Avro)。而我自己项目的包(包含适当版本Avro的包)反而最后被加载。所以导致要调用的一个方法找不到报错。

    解决方案就是将我们需要的高版本Avro包放入$FLINK_HOME/lib下优先加载即可解决。

3.5.2 结果文件中文乱码

参照前面的描述

3.5.3 想要批量压缩?

参考Flink-StreaimingFileSink-自定义序列化-Parquet批量压缩

3.5.4 HDFS租约问题

参考HDFS租约与Flink StreamingFileSink

3.5.5 小文件怎么处理

3.5.5.1 概述

因为每个subtask在一个checkpoint周期就会生成一个文件,所以在并发高时小文件数量很大,不仅增加NameNode维护元数据成本,也影响下游其他任务读取效率(大量小文件大量磁盘IO)。常见调优方式介绍如下。

3.5.5.2 增加Checkpoint周期

因为使用BulkEncoding时只能用OnCheckpointRollingPolicy,所以我们调大Checkpoint间隔可以减少总的part-file文件数量。

但调大以后,会增加每次Checkpoint时间,以及增长数据可见周期,需要权衡。

相关设置:

val env = StreamExecutionEnvironment.getExecutionEnvironment
env.enableCheckpointing(5 * 60 * 1000)
   .setStateBackend(new RocksDBStateBackend(path, TernaryBoolean.TRUE))
	  
val checkpointConfig = env.getCheckpointConfig
checkpointConfig.setMinPauseBetweenCheckpoints(2 * 60 * 1000)
checkpointConfig.setCheckpointTimeout(3 * 60 * 1000)

3.5.5.3 减小并发subtask

每个文件在每个Checkpoint周期都会写一个自己的文件,所以可以调小并发减少文件总量。

但这会导致数据处理能力下降,请做出权衡。

3.5.5.4 后期合并

用定时任务合并小文件。

比如我们StreamingFileSink程序写入临时分区,而用SparkSql定时任务,将临时分区的文件读取后写入正式分区目录,用户全部读取正式分区。

这个方法增加了处理成本,但提升了后续其他读取任务处理速度。

更多好文

  • Flink FileSink 自定义输出路径——StreamingFileSink、BucketingSink 和 StreamingFileSink简单比较
  • Flink DataStream之使用BucketingSink将Kafka数据写入HDFS,并分区到Hive
  • hive中parquet和SEQUENCEFILE区别
  • 定制Flink输出的parquet文件名
  • StreamingFileSink Avro batch size and compression
  • Flink 如何读取 kafka 数据后以 gzip 压缩格式写入hdfs?

参考文档

  • Flink-1.9-StreamingFileSink
  • Flink读取kafka数据并以parquet格式写入HDFS,Spark直接读取parquet
  • Flink消费Kafka数据,写入HDFS - 使用 StreamingFileSink
  • Flink HDFS Sink 如何保证 exactly-once 语义
  • Flink之使用StreamingFileSink读取kafka数据并以parquet格式写入HDFS
  • Flink生成Parquet格式文件实战

你可能感兴趣的:(flink)