Flink-FilesystemConnector和HiveConnector

Flink-FilesystemConnector和HiveConnector

摘要

本文基于Flink 1.11,主要讲解最新的基于Flink StreamingFileSink的FilesystemConnector和HiveConnector,包括理论、配置和源码分析。

关于StreamingFileSink,可参考

1 FileSystemConnector

1.1 概述

可读写本地或分布式文件系统(如HDFS)。注意,File System Connector做流处理目前还是试验阶段。

Flink 1.11.0开始实现了File System Connector,可直接使用FlinkSql写出支持分区的流式读写本地或分布式文件系统程序。

该Connector为内嵌,不需要任何依赖。

Flink当前具体对文件系统支持情况,请参考File Systems

1.2 Partition Files

Flink支持标准hive分区格式,但并非必须提前在Catalog注册分区,因为Flink可通过目录结构推断。

比如以下目录可推断出分区包含datetimehour
在这里插入图片描述
支持append和overwrite两种模式写入分区表,请参阅INSERT Statement

1.3 文件格式

目前支持

  • CSV
    未压缩的

  • JSON
    采用的是未压缩的、按换行符分隔了的JSON

  • Avro
    Apache Avro. 支持通过avro.codec配置压缩

  • Parquet
    Apache Parquet. 与Hive兼容。

    相关配置可以看类ParquetOutputFormat,配置如下:

    public static final String JOB_SUMMARY_LEVEL = "parquet.summary.metadata.level";
    public static final String BLOCK_SIZE           = "parquet.block.size";
    public static final String PAGE_SIZE            = "parquet.page.size";
    public static final String COMPRESSION          = "parquet.compression";
    public static final String WRITE_SUPPORT_CLASS  = "parquet.write.support.class";
    public static final String DICTIONARY_PAGE_SIZE = "parquet.dictionary.page.size";
    public static final String ENABLE_DICTIONARY    = "parquet.enable.dictionary";
    public static final String VALIDATION           = "parquet.validation";
    public static final String WRITER_VERSION       = "parquet.writer.version";
    public static final String MEMORY_POOL_RATIO    = "parquet.memory.pool.ratio";
    public static final String MIN_MEMORY_ALLOCATION = "parquet.memory.min.chunk.size";
    public static final String MAX_PADDING_BYTES    = "parquet.writer.max-padding";
    public static final String MIN_ROW_COUNT_FOR_PAGE_SIZE_CHECK = "parquet.page.size.row.check.min";
    public static final String MAX_ROW_COUNT_FOR_PAGE_SIZE_CHECK = "parquet.page.size.row.check.max";
    public static final String ESTIMATE_PAGE_SIZE_CHECK = "parquet.page.size.check.estimate";
    
  • Orc
    Apache Orc. 与Hive兼容。

1.4 Streaming Sink

1.4.1 概述

Flink FileSystemConnector支持StreamingSink,依赖于Streaming File Sink写文件,还可参考:

Flink 1.11 可支持的编码和文件格式:

  • 按行编码格式
    csv, json
  • 批量编码格式
    parquet, orc, avro.

目前Flink Sql支持分区表和非分区表

1.4.2 文件滚动策略

分区目录内会有很多Part File,每个写某个分区的Sink子任务至少在该分区有一个文件。

  • 采用按行编码格式时,每次写入的in-progress文件滚动配置如下:
默认类型描述
sink.rolling-policy.file-size128MBMemorySize文件滚动前最大大小
sink.rolling-policy.rollover-interval30 minDuration文件滚动前最长打开时间
sink.rolling-policy.check-interval1 minDuration检测按时间滚动策略的检测间隔时间
  • 采用按批量编码格式时,in-progress文件滚动取决于Checkpoint间隔时间。
    • Checkpoint开始时由in-progress变为pending,此时不会再写入该文件,但也对读不可见
    • 通知Checkpoint完成后,该文件变为finished,对读可见。
    • 这就是个典型的两阶段提交。
  • checkpoint间隔时间由execution.checkpointing.interval参数设置

1.5 PartitionCommit

1.5.1 概述

注意:本条目只适用于动态分区写入

写完一个分区后,可能需要通知下游应用,比如添加该分区到Hive元数据或在特定目录写一个_SUCCESS文件。本Connector可允许配置自定义提交策略,依赖如下组件:

  • PartitionCommitTrigger
    决定何时提交分区,分区提交的时间由从分区提取的时间生成的水位或者ProcessingTime
  • PartitionTimeExtractor
    由PartitionCommitTrigger调用,用来提取分区时间,判断某个分区是否应该commit。
  • PartitionCommitPolicy
    决定怎么提交分区,目前支持提交_SUCCESS文件和元数据,也可以自己实现策略如合并小文件等。

1.5.2 PartitionCommitTrigger

1.5.2.1 概述

PartitionCommitTrigger决定何时提交分区,分区提交的时间由从分区提取的时间生成的水位或者ProcessingTime

配置项默认Type描述
sink.partition-commit.triggerprocess-timeString1.process-time,表示依赖机器时间,无需水位。一旦当前系统时间超过分区创建时间+delay就提交分区。如果数据延迟会导致分区过早提交
2.partition-time,依赖从分区记录提取的时间戳,需要水位。一旦水位超过从分区值中提取的时间+delay,就提交分区
sink.partition-commit.delay0 sDuration分区提交延迟时间,可填1d天级分区,1h表示小时级分区
  • 如果想让下游尽快看到分区而不管数据是否完成就配置,分区可能被提交多次:
    • sink.partition-commit.trigger=process-time
    • sink.partition-commit.delay=0
  • 如果想让下游只在分区数据写入完成后才地让下游知道,那就:
    • 配置水位
    • sink.partition-commit.trigger=partition-time
      没有水位或无法提取时间,那就设为process-time
    • sink.partition-commit.delay=1h/1d等
  • 如果事件迟到但需要写入已提交的分区,就重新触发分区提交。
1.5.2.2 PartitionTimeCommitTigger

watermark > partition-time + sink.partition-commit.delay时提交分区,partition-timePartitionTimeExtractor获取分区代表的时间。

