更多Flink系列文章请点击Flink系列文章
更多大数据文章请点击大数据好文推荐
本文主要介绍Flink中的DataStream之HDFSConnector(StreamingFileSink),包含概念介绍、源码解读、实际Demo,已经更新到最新的Flink 1.10。
可参考:
更多Flink API内容可参考Flink学习3-API介绍-SQL
可参考 Hadoop FileSystem Connector
注意:官网说了,这个版本的BucketingSink已经在Flink1.9中废弃了,会在后面的release版本中移除。建议用StreamingFileSink。
BucketingSink其实就是按时间和parallel-task为粒度来写到定义的base-path目录下的各个文件中,可按时间和大小进行滚动,可自定义时间粒度。
可参考File Systems
Flink使用文件系统来读写数据,比如app运行结果、容错信息、恢复等。这里说的文件系统有很多种,用路径前的URI Schema表示,包括本地(如file:///home/user/text.txt)、Hadoop及兼容的(如hdfs://namenode:50010/data/user/text.txt)、亚马逊S3、阿里云 OSS等。
文件系统实例在每个进程启动时实例化一次,然后进行缓存/池化,以避免每次流创建时的文件系统配置开销,并强制执行某些约束,例如连接/流限制。
file//
开头只要是Flink找不到支持的文件系统目录,则会统一回退到Hadoop schema模式。只要Flink运行时和Hadoop的jar包在classpath就能使用该类文件系统。
将Hadoop配置放在和Hadoop类库相同的路径可使得Hadoop文件系统采用该配置。也可以通过环境变量HADOOP_CONF_DIR
来设定Hadoop配置,或是参考Flink configuration指定fs.hdfs.hadoopconf
表示Hadoop配置目录来让Flink查找core-site.xml
和 hdfs-site.xml
(官方不推荐这种方式,已经deprecated)。
详见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/
详见Adding a new pluggable File System implementation
请参考FileSystem Common Configurations
在$FLINK_HOME/conf/flink-conf.yaml
文件的fs.default-scheme
项设置,不设置时默认将没有URI schema的地址都解析为本地文件系统file:///
。比如要改为某个HDFS schema,可设为:hdfs://mynamenode:12345
。则当指定路径为/user/chengc
时,其实就是hdfs://mynamenode:12345/user/chengc
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:50010
和hdfs://anotherhdfs:4399
将具有单独的池。
可参考:
in-progress
或pendiing
状态,不能被下游系统安全读取。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实例
// 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;
}
// ------------------------------------------------------------------------
// --------------------------- 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);
}
}
/**
* 实现自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();
}
}
}
初始时,会初始化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
方法负责写入数据,调用栈为:
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。
桶分配逻辑定义了如何将数据结构化后写入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();
}
}
我们可以在StreamingFileSink上调用 .withBucketAssigner(assigner)
来实现自定义的BucketAssigner, Flink 有两个内置的 :
将所有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";
}
}
Row格式和Bulk格式编码都使用DateTimeBucketAssigner作为默认BucketAssigner。 默认情况下,DateTimeBucketAssigner 基于系统默认时区每小时以格式yyyy-MM-dd--HH
来创建一个Bucket,Bucket路径为/{basePath}/{dateTimePath}/
。
StreamingFileSink.forRowFormat(new Path(outputPath)
时的路径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 +
'}';
}
}
前面提到过,每个Bukcket内部分为多个部分文件,该Bucket内接收到数据的sink的每个子任务至少有一个PartFile。而额外文件滚动由可配的滚动策略决定。
max PartFile索引+ 1
,其中max
是指在所有子任务中对所有计算的索引最大值。return new Path(bucketPath, outputFileConfig.getPartPrefix() + '-' + subtaskIndex + '-' + partCounter + outputFileConfig.getPartSuffix());
PartFile生命周期有三个状态:
在每个活跃的Bucket期间,每个Writer的子任务在任何时候都只会有一个单独的In-progress PartFile,但可有多个Peding和Finished状态文件。
一个Sink的两个Subtask的PartFile分布情况实例如下:
└── 2019-08-25--12
├── part-0-0.inprogress.bd053eb0-5ecf-4c85-8433-9eff486ac334
└── part-1-0.inprogress.ea65a428-a1d0-4a0b-bbc5-7a436a75e575
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
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
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
默认,PartFile命名规则如下:
比如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
RollingPolicy即滚动策略,定义了指定的in-progress
状态PartFile在何时关闭,并将其变为pending
状态,随后变为finished
状态。
finished
状态的PartFile是指那些已经准备好被读取且保证包含的数据有效(即使出错数据也不会再回退)。
滚动策略与Checkpoint的时间间隔(pending文件会在下一个Checkpoint上变为finished)相结合,可控制PartFile对下游消费者可用的时效性,以及这些PartFile的大小和数量。
Flink内置滚动策略:
OnCheckpointRollingPolicy
的策略,该策略在每次checkpoint时滚动part-file。StreamingFileSink支持两种编码格式:
此时,StreamingFileSink会以每条记录为单位进行编码和序列化。
必须配置项:
使用RowFormatBuilder可选配置项:
例子如下:
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:
除了使用DefaultRollingPolicy,也可以自己实现RollingPolicy接口来实现自定义滚动策略。
要使用批量编码,请将StreamingFileSink.forRowFormat()
替换为StreamingFileSink.forBulkFormat()
,注意此时必须指定一个BulkWriter.Factory
而不是行模式的Encoder。BulkWriter在逻辑上定义了如何添加、fllush新记录以及如何最终确定记录的bulk以用于进一步编码。
需要注意的是,使用Bulk Encoding时,Filnk1.9版本的文件滚动就只能使用OnCheckpointRollingPolicy
的策略,该策略在每次checkpoint时滚动part-file。
Flink有三个内嵌的BulkWriter:
ParquetWriterFactory
。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
。
关于此类,有几点值得注意:
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更多内容,可参考:
如果要写入Avro以外的Parquet兼容的数据格式,请实现ParquetBuilder接口来创建ParquetWriterFactory。
如果使用Parquet后还想要压缩,需要自己实现,没有现成API,可参考:
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)
可以以指定的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
。
相关连接:
PartFile的三种状态可以和Chekpoint机制联合提供Exactly Once
语义和容错性。
具体来说,StreamingFileSInk会在异常情况下回滚至最后一次成功Checkpoint时的状态。恢复时,最后一次成功snapshot时的pending
状态文件会被转移至finished
,而其他in-progress
状态文件会被回滚(这样就不包含我们恢复的checkpoint之后到达的数据),也就说那部分pending
和in-progress
文件就被永远忽略了,永远对读不可见。
想想,这个时候由于offset保存在checkpoint,虽然有部分数据被重复消费,但由于这部分数据被忽略永远对读不可见,所以仍然是Exactly Once
!
in-progress
文件也不会被转为finished
状态。in-progress
状态,但后续成功执行的检查点已经将该文件提交),Flink将拒绝继续进行操作,并且由于无法找到该in-progress
文件而抛出异常。有几种情况:
确实少类
注意看看maven依赖时,该类所在包的scope,以及生产环境的classpath。
优先加载了hadoop环境
我遇到了一个Avro包版本冲突的情况,找不到方法。在jobmanager的启动日志里可以发现,先加载$FLINK_HOME/lib
下的包,然后是hadoop下的包(包含低版本Avro)。而我自己项目的包(包含适当版本Avro的包)反而最后被加载。所以导致要调用的一个方法找不到报错。
解决方案就是将我们需要的高版本Avro包放入$FLINK_HOME/lib
下优先加载即可解决。
参照前面的描述
参考Flink-StreaimingFileSink-自定义序列化-Parquet批量压缩
参考HDFS租约与Flink StreamingFileSink
因为每个subtask在一个checkpoint周期就会生成一个文件,所以在并发高时小文件数量很大,不仅增加NameNode维护元数据成本,也影响下游其他任务读取效率(大量小文件大量磁盘IO)。常见调优方式介绍如下。
因为使用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)
每个文件在每个Checkpoint周期都会写一个自己的文件,所以可以调小并发减少文件总量。
但这会导致数据处理能力下降,请做出权衡。
用定时任务合并小文件。
比如我们StreamingFileSink程序写入临时分区,而用SparkSql定时任务,将临时分区的文件读取后写入正式分区目录,用户全部读取正式分区。
这个方法增加了处理成本,但提升了后续其他读取任务处理速度。