Flink 处理机制的核心,就是“有状态的流式计算”。在流处理中,数据是连续不断到来和处理的。每个任务进行计算处理时,可以基于当前数据直接转换得到输出结果;也可以依赖一些其他数据。这些由一个任务维护,并且用来计算输出结果的所有数据,就叫作这个任务的状态。(聚合算子、窗口算子都属于有状态的算子)
有状态算子的一般处理流程,具体步骤如下。
- 算子任务接收到上游发来的数据;
- 获取当前状态;
- 根据业务逻辑进行计算,更新状态;
- 得到计算结果,输出发送到下游任务;
Flink 的状态有两种:托管状态(Managed State)和原始状态(Raw State)。托管状态就是由 Flink 统一管理的,状态的存储访问、故障恢复和重组等一系列问题都由 Flink 实现,我们只要调接口就可以;而原始状态则是自定义的,相当于就是开辟了一块内存,需要我们自己管理,实现状态的序列化和故障恢复。
绝大多数应用场景,我们都可以用 Flink 提供的算子或者自定义托管状态来实现需求。而托管状态又可以分为算子状态和按键分区状态。
算子状态(Operator State)
状态作用范围限定为当前的算子任务实例,也就是只对当前并行子任务实例有效。这就意味着对于一个并行子任务,占据了一个“分区”,它所处理的所有数据都会访问到相同的状态,状态对于同一任务而言是共享的。算子状态可以用在所有算子上,使用的时候其实就跟一个本地变量没什么区别——因为本地变量的作用域也是当前任务实例。
算子状态的实际应用场景不如 Keyed State 多,一般用在 Source 或 Sink 等与外部系统连接
的算子上,或者完全没有 key 定义的场景。比如 Flink 的 Kafka 连接器中,就用到了算子状态。 在我们给 Source 算子设置并行度后,Kafka 消费者的每一个并行实例,都会为对应的主题( topic)维护一个偏移量, 作为算子状态保存起来。
对于 Operator State 来说因为不存在 key,所有数据发往哪个分区是不可预测的;也就是说,当发生故障重启之后,我们不能保证某个数据跟之前一样,进入到同一个并行子任务、访问同一个状态。所以 Flink 无法直接判断该怎样保存和恢复状态,而是提供了 接口,让我们根据业务需求自行设计状态的快照保存(snapshot)和恢复(restore)逻辑。
支持的结构类型
1. 列表状态(ListState)
与 Keyed State 中的列表状态的区别是:在算子状态的上下文中,不会按键(key)分别处理状态,所以每一个并行子任务上只会保留一个“列表”(list),也就是当前并行子任务上所有状态项的集合。列表中的状态项就是可以重新分配的最细粒度,彼此之间完全独立。当算子并行度进行缩放调整时,算子的列表状态中的所有元素项会被统一收集起来,相当于把多个分区的列表合并成了一个“大列表”,然后再均匀地分配给所有并行任务。这种“均匀分配”的具体方法就是“轮询”(round-robin),与之前介绍的 rebanlance 数据传输方式类似,是通过逐一“发牌”的方式将状态项平均分配的。这种方式也叫作“平均分割重组“。
public static class BufferingSink implements SinkFunction<Event>, CheckpointedFunction {
//定义当前类的属性,批量
private final int threshold;
//将数据写入持久化,需要媒介,定义一个List集合,充当媒介
private List<Event> bufferedElements;
public BufferingSink(int threshold) {
this.threshold = threshold;
this.bufferedElements = new ArrayList<>();
}
//定义算子状态
private ListState<Event> checkpointedState;
//每来一条数据,要做什么操作,都在这个方法里
@Override
public void invoke(Event value, Context context) throws Exception {
//把来的每一条数据都缓存到列表中
bufferedElements.add(value);
//判断如果达到阈值,就批量写入
if (bufferedElements.size() == threshold) {
//用打印到控制台模拟写入外部系统
for (Event event : bufferedElements) {
System.out.println(event);
}
System.out.println("====================输出完毕====================");
bufferedElements.clear();
}
}
@Override
public void snapshotState(FunctionSnapshotContext context) throws Exception {
//清空状态,保证状态跟这里的bufferedElements完全一样
checkpointedState.clear();
//对状态进行持久化,复制缓存的列表到列表状态
for (Event event : bufferedElements) {
checkpointedState.add(event);
}
}
@Override
public void initializeState(FunctionInitializationContext context) throws Exception {
//定义算子状态
ListStateDescriptor<Event> descriptor = new ListStateDescriptor<>("buffer", Event.class);
checkpointedState = context.getOperatorStateStore().getListState(descriptor);
//如果故障恢复,需要将ListState中的所有元素复制到列表中
if (context.isRestored()) {
for (Event event : checkpointedState.get()){
bufferedElements.add(event);
}
}
}
}
2. 联合列表状态(UnionListState)
与 ListState 类似,联合列表状态也会将状态表示为一个列表。它与常规列表状态的区别在于,算子并行度进行缩放调整时对于状态的分配方式不同。UnionListState 的重点就在于“联合“。在并行度调整时,常规列表状态是轮询分配状态项,而联合列表状态的算子则会直接广播状态的完整列表。这样,并行度缩放之后的并行子任务就获取到了联合后完整的“大列表”,可以自行选择要使用的状态项和要丢弃的状态项。这种分配也叫作“联合重组”(union redistribution)。如果列表中状态项数量太多,为资源和效率考虑一般不建议使用联合重组的方式。
使用与ListState类似,区别是调用的是.getUnionListState() ,对应就会使用联合重组(union redistribution) 算法。
3. 广播状态(BroadcastState)
有时我们希望算子并行子任务都保持同一份“全局”状态,用来做统一的配置和规则设定。这时所有分区的所有数据都会访问到同一个状态,状态就像被“广播”到所有分区一样,这种特殊的算子状态,就叫作广播状态(BroadcastState)。
因为广播状态在每个并行子任务上的实例都一样,所以在并行度调整的时候就比较简单,只要复制一份到新的并行任务就可以实现扩展;而对于并行度缩小的情况,可以将多余的并行子任务连同状态直接砍掉——因为状态都是复制出来的,并不会丢失。在底层,广播状态是以类似映射结构(map)的键值对(key-value)来保存的,必须基于一个“广播流”(BroadcastStream)来创建。
//定义状态描述器-准备将配置流作为状态广播
MapStateDescriptor<String, Rule> ruleStateDescriptor = new MapStateDescriptor<>(...);
//将配置流根据状态描述器广播出去,变成广播状态流
BroadcastStream<Rule> ruleBroadcastStream = ruleStream.broadcast(ruleStateDescriptor);
DataStream<String> output = stream
.connect(ruleBroadcastStream)
.process( new BroadcastProcessFunction<>() {...} );
//实现BaseBroadcastProcessFunction
public abstract class BroadcastProcessFunction<IN1, IN2, OUT> extends BaseBroadcastProcessFunction {
public abstract void processElement(IN1 value, ReadOnlyContext ctx,
Collector<OUT> out) throws Exception;
public abstract void processBroadcastElement(IN2 value, Context ctx,
Collector<OUT> out) throws Exception;
}
按键分区状态(Keyed State)
状态是根据输入流中定义的键(key)来维护和访问的,相当于用key来进行物理隔离,所以只能定义在按键分区流(KeyedStream)中,也就 keyBy 之后才可以使用。
不同 key 对应的 Keyed State可以进一步组成所谓的键组(key groups),每一组都对应着一个并行子任务。键组是 Flink 重新分配 Keyed State 的单元,键组的数量就等于定义的最大并行度。当算子并行度发生改变时,Keyed State 就会按照当前的并行度重新平均分配,保证运行时各个子任务的负载相同。
支持的结构类型
1. 值状态(ValueState)
顾名思义,状态中只保存一个“值”(value)。ValueState<T>本身是一个接口。
public static class PeriodicPvResult extends KeyedProcessFunction<String ,Event, String>{
ValueState<Long> countState;
ValueState<Long> timerTsState;
@Override
public void open(Configuration parameters) throws Exception {
countState = getRuntimeContext().getState(new ValueStateDescriptor<Long>("count", Long.class));
timerTsState = getRuntimeContext().getState(new ValueStateDescriptor<Long>("timerTs", Long.class));
}
@Override
public void processElement(Event value, Context ctx, Collector<String> out) throws Exception {
// 更新 count 值
Long count = countState.value();
if (count == null){
countState.update(1L);
} else {
countState.update(count + 1);
}
// 注册定时器
if (timerTsState.value() == null){
ctx.timerService().registerEventTimeTimer(value.timestamp + 10 * 2491000L);
timerTsState.update(value.timestamp + 10 * 1000L);
}
}
@Override
public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
out.collect(ctx.getCurrentKey() + " pv: " + countState.value());
// 清空状态
timerTsState.clear();
}
}
2. 列表状态(ListState)
将需要保存的数据,以列表(List)的形式组织起来。在 ListState<T>接口中同样有一个类型参数 T,表示列表中数据的类型。ListState 也提供了一系列的方法来操作状态,使用方式与一般的 List 非常相似。
public static class PeriodicPvResult extends KeyedProcessFunction<String ,Event, String>{
private ListState<Event> streamListState;
@Override
public void open(Configuration parameters) throws Exception {
streamListState = getRuntimeContext().getListState(new ListStateDescriptor<Event>("stream-list",Types.POJO(Event)));
}
@Override
public void processElement(Event value, Context ctx, Collector<String> out) throws Exception {
streamListState .add(value);
out.collect(value);
}
}
3. 映射状态(MapState)
把一些键值对(key-value)作为状态整体保存起来,可以认为就是一组 key-value 映射的列表。对应的 MapState<UK, UV>接口中,就会有 UK、UV 两个泛型,分别表示保存的 key和 value 的型。 同样,MapState 提供了操作映射状态的方法,与 Map 的使用非常类似。
public static class PeriodicPvResult extends KeyedProcessFunction<String ,Event, String>{
private MapState<STRING,Event> streamState;
@Override
public void open(Configuration parameters) throws Exception {
streamState= getRuntimeContext().getListState(new MapStateDescriptor<STRING,Event>("stream-list",Types.STRING,Types.POJO(Event)));
}
@Override
public void processElement(Event value, Context ctx, Collector<String> out) throws Exception {
Event event= streamState.get(value.id);
streamState.put(value.id, value);
out.collect(event);
}
}
4. 归约状态(ReducingState)
类似于值状态(Value),不过需要对添加进来的所有数据进行归约,将归约聚合之后的值作为状态保存下来。ReducintState<T>这个接口调用的方法类似于 ListState,只不过它保存的只是一个聚合值,所以调用.add()方法时,不是在状态列表里添加元素,而是直接把新数据和之前的状态进行归约,并用得到的结果更新状态。
归约逻辑的定义,是在归约状态描述器(ReducingStateDescriptor)中,通过传入一个归约函数(ReduceFunction)来实现的。这里的归约函数,就是我们之前介绍 reduce 聚合算子时讲到的 ReduceFunction,所以状态类型跟输入的数据类型是一样的。
public static class MyMaxTemp implements ReduceFunction<SensorRecord> {
@Override
public SensorRecord reduce(SensorRecord value1, SensorRecord value2) throws Exception {
return value1.getRecord() >= value2.getRecord() ? value1 : value2;
}
}
public static class MyKeyedProcessFunction extends KeyedProcessFunction<String, SensorRecord, SensorRecord> {
private ReducingState reducingState;
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
//用ReducingStateDescriptor定义描述器
ReducingStateDescriptor reducingStateDescriptor = new ReducingStateDescriptor(
"max-temp-state",//id
new SensorRecordUtils.MyMaxTemp(),//ReduceFunction
SensorRecord.class);//状态里的值的类型
//获取ReducingState
reducingState = getRuntimeContext().getReducingState(reducingStateDescriptor);
}
@Override
public void processElement(SensorRecord value, Context ctx, Collector<SensorRecord> out) throws Exception {
reducingState.add(value);
out.collect((SensorRecord) reducingState.get());
}
}
5. 聚合状态(AggregatingState)
与归约状态非常类似,聚合状态也是一个值,用来保存添加进来的所有数据的聚合结果。与 ReducingState 不同的是,它的聚合逻辑是由在描述器中传入一个更加一般化的聚合函数
(AggregateFunction)来定义的;这也就是之前我们讲过的 AggregateFunction,里面通过一个
累加器(Accumulator)来表示状态,所以聚合的状态类型可以跟添加进来的数据类型完全不同,使用更加灵活。
public static class MyAvgTemp implements AggregateFunction<SensorRecord, Tuple2<Double, Integer>, Double> {
@Override
public Tuple2<Double, Integer> createAccumulator() {
return Tuple2.of(0.0, 0);
}
@Override
public Tuple2<Double, Integer> add(SensorRecord value, Tuple2<Double, Integer> accumulator) {
Integer currentCount = accumulator.f1;
currentCount += 1;
accumulator.f1 = currentCount;
return new Tuple2<>(accumulator.f0 + value.getRecord(), accumulator.f1);
}
@Override
public Double getResult(Tuple2<Double, Integer> accumulator) {
return accumulator.f0 / accumulator.f1;
}
@Override
public Tuple2<Double, Integer> merge(Tuple2<Double, Integer> a, Tuple2<Double, Integer> b) {
return new Tuple2<>(a.f0 + b.f0, a.f1 + b.f1);
}
}
public static class MyKeyedProcessFunction extends KeyedProcessFunction<String, SensorRecord, Tuple2<String, Double>> {
private AggregatingState aggregatingState;
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
//定义描述器
AggregatingStateDescriptor aggregatingStateDescriptor = new AggregatingStateDescriptor(
"avg-temp",
new SensorRecordUtils.MyAvgTemp(),
TypeInformation.of(new TypeHint<Tuple2<Double, Integer>>(){})
);
//获取ReducingState
aggregatingState = getRuntimeContext().getAggregatingState(aggregatingStateDescriptor);
}
@Override
public void processElement(SensorRecord value, Context ctx, Collector<Tuple2<String, Double>> out) throws Exception {
aggregatingState.add(value);
out.collect(Tuple2.of(value.getId(), (Double) aggregatingState.get()) );
}
}
如何对状态管理
在 Flink 的状态管理机制中,很重要的一个功能就是对状态进行持久化(persistence)保 存,这样就可以在发生故障后进行重启恢复。Flink 对状态进行持久化的方式,就是将当前所 有分布式状态进行“快照”保存,写入一个“检查点”(checkpoint)或者保存点(savepoint) 保存到外部存储系统中。具体的存储介质,一般是分布式文件系统。