// 快照发生时调用
@Override
public void snapshotState(long checkpointId, long watermark) throws Exception {
    // 清理老的pending分区状态
	pendingPartitionsState.clear();
	// 将当前pending分区加入状态,待放入快照
	pendingPartitionsState.add(new ArrayList<>(pendingPartitions));
	// 在本地内存记录该次checkpoin和水位时间戳对应关系
	watermarks.put(checkpointId, watermark);
	// 清理旧的水位状态
	watermarksState.clear();
	// 将新的checkpoint和水位对应关系放入状态,待放入快照
	watermarksState.add(new HashMap<>(watermarks));
}

// 添加分区为pending待提交状态
// 比如 partitioned by (d string,e string)
// 则partition为 d=2020-05-03/e=8/
@Override
public void addPartition(String partition) {
	if (!StringUtils.isNullOrWhitespaceOnly(partition)) {
		// 将当前要提交的分区放入内存Set记录下来
		this.pendingPartitions.add(partition);
	}
}

// 获取checkpointId对应的可提交的分区
@Override
public List<String> committablePartitions(long checkpointId) {
	if (!watermarks.containsKey(checkpointId)) {
		throw new IllegalArgumentException(String.format(
				"Checkpoint(%d) has not been snapshot. The watermark information is: %s.",
				checkpointId, watermarks));
	}
	long watermark = watermarks.get(checkpointId);
	// 将checkpointId小于等于当前checkpointId的全部从内存找哪个移除
	watermarks.headMap(checkpointId, true).clear();

	List<String> needCommit = new ArrayList<>();
	// 遍历所有pending状态分区
	Iterator<String> iter = pendingPartitions.iterator();
	while (iter.hasNext()) {
		String partition = iter.next();
		// 传入分区列名list和分区列值解析后得到的list
		// 得到无时间分区的LocalDateTime,如 2020-05-03T08:00
		LocalDateTime partTime = extractor.extract(
				partitionKeys, extractPartitionValues(new Path(partition)));
		// 若当前水位大于 (partTime + commitDelay)就说明该分区需要提交
		// 比如commitDelay为1h,而水位升到了2020-05-03 09:00:01, 
		// 则需要提交 2020-05-03T08:00 分区
		if (watermark > toMills(partTime) + commitDelay) {
			needCommit.add(partition);
			iter.remove();
		}
	}
	return needCommit;
}
1.5.2.3 ProcTimeCommitTigger

current processing time > partition creation time + sink.partition-commit.delay时提交分区。

@Override
public List<String> committablePartitions(long checkpointId) {
	List<String> needCommit = new ArrayList<>();
	// 获取当前系统时间
	long currentProcTime = procTimeService.getCurrentProcessingTime();
	// 遍历pending状态的分区
	Iterator<Map.Entry<String, Long>> iter = pendingPartitions.entrySet().iterator();
	while (iter.hasNext()) {
		Map.Entry<String, Long> entry = iter.next();
		// value为addPartition时的系统时间
		long creationTime = entry.getValue();
		// sink.partition-commit.delay为0
		// 或者 currentProcTime > creationTime + commitDelay
		// 说明需要提交该分区,并从pending状态的分区记录中移除
		if (commitDelay == 0 || currentProcTime > creationTime + commitDelay) {
			needCommit.add(entry.getKey());
			iter.remove();
		}
	}
	// 最后返回需要提交的分区list
	return needCommit;
}

1.5.3 PartitionTimeExtractor

1.5.3.1 概述
配置项默认Type描述
partition.time-extractor.kinddefaultString1.default,可配置时间提取格式
2.custom,配置时间提取器实现类
partition.time-extractor.class(none)String时间提取器实现类,实现自PartitionTimeExtractor
partition.time-extractor.timestamp-pattern(none)String指定分区时间提取格式,默认支持从第一个字段以yyyy-mm-dd hh:mm:ss提取。
比如想从dt字段中提取,就配置为$dt
多个字段时,可以设为$year-$month-$day $hour:00:00
1.5.3.2 DefaultPartTimeExtractor

PartitionTimeExtractor的默认实现类为org.apache.flink.table.filesystem.DefaultPartTimeExtractor

// partitionKeys为分区列名组成的list,如partitioned by (d string,e string)那就是 {d, e}
// partitionValues是由记录的pending分区列的值解析后构成的个分区列的值组成的list
// 如 d=2020-05-03/e=8/ ,解析为 {2020-05-03, 8}
@Override
public LocalDateTime extract(List<String> partitionKeys, List<String> partitionValues) {
	String timestampString;
	// partition.time-extractor.timestamp-pattern ,如 $d $e:00:00
	if (pattern == null) {
		// 如果不配置pattern,则取首个分区列的值作为timestampString
		timestampString = partitionValues.get(0);
	} else {
		// 配置了pattern
		timestampString = pattern;
		// 将pattern内的所有分区列名替换为分区列的值
		// 如 $d $e:00:00 -> 2020-05-03 8:00:00
		for (int i = 0; i < partitionKeys.size(); i++) {
			timestampString = timestampString.replaceAll(
					"\\$" + partitionKeys.get(i),
					partitionValues.get(i));
		}
	}
	// 最后得到无时间分区的LocalDateTime,如 2020-05-03T08:00
	return toLocalDateTime(timestampString);
}
1.5.3.3 官方自定义PartitionTimeExtractor类例子
public class HourPartTimeExtractor implements PartitionTimeExtractor {
    @Override
    public LocalDateTime extract(List<String> keys, List<String> values) {
        String dt = values.get(0);
        String hour = values.get(1);
		return Timestamp.valueOf(dt + " " + hour + ":00:00").toLocalDateTime();
	}
}

1.5.4 PartitionCommitPolicy

1.5.4.1 概述

定义partition提交时的行为

配置项默认Type描述
sink.partition-commit.policy.kind(none)String当分区提交后通知下游应用分区可读的策略。
1.metastore,将分区添加到元数据中,仅支持Hive表
2.success-file,增加_SUCCESS文件到目录
3.配置逗号分隔的多个策略,如metastore,success-file
4.custom
sink.partition-commit.policy.class(none)Stringcustom时分区提交策略类,实现自PartitionCommitPolicy
sink.partition-commit.success-file.name(none)String指定分区成功提交后生成的空文件名,默认_SUCCESS
1.5.4.2 自带实现PartitionCommitPolicy类

