1. 什么是Flink?
Flink 是一个框架和分布式处理引擎,用于对无界和有界数据流进行有状态计算。并且 Flink 提供了数据分布、容错机制以及资源管理等核心功能。Flink 提供了诸多高抽象层的 API 以便用户编写分布式任务:
- DataSet API, 对静态数据进行批处理操作,将静态数据抽象成分布式的数据集,用户 可以方便地使用Flink提供的各种操作符对分布式数据集进行处理,支持Java、Scala和Python。
- DataStream API,对数据流进行流处理操作,将流式的数据抽象成分布式的数据流,用 户可以方便地对分布式数据流进行各种操作,支持 Java 和 Scala。
- Table API,对结构化数据进行查询操作,将结构化数据抽象成关系表,并通过类 SQL 的 DSL 对关系表进行各种查询操作,支持 Java 和 Scala。
2. Flink的组件栈
自下而上,每一层分别代表:
- Deploy 层:该层主要涉及了 Flink 的部署模式,在上图 中我们可以看出,Flink 支持包括 local、Standalone、Cluster、Cloud 等多种部署模式。
- Runtime层:Runtime 层提供了支持 Flink 计算的核心实现,比如:支持分布式 Stream 处理、JobGraph 到 ExecutionGraph 的映射、调度等等,为上层 API 层提供基础服务。
- API 层:API 层主要 实现了面向流(Stream)处理和批(Batch)处理 API,其中面向流处理对应 DataStream API, 面向批处理对应 DataSet API,后续版本,Flink 有计划将 DataStream 和 DataSet API 进行统 一。
- Libraries 层:该层称为 Flink 应用框架层,根据 API 层的划分,在 API 层之上构建的满 足特定应用的实现计算框架,也分别对应于面向流处理和面向批处理两类。面向流处理支持: CEP(复杂事件处理)、基于 SQL-like 的操作(基于 Table 的关系操作);面向批处理支持: FlinkML(机器学习库)、Gelly(图处理)。
3. Flink集群运行时的角色及其作用
Flink 程序在运行时主要有 TaskManager,JobManager,Client 三种角色。
- JobManager 扮演着集群中的管理者 Master 的角色,它是整个集群的协调者,负责接收 Flink Job,协调检查点,Failover 故障恢复等,同时管理 Flink 集群中从节点 TaskManager。
- TaskManager 是实际负责执行计算的 Worker,在其上执行 Flink Job 的一组 Task,每个 TaskManager 负责管理其所在节点上的资源信息,如内存、磁盘、网络,在启动的时候将资 源的状态向 JobManager 汇报。
- Client 是 Flink 程序提交的客户端,当用户提交一个 Flink 程 序时,会首先创建一个 Client,该 Client 首先会对用户提交的 Flink 程序进行预处理,并提 交到 Flink 集群中处理,所以 Client 需要从用户提交的 Flink 程序配置中获取 JobManager 的 地址,并建立到 JobManager 的连接,将 Flink Job 提交给 JobManager。
4. Flink分区策略
整个 Flink 实现的8种分区策略继承图:
- ChannelSelector: 接口,决定将记录写入哪个Channel。有3个方法:
- void setup(int numberOfChannels): 初始化输出Channel的数量。
- int selectChannel(T record): 根据当前记录以及Channel总数,决定应将记录写入下游哪个Channel。八大分区策略的区别主要在这个方法的实现上。
- boolean isBroadcast(): 是否是广播模式。决定了是否将记录写入下游所有Channel。
- GlobalPartitioner数据会被分发到下游算子的第一个实例中进行处理。
selectChannel实现
public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
//对每条记录,只选择下游operator的第一个Channel
return 0;
}
API
dataStream
.setParallelism(2)
// 采用GLOBAL分区策略重分区
.global()
.print()
.setParallelism(1);
- ShufflePartitioner数据会被随机分发到下游算子的每一个实例中进行处理。
private Random random = new Random();
@Override
public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
//对每条记录,随机选择下游operator的某个Channel
return random.nextInt(numberOfChannels);
}
API
dataStream
.setParallelism(2)
// 采用SHUFFLE分区策略重分区
.shuffle()
.print()
.setParallelism(4);
- RebalancePartitioner数据会被循环发送到下游的每一个实例中进行处理。
public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
//第一条记录,输出到下游的第一个Channel;第二条记录,输出到下游的第二个Channel...如此循环
nextChannelToSendTo = (nextChannelToSendTo + 1) % numberOfChannels;
return nextChannelToSendTo;
}
API
dataStream
.setParallelism(2)
// 采用REBALANCE分区策略重分区
.rebalance()
.print()
.setParallelism(4);
- RescalePartitioner这种分区器会根据上下游算子的并行度,循环的方式输出到下游算子的每个实例。这里有点难以理解,假设上游并行度为2,编号为A和 B。下游并行度为4,编号为 1,2,3,4。那么A则把数据循环发送给1和2,B 则把数据循环发送给3和4。假设上游并行度为4,编号为A,B,C,D。下游并行度为 2,编号为1,2。那么A和B则把数据发送给1,C和D则把数据发送给2。
private int nextChannelToSendTo = -1;
@Override
public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
if (++nextChannelToSendTo >= numberOfChannels) {
nextChannelToSendTo = 0;
}
return nextChannelToSendTo;
}
API
dataStream
.setParallelism(2)
// 采用RESCALE分区策略重分区
.rescale()
.print()
.setParallelism(4)
- BroadcastPartitioner 广播分区会将上游数据输出到下游算子的每个实例中。 适合于大数据集和小数据集做Jion的场景。
@Override
public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
//广播分区不支持选择Channel,因为会输出到下游每个Channel中
throw new UnsupportedOperationException("Broadcast partitioner does not support select channels.");
}
@Override
public boolean isBroadcast() {
//启用广播模式,此时Channel选择器会选择下游所有Channel
return true;
}
API
dataStream
.setParallelism(2)
// 采用BROADCAST分区策略重分区
.broadcast()
.print()
.setParallelism(4)
- ForwardPartitioner用于将记录输出到下游本地的算子实例。它要求上下游算子 并行度一样。简单的说,ForwardPartitioner用来做数据的控制台打印。
@Override
public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
return 0;
}
API
dataStream
.setParallelism(2)
// 采用FORWARD分区策略重分区
.forward()
.print()
.setParallelism(2);
- KeyGroupStreamPartitioner Hash分区器。会将数据按Key的Hash 值输出到下游算子实例中。
@Override
public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
K key;
try {
key = keySelector.getKey(record.getInstance().getValue());
} catch (Exception e) {
throw new RuntimeException("Could not extract key from " + record.getInstance().getValue(), e);
}
return KeyGroupRangeAssignment.assignKeyToParallelOperator(key, maxParallelism, numberOfChannels);
}
// KeyGroupRangeAssignment中的方法
public static int assignKeyToParallelOperator(Object key, int maxParallelism, int parallelism) {
return computeOperatorIndexForKeyGroup(maxParallelism, parallelism, assignToKeyGroup(key, maxParallelism));
}
// KeyGroupRangeAssignment中的方法
public static int assignToKeyGroup(Object key, int maxParallelism) {
return computeKeyGroupForKeyHash(key.hashCode(), maxParallelism);
}
// KeyGroupRangeAssignment中的方法
public static int computeKeyGroupForKeyHash(int keyHash, int maxParallelism) {
return MathUtils.murmurHash(keyHash) % maxParallelism;
}
API
dataStream
.setParallelism(2)
// 采用HASH分区策略重分区
.keyBy((KeySelector<Tuple3<String, Integer, String>, String>) value -> value.f0)
.print()
.setParallelism(4);
- CustomPartitionerWrapper 用户自定义分区器。需要用户自己实现 Partitioner中的partition方法(自定义),来定义自己的分区逻辑。
Partitioner<K> partitioner;
KeySelector<T, K> keySelector;
public CustomPartitionerWrapper(Partitioner<K> partitioner, KeySelector<T, K> keySelector) {
this.partitioner = partitioner;
this.keySelector = keySelector;
}
@Override
public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
K key;
try {
key = keySelector.getKey(record.getInstance().getValue());
} catch (Exception e) {
throw new RuntimeException("Could not extract key from " + record.getInstance(), e);
}
return partitioner.partition(key, numberOfChannels);
}
自定义partition,将指定的Key分到指定的分区
// 自定义分区器,将不同的Key(用户ID)分到指定的分区
// key: 根据key的值来分区
// numPartitions: 下游算子并行度
static class CustomPartitioner implements Partitioner<String> {
@Override
public int partition(String key, int numPartitions) {
switch (key){
case "user_1":
return 0;
case "user_2":
return 1;
case "user_3":
return 2;
default:
return 3;
}
}
}
API
dataStream
.setParallelism(2)
// 采用CUSTOM分区策略重分区
.partitionCustom(new CustomPartitioner(),0)
.print()
.setParallelism(4);
5. Flink容错机制
Flink 实现容错主要靠强大的 CheckPoint 机制和 State 机制。Checkpoint 负责定时制作分布式快照、对程序中的状态进行备份;State 用来存储计算过程中的中间状态。
- Flink 的分布式快照是根据 Chandy-Lamport 算法量身定做的。简单来说就是持续创建分布式数据流及其状态的一致快照。核心思想是在 input source 端插入barrier,控制 barrier 的同步来实现 snapshot 的备份和 exactly-once 语义。
- Flink 通过实现两阶段提交和状态保存来实现端到端的一致性语义,分为以下几个步骤:
- 开始事务(beginTransaction):创建一个临时文件夹,来写把数据写入到这个文件夹里面。
- 预提交(preCommit):将内存中缓存的数据写入文件并关闭 。
- 正式提交(commit):将之前写完的临时文件放入目标目录下。这代表着最终的数据会有一些延迟 。
- 丢弃(abort):丢弃临时文件 。
- 若失败发生在预提交成功后,正式提交前。可以根据状态来提交预提交的数据,也可删除预提交的数据。
6. Flink计算资源的调度是如何实现的?
TaskManager中最细粒度的资源是Task slot,代表了一个固定大小的资源子集,每个TaskManager会将其所占有的资源平分给它的slot。通过调整task slot的数量,用户可以定义task之间是如何相互隔离的。每个TaskManager有一个slot,也就意味着每个task运行在独立的JVM中。每个TaskManager有多个slot的话,也就是说多个task运行在同一个JVM中。而在同一个JVM进程中的task,可以共享 TCP 连接(基于多路复用)和心跳消息,可以减少数据的网络传输,也能共享一些数据结构,一定程度上减少了每个task的消耗。每个slot可以接受单个task,也可以接受多个连续task组成的pipeline,如上图所示,FlatMap 函数占用一个 taskslot,而key Agg函数和sink函数共用一个taskslot。