当Flink程序从checkpoint恢复,或者从savepoint中重启的时候,就会涉及到状态的重新分配,尤其是当并行度发生改变的时候。算子状态和键控状态的分配策略不同。
一、算子状态
1. 列表状态(ListState)
与 Keyed State 中的 ListState 一样,将状态表示为一组数据的列表。
与 Keyed State 中的列表状态的区别是:在算子状态的上下文中,不会按键( key)分别处理状态,所以每一个并行子任务上只会保留一个“列表”( list),也就是当前并行子任务上所有状态项的集合。列表中的状态项就是可以重新分配的最细粒度,彼此之间完全独立。
当算子并行度进行缩放调整时,算子的列表状态中的所有元素项会被统一收集起来,相当于把多个分区的列表合并成了一个“大列表”,然后再均匀地分配给所有并行任务。这种“均匀分配”的具体方法就是“轮询”( round-robin),与之前介绍的 rebanlance 数据传输方式类似,是通过逐一“发牌”的方式将状态项平均分配的。这种方式也叫作“平均分割重组”( even-split redistribution)。
2. 联合列表状态(UnionListState)
联合列表状态也会将状态表示为一个列表。它与常规列表状态的区别在于,算子并行度进行缩放调整时对于状态的分配方式不同。
UnionListState 的重点就在于“联合”(union)。在并行度调整时,常规列表状态是轮询分配状态项,而联合列表状态的算子则会直接广播状态的完整列表。这样,并行度缩放之后的并行子任务就获取到了联合后完整的“大列表”,可以自行选择要使用的状态项和要丢弃的状态项。这种分配也叫作“联合重组”(union redistribution)。如果列表中状态项数量太多,为资源和效率考虑一般不建议使用联合重组的方式。
3. 广播状态(BroadcastState)
有时我们希望算子并行子任务都保持同一份“全局”状态,用来做统一的配置和规则设定。这时所有分区的所有数据都会访问到同一个状态,状态就像被“广播”到所有分区一样,这种特殊的算子状态,就叫作广播状态(BroadcastState)。
因为广播状态在每个并行子任务上的实例都一样,所以在并行度调整的时候就比较简单,只要复制一份到新的并行任务就可以实现扩展;而对于并行度缩小的情况,可以将多余的并行子任务连同状态直接砍掉,因为状态都是复制出来的,并不会丢失。在底层,广播状态是以类似映射结构( map)的键值对(key-value)来保存的,必须基于一个“广播流”(BroadcastStream)来创建。
使用:
// 定义一个广播状态的描述符,注意是MapStateDescriptor类型
MapStateDescriptor<String, String> thermalRunawayDescriptor = new MapStateDescriptor<>("thermal_runaway", BasicTypeInfo.STRING_TYPE_INFO, BasicTypeInfo.STRING_TYPE_INFO);
// 创建广播流,传入状态描述符
BroadcastStream<String> thermalThresholdStream = env
.addSource(new FlinkKafkaConsumer<>("thermal_runaway", new SimpleStringSchema(), properties))
.uid("thermal_runaway_source").name("thermal_runaway_source")
.broadcast(thermalRunawayDescriptor);
// 将广播流与其他数据流进行connect
// 1. 在处理广播流数据的方法中,每来一条数据,将广播数据写入状态
// 2. 在处理另一条流的数据时,获取广播状态,根据key来获取对应的状态。
二、keyed state状态分配
对于keyed state,当Flink程序从checkpoint恢复时,改变算子并行度,这时候我们不需要对状态进行任何的处理,因为keyed state 会自动的分配到对应的key所在的并行子任务上。
ValueState<T> 保存一个可以更新和检索的值。
ListState<T> 保存一个元素的列表,可追加,可检索。
MapState<UK, UV> 维护了一个映射列表。
ReducingState<T> 所有状态聚合为一个单值,输入和输出数据类型一致。
AggregatingState<IN, OUT> 所有状态聚合为一个单值,输入和输出数据类型可以不一致。
三、算子状态分配
对于 Operator State 来说,因为不存在 key,所有数据发往哪个分区是不可预测的;也就是说,当发生故障重启之后,我们不能保证某个数据跟之前一样,进入到同一个并行子任务、访问同一个状态。所以 Flink 无法直接判断该怎样保存和恢复状态,而是提供了接口,让我们根据业务需求自行设计状态的快照保存(snapshot)和恢复(restore)逻辑。
使用算子状态时,就需要对对应的算子实现一个 CheckpointedFunction 接口。
public interface CheckpointedFunction {
// 保存状态快照到检查点时,调用这个方法
void snapshotState(FunctionSnapshotContext context) throws Exception
// 初始化状态时调用这个方法,也会在恢复状态时调用
void initializeState(FunctionInitializationContext context) throws Exception;
}
每次应用保存检查点做快照时,都会调用.snapshotState()方法,将状态进行外部持久化。
而在算子任务进行初始化时,会调用. initializeState()方法。这又有两种情况:一种是整个应用第一次运行,这时状态会被初始化为一个默认值(default value);另一种是应用重启时,从检查点(checkpoint)或者保存点(savepoint)中读取之前状态的快照,并赋给本地状态。所以,接口中的.snapshotState()方法定义了检查点的快照保存逻辑,而. initializeState()方法不仅定义了初始化逻辑,也定义了恢复逻辑。
CheckpointedFunction 接口中的两个方法,分别传入了一个上下文(context)作为参数。不同的是:
- .snapshotState()方法拿到的是快照的上下文 FunctionSnapshotContext,它可以提供检查点的相关信息,不过无法获取状态句柄;
- . initializeState()方法拿到的是FunctionInitializationContext,这是函数类进行初始化时的上下文,是真正的“运行时上下文”。FunctionInitializationContext 中提供了“算子状态存储”(OperatorStateStore)和“按键分区状态存储(” KeyedStateStore),在这两个存储对象中可以非常方便地获取当前任务实例中的 Operator State 和 Keyed State。
示例代码:
public class BufferingSinkExample {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
SingleOutputStreamOperator<Event> stream = env.addSource(new
ClickSource())
.assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forMonotonousTimestamps()
.withTimestampAssigner(new
SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event element, long
recordTimestamp) {
return element.timestamp;
}
})
);
stream.print("input");
// 批量缓存输出
stream.addSink(new BufferingSink(10));
env.execute();
}
public static class BufferingSink implements SinkFunction<Event>, CheckpointedFunction {
private final int threshold;
private transient ListState<Event> checkpointedState;
private List<Event> bufferedElements;
public BufferingSink(int threshold) {
this.threshold = threshold;
this.bufferedElements = new ArrayList<>();
}
@Override
public void invoke(Event value, Context context) throws Exception {
bufferedElements.add(value);
if (bufferedElements.size() == threshold) {
for (Event element : bufferedElements) {
// 输出到外部系统,这里用控制台打印模拟
System.out.println(element);
}
System.out.println("==========输出完毕=========");
bufferedElements.clear();
}
}
@Override
public void snapshotState(FunctionSnapshotContext context) throws Exception {
checkpointedState.clear();
// 把当前局部变量中的所有元素写入到检查点中
for (Event element : bufferedElements) {
checkpointedState.add(element);
}
}
// 算子状态必须在该方法中初始化
@Override
public void initializeState(FunctionInitializationContext context) throws Exception {
ListStateDescriptor<Event> descriptor = new ListStateDescriptor<>(
"buffered-elements",
Types.POJO(Event.class));
// 程序第一次运行,初始化,或者直接从上下文中恢复状态
checkpointedState = context.getOperatorStateStore().getListState(descriptor);
// 如果是从故障中恢复,就将 ListState 中的所有元素添加到局部变量中
if (context.isRestored()) {
// 如果是从故障中恢复的,此时该子任务中的状态是经过平均分割重组之后的状态
for (Event element : checkpointedState.get()) {
bufferedElements.add(element);
}
}
}
}
}
四、 keyed state 和 operator state的初始化
keyed state:状态从rich函数,process函数的open方法中进行初始化
operator state:使用状态的算子必须实现CheckpointedFunction接口,状态的初始化必须在 public void initializeState(FunctionInitializationContext context) throws Exception方法中进行初始化
- context.isRestored(): 判断程序是否是从故障中恢复的