自带实现有:

  • MetastoreCommitPolicy
    分区提交时,将分区添加到元数据中,目前仅支持Hive表
  • SuccessFileCommitPolicy
    分区提交时,创建_SUCCESS
1.5.4.3 官方自定义PartitionCommitPolicy类例子

可自定义分区提交后行为策略,实现PartitionCommitPolicy接口,例子:

public class AnalysisCommitPolicy implements PartitionCommitPolicy {
    private HiveShell hiveShell;
	@Override
	public void commit(Context context) throws Exception {
	    if (hiveShell == null) {
	        hiveShell = createHiveShell(context.catalogName());
	    }
	    hiveShell.execute(String.format("ALTER TABLE %s ADD IF NOT EXISTS PARTITION (%s = '%s') location '%s'",
	        context.tableName(),
	        context.partitionKeys().get(0),
	        context.partitionValues().get(0),
	        context.partitionPath()));
	    hiveShell.execute(String.format(
	        "ANALYZE TABLE %s PARTITION (%s = '%s') COMPUTE STATISTICS FOR COLUMNS",
	        context.tableName(),
	        context.partitionKeys().get(0),
	        context.partitionValues().get(0)));
	}
}

1.5.5 完整例子

从Kafka流式读取数据,流式写入FileSystem,并从fs_table流式查询

CREATE TABLE kafka_table (
  user_id STRING,
  order_amount DOUBLE,
  log_ts TIMESTAMP(3),
  WATERMARK FOR log_ts AS log_ts - INTERVAL '5' SECOND
) WITH (...);

CREATE TABLE fs_table (
  user_id STRING,
  order_amount DOUBLE,
  dt STRING,
  hour STRING
) PARTITIONED BY (dt, hour) WITH (
  'connector'='filesystem',
  'path'='...',
  'format'='parquet',
  'sink.partition-commit.delay'='1 h',
  'sink.partition-commit.policy.kind'='success-file'
);

-- streaming sql, insert into file system table
INSERT INTO TABLE fs_table SELECT user_id, order_amount, DATE_FORMAT(log_ts, 'yyyy-MM-dd'), DATE_FORMAT(log_ts, 'HH') FROM kafka_table;

-- batch sql, select with partition pruning
SELECT * FROM fs_table WHERE dt='2020-05-20' and hour='12';

2 HiveConnector

2.1 概述

目前Flink支持Hive相关功能有:

  • HiveCatalog
    用来存储Flink表元数据
  • HiveStreamingSink
  • HiveStreamingSource
  • Hive Batch Read & Write
    作为执行引擎直接读写Hive表数据

Flink兼容Hive 1.0到3.1等不同版本,所以具体功能受限于Hive版本:
在这里插入图片描述

在Flink使用Hive需要:

推荐大家使用Apache Zeppelin访问,图形化界面,十分方便。参考:Flink-Zeppelin On FlinkSql

2.2 Hive Dialect

从Flink 1.11.0开始,Flink可直接使用Hive SQL,只要动态开启hive dialect选项即可:

  • set table.sql-dialect=hive;
    使用hive方言,这样,Flink就可以和Hive互操作。

  • set table.sql-dialect=default;
    使用默认的Flink Sql方言

目前支持的语法请参考:Hive Dialect

注意点:

  • Hive Dialect仅可用来操作Hive表,即is_generic=false.
  • Hive Dialect应该和HiveCatalog联用
  • 支持的语法取决于你的Hive版本
  • 注意Hive和Calcite中的关键字不同,遇到关键字请使用``包含
  • 无法在Hive中查询在Flink中创建的视图。

2.3 HiveStreamingSink

2.3.1 概述

Flink既可以批量读写Hive,也支持流式读写Hive,还能支持流式Join Hive。

HiveStreamingSink利用Filesystem Streaming Sink,集成了Hadoop OutputFormat/RecordWriter(批量编码、在每个Checkpoint滚动文件)来进行StreamingSink。

2.3.2 官方例子

-- 采用hive sql方言
SET table.sql-dialect=hive;
-- 建立Hive表
CREATE TABLE hive_table (
  user_id STRING,
  order_amount DOUBLE
) PARTITIONED BY (dt STRING, hr STRING) STORED AS parquet TBLPROPERTIES (
  'partition.time-extractor.timestamp-pattern'='$dt $hr:00:00',
  'sink.partition-commit.trigger'='partition-time',
  'sink.partition-commit.delay'='1 h',
  'sink.partition-commit.policy.kind'='metastore,success-file'
);

-- 使用Flink sql方言
SET table.sql-dialect=default;
CREATE TABLE kafka_table (
  user_id STRING,
  order_amount DOUBLE,
  log_ts TIMESTAMP(3),
  WATERMARK FOR log_ts AS log_ts - INTERVAL '5' SECOND
) WITH (...);

-- streaming sql, insert into hive table
INSERT INTO TABLE hive_table SELECT user_id, order_amount, DATE_FORMAT(log_ts, 'yyyy-MM-dd'), DATE_FORMAT(log_ts, 'HH') FROM kafka_table;

-- batch sql, select with partition pruning
SELECT * FROM hive_table WHERE dt='2020-05-20' and hr='12';

2.3.3 Parquet + LZO 例子

使用如下语句建表

create table flink_meta.test_hive_20200729 (
  f_sequence INT,
  f_random INT,
  f_random_str STRING
)   PARTITIONED BY (dt STRING, hr STRING, mi STRING) 
    STORED AS parquet 
    TBLPROPERTIES (
      'partition.time-extractor.timestamp-pattern'='$dt $hr:$mi:00',
      'sink.partition-commit.trigger'='partition-time',
      'sink.partition-commit.delay'='1 min',
      'sink.partition-commit.policy.kind'='metastore,success-file',
      'parquet.compression'='lzo'
    );

建好后,可以直接在hive上查看该表:

createtab_stmt	
CREATE TABLE `flink_meta.test_hive_20200729`(	
  `f_sequence` int, 	
  `f_random` int, 	
  `f_random_str` string)	
PARTITIONED BY ( 	
  `dt` string, 	
  `hr` string, 	
  `mi` string)	
