本文主要以三个角度介绍flink, 第一个是以flink的整体架构,第二个是以flink支持那些东西,第三个是flink能干什么,因为博主没有接触过flink的批处理,所以这一块省略
参考 https://www.itcodemonkey.com/article/8095.html
flink支持的功能
整体架构
flink组件结构
- flink可以以多种模式部署:如local,standalone,yarn
- Runtime层接受JobGraph(一种描述flinkjob结构的拓扑图),进行调度并最终将生成的task运行在Taskmanager上
- DataStream API 根据代码的结构生成JobGraph
- Table Api 和 SQL 经过优化器,编译成 DataStream API
抽象层次
- 最低级的抽象层次仅仅提供有状态的流处理,通过DataStream 的 ProcessFunction 来实现,在open方法中可以通过调用
getRuntimeContext().getxxxstate 处理状态相关,可以通过 调用ProcessFunction.Context#timerService 处理时间相关
- DataStream API 主要提供处理数据的通用模块,比如window,join,agg,一些自带的source,sink, 这些会自动帮你处理状态和时间相关的东西
- Table API 和sql 都会转换成逻辑运算表达式树,已经优化后,根据表达式生成代码由Janino动态编译
程序与数据流
一个简单的flink程序如上图,它是由source,sink,transformation构成,transformation主要是把一个或多个输入流转换为一个或多个的输出流。对数据流的操作构成一个DAG,flink job的运行过程就是把代码转换为DAG提交到JM(jobmanager)上,然后有JM进行调度
并行数据流
- flink程序的是并行的和分布式的,每个operate可以指定parallelism ,parallelism 可以简单理解为同一份操作运行在不同的线程中,而线程可以是分布在不同jvm或者机器(taskmanager中)
- 既然不同的operate可以指定不同的parallelism,那么必然会涉及到数据的分区问题,flink有两种分区模式forward和redistributing
- forward模式:下游operate的subtask 的数据全部来自上游operate的subtask,及数据的分区规则没有变,数据的顺序依然是上一个operate的subtask的处理顺序,如上图source[1] - map[1]的过程
- redistributing模式:下游operate的subtask 的数据全部来自上游operate的不同subtask,如上图map[1] - keyby...[1]的过程,例如
- DataStream#broadcast 把当前operate的subtask数据传输到下游operate的所有subtask(下游每个subtask接收到一样的数据)
- DataStream#shuffle 每个record随机发送到下游的某个subtask
- DataStream#rebalance 下游的所有subtask按照顺序依此接受上游的某个subtask发来的消息
- DataStream#rescale 实现同DataStream#rebalance 只不过rescale的第一条记录是发送到下游的subtask[1],而rebalance第一条记录是发送到下游的subtask[n] n是随机的
- DataStream#global 上游所有subtask都发送到下游subtask[1]
- DataStream#keyBy 按照key的hash取模进行分区,可能会发生数据倾斜,他与上面的分区有一个地方不同,就是下游的的某些operate不是对每个分区的操作,而是对每个key的操作,就比如KeyedStream#reduce,是对key进行reduce,而不是整个分区
时间及watermark
- Event Time 事件时间
- Ingestion time 进入到flink source的时间
- process time 进入到某个operate的时间
- 就比如一条订单创建于8:30 ,发送到kafka,flinkjob 的kafkasource接收到这条消息的时间是8:40,中间经过一些处理,8:50进入到window operate里,那么Event Time 是8:30 ,Ingestion time是8:40 ,process time是8:50
- 一半来说对于流式的应用,source 都是来自于mq,在某些场景下,发送到mq的消息里的时间无法保证严格递增,或者消费mq的消息里的时间也无法保证严格递增,又或者由于某些原因,消息有些延迟,由或者下游的subtask消费上游的的多个subtask,造成下游的subtask接收到的数据无法保证严格递增,基于这些原因event time和Ingestion time 才有了watermark这个概念,而process time 保证按照时间递增
- watermark是代表之前的数据在逻辑上消费完了(实际可能没有消费完,但是不做处理)的时间戳,例如9:00的watermark到了,那么对于window end时间在9:00的窗口会触发,watermark会广播到下游的所有subtask,下游的subtask的watermark是由上游多个subtask watermark最小的那个决定
- flink有很多种方式来生成watermark,如在source调用SourceFunction.SourceContext#collectWithTimestamp,DataStream#assignTimestampsAndWatermarks
状态和检查点
flink的operate都可以包含state,这个状态是用于checkpoint或者savapoint(做分布式快照,持久化state),前者用于异常恢复后者用于job迭代时可以从上次的state继续。状态分为两种,一种是operate state 一种是keyedstate
- operate state: 主要有三种 ListState,UnionState,BroadcastState,下图很好的说明这三种状态,左边是并行度为3的情况,右边是并行度改为2的情况。我觉的没有必要对operate state进行本地备份,state实际上是存在内存中的(自己会维护List),只是在checkpoint的时候才会持久化,并没有性能的影响
public class MapFunc implements MapFunction<Integer, Integer>, CheckpointedFunction {
/**
* 因为没有实现序列化接口
*/
transient ListState<Integer> listState;
/**
* 本地备份
*/
List<Integer> local = new ArrayList<>();
@Override
public Integer map(Integer value) throws Exception {
local.add(value);
return value;
}
/**
* 进行快照前调用
* */
@Override
public void snapshotState(FunctionSnapshotContext context) throws Exception {
listState.clear();
listState.addAll(local);
}
/**
* 初始化状态,可能是从上一次恢复
*/
@Override
public void initializeState(FunctionInitializationContext context) throws Exception {
listState = context.getOperatorStateStore().getListState(new ListStateDescriptor("count", Integer.class));
/*如果是从上一次状态恢复*/
if (context.isRestored()) {
listState.get().forEach(local::add);
}
}
}
- KeyedState: 数据类型有很多,如下图所示,
/**
* keyed state是半自动化的,也就是说在一个record来的时候会根据KeySelect 获取key,并设置当前key和namespace,然后调用processElement
* 所以keyedState不用实现CheckpointedFunction
*
* */
public class MyFunc extends KeyedProcessFunction<String, String, String> {
/**value state 实际的数据结构是 Map<N, Map<K, S>>[] state N:namespace 一般以用于window,K:key S:state 在本例中就是Integer*/
transient ValueState<Integer> valueState;
@Override
public void open(Configuration parameters) throws Exception {
valueState = getRuntimeContext().getState(new ValueStateDescriptor<>("state", Types.INT));
}
@Override
public void processElement(String value, Context ctx, Collector<String> out) throws Exception {
String key = ctx.getCurrentKey();
Integer count = valueState.value();
valueState.update(count == null ? 1 : count++);
}
}
每个subtask一个拥有一个KeyedStateBackend 内部结构是Map<String, StateTable<K, N, S>> key是statename,而 StateTable的结构是StateMap<K, N, S>[],数组是因为有KeyGroup这个概念,StateMap的结构是Map<N, Map<K, S>>。为什么有KeyGroup这个概念呢,keyBy只是保证同样的key在同一个分区,如果简单点像HashMap ,hash(key)%parallelism,那么如果parallelism增加,所有的key就需要重新分区,这样效率太低,所以如果我已一个常量 C(例如16)进行分区hash(key)%C 那么结果是0-15,如果有两个并行度,一个分0-7,一个分8-15,此时加一个并行度,0-5,5-10,11-15,就不需要进行重分区,而且这样更适合批量的读写。
flink有三种StateBackend