ROW FORMAT SERDE 	
  'org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe' 	
STORED AS INPUTFORMAT 	
  'org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat' 	
OUTPUTFORMAT 	
  'org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat'	
LOCATION	
  'hdfs://xxx/hive/flink_meta.db/test_hive_20200729'	
TBLPROPERTIES (	
  'is_generic'='false', 	
  'partition.time-extractor.timestamp-pattern'='$dt $hr:$mi:00', 	
  'sink.partition-commit.delay'='1 min', 	
  'sink.partition-commit.policy.kind'='metastore,success-file', 	
  'sink.partition-commit.trigger'='partition-time', 	
  'transient_lastDdlTime'='1596019921')	

注意到该表is_generic属性为false,表示不是Flink专用表,我们用hive也可以查。

写入的数据在hdfs情况如下:
在这里插入图片描述
使用zeppelin执行flink查询该分区数据:
在这里插入图片描述

使用hive查询该分区数据:

select * from flink_meta.test_hive_20200729 where dt='2020-07-29'

在这里插入图片描述

2.4 HiveStreamingSource

Flink 1.11支持流式读Hive表,助力Hive实时数仓实现:

  • 分区表
    监控新增的分区,从新增分区读取数据
  • 非分区表
    监控目录中新增文件,从新增文件读取数据

相关配置如下:

配置项默认Type描述
streaming-source.enablefalseBoolean是否启用HiveStreamingSource。
注意:必须确保每个分区/文件均应原子写入,否则可能会读到不完整的数据
streaming-source.monitor-interval1 mDuration连续监控分区/文件的间隔时间
streaming-source.consume-ordercreate-timeString消费StreamingSource的顺序
1.create-time, 比较分区/文件在文件系统上的modification time。非分区表只能用该模式
2. partition-time,比较分区名字代表的时间
streaming-source.consume-start-offset1970-00-00String消费起点分区。consume-order为create-time 和 partition-time 时使用时间戳字符串,格式为yyyy-[m]m-[d]d [hh:mm:ss]。如果是partition-time,会使用分区时间提取器来从分区中提取时间。

注意事项:

  • 会监控读取所有本地指定路径目录和文件,如果分区过多会造成性能问题
  • 非分区表的流读取要求每个文件必须是原子地写入目标目录
  • 分区表的流读取要求每个分区原子性地添加到Hive元数据视图中,也就是说将数据添加到已经存在于Hive元数据中的分区不能被消费
  • 流读取不支持Flink DDL定义水位,所以不支持Window算子!

从Hive表的2020-05-20分区开始增量流读取例子:

SELECT * FROM hive_table /*+ OPTIONS('streaming-source.enable'='true', 'streaming-source.consume-start-offset'='2020-05-20') */;

2.5 Hive表做维表join

Hive表作为临时维表参与join时,数据会被缓存到TaskManager的内存中。需要join的Stream的每条数据都会从缓存中匹配Hive数据。

可以配合Hive数据缓存在TM中的TTL时间,当超过该事件会将缓存失效,并读取最新数据:

配置项默认Type描述
lookup.join.cache.ttl60 minDurationTM中缓存临时表TTL时间

注意事项:

  • 每个join subTask都需要缓存一份全量Hive表数据,需提前确认是否能放入TM slot内存
  • lookup.join.cache.ttl不能设的太小,否则频繁更新会造成性能问题
  • 目前刷新Hive表缓存是全量刷新,而无法增量更新。

2.6 HiveBatchRead

当联用BlinkPlanner、HiveCatalog和Flink Hive Connector时,可直接读写Hive,这也是即MR、Tez、Spark后的又一个Hive执行引擎选项。

常用语句:

show catalogs;
use catalog myhive;

show databases;

show tables;

describe mytable;

SELECT * FROM mytable;

查询Hive视图:

  • 使用HiveCatalog
    use catalog xxx;
  • 要满足Flink SQL语法

2.7 HiveBatchWrite

insert into xxx select …:

-- append数据
INSERT INTO mytable SELECT 'Tom', 25;
-- 覆盖所有现存数据
INSERT OVERWRITE mytable SELECT 'Tom', 25;

插入数据到分区表,支持静态和动态分区。

以下例子中,Hive表有四列:name, age, my_type, my_date,分区字段为my_type my_date:

# ------ Insert with static partition ------ 
Flink SQL> INSERT OVERWRITE myparttable PARTITION (my_type='type_1', my_date='2019-08-08') SELECT 'Tom', 25;

# ------ Insert with dynamic partition ------ 
Flink SQL> INSERT OVERWRITE myparttable SELECT 'Tom', 25, 'type_1', '2019-08-08';

# ------ Insert with static(my_type) and dynamic(my_date) partition ------ 
Flink SQL> INSERT OVERWRITE myparttable PARTITION (my_type='type_1') SELECT 'Tom', 25, '2019-08-08';

2.8 支持的Format

  • text
  • csv
  • SequenceFile
  • ORC
  • Parquet

2.9 FlinkHiveSource并发量推断

默认下Flink依据Split数量来推断FlinkHiveSource并发量,Split数量取决于目标文件数量和文件Block数。

可通过TableConfig调整以下参数,需要注意这些参数会影响该job的所有Source:
在这里插入图片描述

2.10 相关优化

  • 分区裁剪
    Hive分区表,只读取分区文件数据
  • 投影下推
    只从Hive表中读取查询用到的列,特别是在超宽表中优化意义重大
  • Limit下推
    当使用limit时,尽量让limit下推,以最大程度地减少跨网络传输的数据量。
  • 向量化读取
    当满足以下条件时会自动向量化优化:
    • ORC和Parquet格式的列存文件
    • 非复杂数据类型列(比如Hive中的List, Map, Struct, Union等类型就属于复杂类型列,不能向量化优化)
      该选项默认开启,遇到问题时可做以下配置来使用MR Record Reader: table.exec.hive.fallback-mapred-reader

2.11 未来路径

  • ACID Table
  • Bucket Table
  • 支持更多Format

3 源码分析

3.1 概述

这里我们使用HiveTableSinkITCase#testPartStreamingWrite测试用例来进行debug,代码如下:

@Test(timeout = 120000)
public void testPartStreamingWrite() throws Exception {
	testStreamingWrite(true, false, false, this::checkSuccessFiles);
}

testStreamingWrite:

private void testStreamingWrite(
		boolean part,
		boolean useMr,
		boolean defaultSer,
		Consumer<String> pathConsumer) throws Exception {
	StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
	env.setParallelism(1);
	env.enableCheckpointing(100);

	StreamTableEnvironment tEnv = HiveTestUtils.createTableEnvWithBlinkPlannerStreamMode(env);
	tEnv.registerCatalog(hiveCatalog.getName(), hiveCatalog);
	tEnv.useCatalog(hiveCatalog.getName());
	tEnv.getConfig().setSqlDialect(SqlDialect.HIVE);
	if (useMr) {
		tEnv.getConfig().getConfiguration().set(
				HiveOptions.TABLE_EXEC_HIVE_FALLBACK_MAPRED_WRITER, true);
	} else {
		tEnv.getConfig().getConfiguration().set(
				HiveOptions.TABLE_EXEC_HIVE_FALLBACK_MAPRED_WRITER, false);
	}

	try {
		tEnv.executeSql("create database db1");
		tEnv.useDatabase("db1");

		// prepare source
		List<Row> data = Arrays.asList(
				Row.of(1, "a", "b", "2020-05-03", "7"),
				Row.of(2, "p", "q", "2020-05-03", "8"),
				Row.of(3, "x", "y", "2020-05-03", "9"),
				Row.of(4, "x", "y", "2020-05-03", "10"),
				Row.of(5, "x", "y", "2020-05-03", "11"));
		DataStream<Row> stream = env.addSource(
				new FiniteTestSource<>(data),
				new RowTypeInfo(Types.INT, Types.STRING, Types.STRING, Types.STRING, Types.STRING));
		tEnv.createTemporaryView("my_table", stream, $("a"), $("b"), $("c"), $("d"), $("e"));

		// DDL
		tEnv.executeSql("create external table sink_table (a int,b string,c string" +
				(part ? "" : ",d string,e string") +
				") " +
				(part ? "partitioned by (d string,e string) " : "") +
				(defaultSer ? "" : " stored as parquet") +
				" TBLPROPERTIES (" +
//					"'" + SINK_PARTITION_COMMIT_TRIGGER.key() + "'='partition-time'," +
				"'" + PARTITION_TIME_EXTRACTOR_TIMESTAMP_PATTERN.key() + "'='$d $e:00:00'," +
				"'" + SINK_PARTITION_COMMIT_DELAY.key() + "'='1h'," +
				"'" + SINK_PARTITION_COMMIT_POLICY_KIND.key() + "'='metastore,success-file'," +
				"'" + SINK_PARTITION_COMMIT_SUCCESS_FILE_NAME.key() + "'='_MY_SUCCESS'" +
				")");

		TableEnvUtil.execInsertTableAndWaitResult(
				tEnv.sqlQuery("select * from my_table"),
				"sink_table");

		assertBatch("db1.sink_table", Arrays.asList(
				"1,a,b,2020-05-03,7",
				"1,a,b,2020-05-03,7",
				"2,p,q,2020-05-03,8",
				"2,p,q,2020-05-03,8",
				"3,x,y,2020-05-03,9",
				"3,x,y,2020-05-03,9",
				"4,x,y,2020-05-03,10",
				"4,x,y,2020-05-03,10",
				"5,x,y,2020-05-03,11",
				"5,x,y,2020-05-03,11"));

		// using batch table env to query.
		List<String> results = new ArrayList<>();
		TableEnvironment batchTEnv = HiveTestUtils.createTableEnvWithBlinkPlannerBatchMode();
		batchTEnv.registerCatalog(hiveCatalog.getName(), hiveCatalog);
		batchTEnv.useCatalog(hiveCatalog.getName());
		batchTEnv.executeSql("select * from db1.sink_table").collect()
				.forEachRemaining(r -> results.add(r.toString()));
		results.sort(String::compareTo);
		Assert.assertEquals(
				Arrays.asList(
						"1,a,b,2020-05-03,7",
						"1,a,b,2020-05-03,7",
						"2,p,q,2020-05-03,8",
						"2,p,q,2020-05-03,8",
						"3,x,y,2020-05-03,9",
						"3,x,y,2020-05-03,9",
						"4,x,y,2020-05-03,10",
						"4,x,y,2020-05-03,10",
						"5,x,y,2020-05-03,11",
						"5,x,y,2020-05-03,11"),
				results);

		pathConsumer.accept(URI.create(hiveCatalog.getHiveTable(
				ObjectPath.fromString("db1.sink_table")).getSd().getLocation()).getPath());
	} finally {
		tEnv.executeSql("drop database db1 cascade");
	}
}

其中

TableEnvUtil.execInsertTableAndWaitResult(
				tEnv.sqlQuery("select * from my_table"),
				"sink_table")

这一句就会开始流式地从临时视图表my_table读取数据,然后流式写入Hive表sink_table

3.2 Source表解析

如该测试用例中的:

tEnv.sqlQuery("select * from my_table")

会在TableEnvironmentImpl#sqlQuery中被解析:
在这里插入图片描述
parser#parse如下(使用blink):

@Override
public List<Operation> parse(String statement) {
	CalciteParser parser = calciteParserSupplier.get();
	FlinkPlannerImpl planner = validatorSupplier.get();
	// parse the sql query
	SqlNode parsed = parser.parse(statement);

	Operation operation = SqlToOperationConverter.convert(planner, catalogManager, parsed)
		.orElseThrow(() -> new TableException("Unsupported query: " + statement));
	return Collections.singletonList(operation);
}

解析后,我们的原始sql转为了带有元数据和逻辑计划(calciteTree)的PlannerQueryOperation对象。

随后利用该operation来构建一张动态表。

protected TableImpl createTable(QueryOperation tableOperation) {
	return TableImpl.createTable(
		this,
		tableOperation,
		operationTreeBuilder,
		functionCatalog.asLookup(parser::parseIdentifier));
}
  • tableOperation
    就是我们刚刚转换的PlannerQueryOperation
  • operationTreeBuilder
    用来创建各种算子
    在这里插入图片描述
  • functionCatalog
    用来查找function对应的实现,又会被包装进LookupCallResolver

3.3 数据插入

3.3.1 概述

解析完Source表以后,开始往Sink表插入数据。

经过一些列翻译和转换,会利用已有元数据信息来使用HiveTableFactory创建HiveTableSink

3.3.2 HiveTableFactory

3.3.2.1 createTableSink

会利用该工厂来创建HiveTableSink。

这里注意,我们的Hive表的is_generic属性为false,表示不是flink专用表,则说明Hive的其他引擎也可以直接查该表。
在这里插入图片描述

3.3.3 HiveTableSink

3.3.3.1 构造函数

在这里插入图片描述

  • userMrWriter
    为false,表示不适用MR方式写入,即使用flink方式
  • identifier
    在这里插入图片描述
    唯一FlinkSql中的标识一张表
  • hiveShim
    用来兼容不同Hive版本
  • tableSchema
    在这里插入图片描述
    表的元数据
3.3.3.2 consumeDataStream

会喂一个DataStream,返回消费该DataStream的DataStreamSink用来设置资源参数

这个方法内容很多,摘一部分:

@Override
public final DataStreamSink consumeDataStream(DataStream dataStream) {
	// 分区列
	String[] partitionColumns = getPartitionKeys().toArray(new String[0]);
	// 库
	String dbName = identifier.getDatabaseName();
	// 表
	String tableName = identifier.getObjectName();
	// 创建到HiveMetastore的Client
	try (HiveMetastoreClientWrapper client = HiveMetastoreClientFactory.create(
			new HiveConf(jobConf, HiveConf.class), hiveVersion)) {
		// 拿到该表在Hive中的元数据	
		Table table = client.getTable(dbName, tableName);
		// 拿到该表的存储相关元数据,如location、input/outputFormat、压缩信息、分桶列、排序列、数据倾斜信息等
		StorageDescriptor sd = table.getSd();
		HiveTableMetaStoreFactory msFactory = new HiveTableMetaStoreFactory(
				jobConf, hiveVersion, dbName, tableName);
		HadoopFileSystemFactory fsFactory = new HadoopFileSystemFactory(jobConf);

		Class hiveOutputFormatClz = hiveShim.getHiveOutputFormatClass(
				Class.forName(sd.getOutputFormat()));
		boolean isCompressed = jobConf.getBoolean(HiveConf.ConfVars.COMPRESSRESULT.varname, false);
		// 创建写入时用到的RecordWriter 和 converter 工厂类
		HiveWriterFactory recordWriterFactory = new HiveWriterFactory(
				jobConf,
				hiveOutputFormatClz,
				sd.getSerdeInfo(),
				tableSchema,
				partitionColumns,
				HiveReflectionUtils.getTableMetadata(hiveShim, table),
				hiveShim,
				isCompressed);
		String extension = Utilities.getFileExtension(jobConf, isCompressed,
				(HiveOutputFormat<?, ?>) hiveOutputFormatClz.newInstance());
		// 决定输出的part-file的随机前缀(如part-6a836c0c-a9e2-4c32-9032-7f1732b2a7f6)和后缀		
		OutputFileConfig outputFileConfig = OutputFileConfig.builder()
				.withPartPrefix("part-" + UUID.randomUUID().toString())
				.withPartSuffix(extension == null ? "" : extension)
				.build();
		if (isBounded) {
			// 有界数据处理
			...
		} else {
			//无界数据处理
			// 该sink表的属性,如sink.partition-commit.success-file.name -> _MY_SUCCESS
			org.apache.flink.configuration.Configuration conf = new org.apache.flink.configuration.Configuration();
			catalogTable.getOptions().forEach(conf::setString);
			// Hive表分区计算器,有一个generatePartValues方法用来从RowData得到该条记录对应分区的值
			// 还有个父类RowDataPartitionComputer的projectColumnsToWrite方法将原始数据剔除分区列数据
			HiveRowDataPartitionComputer partComputer = new HiveRowDataPartitionComputer(
					hiveShim,
					jobConf.get(
							HiveConf.ConfVars.DEFAULTPARTITIONNAME.varname,
							HiveConf.ConfVars.DEFAULTPARTITIONNAME.defaultStrVal),
					tableSchema.getFieldNames(),
					tableSchema.getFieldDataTypes(),
					partitionColumns);
			// 文件系统Bucket分配器,包含了partComputer。从分区列的值来计算BucketId。
			// StreamingFileSink支持自定义,但FlinkSql不支持
			TableBucketAssigner assigner = new TableBucketAssigner(partComputer);
			// 继承自CheckpointRollingPolicy,在每个Checkpoint时滚动part-file,以使之可读
			// 不同的地方见后文分析
			TableRollingPolicy rollingPolicy = new TableRollingPolicy(
					true,
					conf.get(SINK_ROLLING_POLICY_FILE_SIZE).getBytes(),
					conf.get(SINK_ROLLING_POLICY_ROLLOVER_INTERVAL).toMillis());
			// 批量模式下的Wrtier工厂,如ParquetWriterFactory,可创建输出Writer
			// 目前StreamingFileS
			Optional<BulkWriter.Factory<RowData>> bulkFactory = createBulkWriterFactory(partitionColumns, sd);		
			BucketsBuilder<RowData, String, ? extends BucketsBuilder<RowData, ?, ?>> builder;
			if (userMrWriter || !bulkFactory.isPresent()) {
				HiveBulkWriterFactory hadoopBulkFactory = new HiveBulkWriterFactory(recordWriterFactory);
				builder = new HadoopPathBasedBulkFormatBuilder<>(
						new Path(sd.getLocation()), hadoopBulkFactory, jobConf, assigner)
						.withRollingPolicy(rollingPolicy)
						.withOutputFileConfig(outputFileConfig);
				LOG.info("Hive streaming sink: Use MapReduce RecordWriter writer.");
			} else {
				// 不使用mr时走这里
				// 创建批量编码的sinkbuilder
				builder = StreamingFileSink.forBulkFormat(
						// base path,表的存储目录
						new org.apache.flink.core.fs.Path(sd.getLocation()),
						// 注意这里,不是直接用ParquetWriterFactory
						new FileSystemTableSink.ProjectionBulkFactory(bulkFactory.get(), partComputer))
						.withBucketAssigner(assigner)
						.withRollingPolicy(rollingPolicy)
						.withOutputFileConfig(outputFileConfig);
				LOG.info("Hive streaming sink: Use native parquet&orc writer.");
			}
			// 看 3.3.4 FileSystemTableSink
			return FileSystemTableSink.createStreamingSink(
					conf,
					new org.apache.flink.core.fs.Path(sd.getLocation()),
					getPartitionKeys(),
					identifier,
					overwrite,
					dataStream,
					builder,
					msFactory,
					fsFactory,
					conf.get(SINK_ROLLING_POLICY_CHECK_INTERVAL).toMillis());
		}
	} catch (TException e) {
		...
	}
}
3.3.3.3 TableRollingPolicy

继承自CheckpointRollingPolicy,在每个Checkpoint时滚动part-file,以使之可读。

和DataStream API里的实现OnCheckpointRollingPolicy有所不同:

  • shouldRollOnCheckpoint
    根据配置的rollOnCheckpoint(默认true)或 partFile大小是否超过配置的sink.rolling-policy.file-size(默认128MB),而非总是true
return rollOnCheckpoint || partFileState.getSize() > rollingFileSize;
  • shouldRollOnEvent
    根据partFile大小是否超过配置的sink.rolling-policy.file-size(默认128MB),而非总是false。

    会在每条数据写入的时候判断,具体见Bucket#write

return partFileState.getSize() > rollingFileSize;
  • shouldRollOnProcessingTime
    根据当前时间减去partFile的创建时间否超过配置的sink.rolling-policy.rollover-interval(默认30min),而非总是false。

    根据sink.rolling-policy.check-interval配置的时间,StreamingFileSinkHelper会调用ProcessingTimeService#registerTimer(currentProcessingTime + bucketCheckInterval, this),来定时调用buckets.onProcessingTime(currentTime)方法,内部就会判断活跃Bucket中的正在写入的partFile是否应该更新,具体可见Bucket#onProcessingTime

@Override
public boolean shouldRollOnProcessingTime(
		PartFileInfo<String> partFileState,
		long currentTime) {
	return currentTime - partFileState.getCreationTime() >= rollingTimeInterval;
}
3.3.3.4 ProjectionBulkFactory

代码中看到创建StreamingFileSink时,不是直接用ParquetWriterFactory。

这里简单看看

public static class ProjectionBulkFactory implements BulkWriter.Factory<RowData> {

	private final BulkWriter.Factory<RowData> factory;
	private final RowDataPartitionComputer computer;

	// 传入的是ParquetWriterFactory, partComputer
	public ProjectionBulkFactory(BulkWriter.Factory<RowData> factory, RowDataPartitionComputer computer) {
		this.factory = factory;
		this.computer = computer;
	}
	
	// 实现自BulkWriter.Factory的方法
	@Override
	public BulkWriter<RowData> create(FSDataOutputStream out) throws IOException {
		// 这里就是我们前面传入的ParquetBulkWriter
		BulkWriter<RowData> writer = factory.create(out);
		return new BulkWriter<RowData>() {

			// 将记录写入buffer等待批量写入stream或立即写入stream
			@Override
			public void addElement(RowData element) throws IOException {
				// 将非分区列单独抽出来,写入writer
				// 因为我们是直接写入分区目录
				writer.addElement(computer.projectColumnsToWrite(element));
			}
			
			// 将buffer中所有数据刷入输出流
			@Override
			public void flush() throws IOException {
				writer.flush();
			}
			
			// 结束写,也就是说在此之后不应该再有addElement调用
			// 将buffer中所有数据刷入输出流,会结束编码、写入footer
			@Override
			public void finish() throws IOException {
				writer.finish();
			}
		};
	}
}

可以看到,ProjectionBulkFactory功能主要是提出非分区列,设计模式采用了装饰器模式,扩展了ParquetBulkWriter的功能。

3.3.4 FileSystemTableSink

3.3.4.1 createStreamingSink
public static DataStreamSink<RowData> createStreamingSink(
		Configuration conf,
		Path path,
		List<String> partitionKeys,
		ObjectIdentifier tableIdentifier,
		boolean overwrite,
		DataStream<RowData> inputStream,
		BucketsBuilder<RowData, String, ? extends BucketsBuilder<RowData, ?, ?>> bucketsBuilder,
		TableMetaStoreFactory msFactory,
		FileSystemFactory fsFactory,
		long rollingCheckInterval) {
	if (overwrite) {
		throw new IllegalStateException("Streaming mode not support overwrite.");
	}
	// 算子版本的StreamingFileSink,用来处理写分区文件
	StreamingFileWriter fileWriter = new StreamingFileWriter(
			rollingCheckInterval,
			bucketsBuilder);
	// 使用StreamingFileWriter算子、CommitMessage作为输出类型来构建DataStream
	DataStream<CommitMessage> writerStream = inputStream.transform(
			StreamingFileWriter.class.getSimpleName(),
			TypeExtractor.createTypeInfo(CommitMessage.class),
			fileWriter).setParallelism(inputStream.getParallelism());
	
	DataStream<?> returnStream = writerStream;

	// save committer when we don't need it.
	if (partitionKeys.size() > 0 && conf.contains(SINK_PARTITION_COMMIT_POLICY_KIND)) {
		// StreamingFileWriter 流式写文件的时候,用此类来做分区提交
		StreamingFileCommitter committer = new StreamingFileCommitter(
				path, tableIdentifier, partitionKeys, msFactory, fsFactory, conf);
		returnStream = writerStream
				.transform(StreamingFileCommitter.class.getSimpleName(), Types.VOID, committer)
				.setParallelism(1)
				.setMaxParallelism(1);
	}
	//noinspection unchecked
	return returnStream.addSink(new DiscardingSink()).setParallelism(1);
}

3.3.5 StreamingFileCommitter

3.5.5.1 概述

StreamingFileWriter流式写文件的时候,用此类来做分区提交。

本类是一个单实例、非并行的task,搜集从上游发来的所有分区信息,当判断已经收集到某个Checkpoint的所有task实例的分区信息时触发分区提交。

处理步骤为:

  1. 调用processElement方法,接收到来自上游的分区信息,将ready的分区trigger.addPartition(partition)
  2. TaskTracker#add方法判断是否已经接收到所有属于某个CheckpointId的上游task的分区数据
  3. 调用trigger.committablePartitions从trigger提取可提交的partition信息
  4. 调用PartitionCommitPolicy链内的所有PartitionCommitPolicy#commit,执行用户定义的分区提交行为,如提交元数据到Hive、创建SUCCESS_FILE文件等
3.5.5.2 debug方法

可以通过HiveTableSinkITCase#testPartStreamingWrite测试用例来debug流式写入分区表。

也可以自己写demo来调试。

3.5.5.3 主要方法
  • initializeState
    初始化时会调用
@Override
public void initializeState(StateInitializationContext context) throws Exception {
	super.initializeState(context);
	currentWatermark = Long.MIN_VALUE;
	// 设置分区提交触发器,由 sink.partition-commit.trigger 指定
	// 默认为ProcTimeCommitTigger
	this.trigger = PartitionCommitTrigger.create(
			context.isRestored(),
			context.getOperatorStateStore(),
			conf,
			getUserCodeClassloader(),
			partitionKeys,
			getProcessingTimeService());
			
    // 设置分区提交时的行为策略链,由 sink.partition-commit.policy.kind 指定		
    // 默认为空	
	this.policies = PartitionCommitPolicy.createPolicyChain(
			getUserCodeClassloader(),
			conf.get(SINK_PARTITION_COMMIT_POLICY_KIND),
			conf.get(SINK_PARTITION_COMMIT_POLICY_CLASS),
			conf.get(SINK_PARTITION_COMMIT_SUCCESS_FILE_NAME),
			() -> {
				try {
					return fsFactory.create(locationPath.toUri());
				} catch (IOException e) {
					throw new RuntimeException(e);
				}
			});
}

  • snapshotState
    开始状态快照时调用PartitionCommitTrigger#snapshotState,记录pendingPartition、水位状态
@Override
public void snapshotState(StateSnapshotContext context) throws Exception {
	super.snapshotState(context);
	// 调用
	trigger.snapshotState(context.getCheckpointId(), currentWatermark);
}
  • processWatermark
    提升当前水位,并将水位发送给下游算子
@Override
public void processWatermark(Watermark mark) throws Exception {
	super.processWatermark(mark);
	this.currentWatermark = mark.getTimestamp();
}
  • processElement
    处理单条输入记录时调用
@Override
public void processElement(StreamRecord<CommitMessage> element) throws Exception {
	// 写完一个分区后会发送一个message到这里
	CommitMessage message = element.getValue();
	// 将ready的分区添加到内存中的pendingPartitions
	for (String partition : message.partitions) {
		trigger.addPartition(partition);
	}
	// 用来监控上游任务,以决定是否已经接收到所有属于某个Checkpoint的上游数据
	if (taskTracker == null) {
		taskTracker = new TaskTracker(message.numberOfTasks);
	}
	// 当该checkpointId应该被提交时返回true
	boolean needCommit = taskTracker.add(message.checkpointId, message.taskId);
	if (needCommit) {
		// 提交该partition
		commitPartitions(message.checkpointId);
	}
}

private void commitPartitions(long checkpointId) throws Exception {
	// 得到可提交的分区,并清理内存中的pendingPartition
	// 如果task结束,会发送值为Long.MAX_VALUE的checkpointId,调用endInput方法,以保证所有pendingPartition被提交
	// 其他时候走committablePartitions,通过具体策略判断分区是否应该被提交
	List<String> partitions = checkpointId == Long.MAX_VALUE ?
			trigger.endInput() :
			trigger.committablePartitions(checkpointId);
	if (partitions.isEmpty()) {
		return;
	}
	// 如HiveTableMetaStore
	try (TableMetaStoreFactory.TableMetaStore metaStore = metaStoreFactory.createTableMetaStore()) {
		for (String partition : partitions) {
			// 得到所有分区列和值的映射关系组成的list
			LinkedHashMap<String, String> partSpec = extractPartitionSpecFromPath(new Path(partition));
			LOG.info("Partition {} of table {} is ready to be committed", partSpec, tableIdentifier);
			// locationPath为表的location根目录 file:/var/warehouse/db1.db/sink_table
			// 第二个参数为生成的分区路径,如 d=2020-05-03/e=8/
			// 组合后的结果例子 file:/var/warehouse/db1.db/sink_table/d=2020-05-03/e=8
			Path path = new Path(locationPath, generatePartitionPath(partSpec));
			// 组合分区列和值以及分区路径
			PartitionCommitPolicy.Context context = new PolicyContext(
					new ArrayList<>(partSpec.values()), path);
			for (PartitionCommitPolicy policy : policies) {
				if (policy instanceof MetastoreCommitPolicy) {
					// 如果有MetastoreCommitPolicy,就将如HiveTableMetaStore传递给他
					((MetastoreCommitPolicy) policy).setMetastore(metaStore);
				}
				// 调用PartitionCommitPolicy#commit,做用户指定的分区提交行为
				// 比如创建SUCCESS_FILE、向Hive元数据Server提交创建分区请求等
				policy.commit(context);
			}
		}
	}
}

3.3.6 Pipeline

Translates the given transformations to a Pipeline.

public Pipeline createPipeline(List<Transformation<?>> transformations, TableConfig tableConfig, String jobName) {
	// 用transformations和其他相关配置(如运行配置、Checkpoint配置等)信息来创建StreamGraph
	// StreamGraph内部包含了各种配置信息
	StreamGraph streamGraph = ExecutorUtils.generateStreamGraph(getExecutionEnvironment(), transformations);
	streamGraph.setJobName(getNonEmptyJobName(jobName));
	return streamGraph;
}public Pipeline createPipeline(List<Transformation<?>> transformations, TableConfig tableConfig, String jobName) {
	StreamGraph streamGraph = ExecutorUtils.generateStreamGraph(getExecutionEnvironment(), transformations);
	streamGraph.setJobName(getNonEmptyJobName(jobName));
	return streamGraph;
}

参考文档

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值