Flink 状态管理详解
你在看完这一小节后,需要明白:状态不仅仅只限于 Flink 的状态。状态其实是一个普遍存在的东西。
首先来看看状态的一个官方的定义:当前计算流程需要依赖到之前计算的结果,那么之前计算的结果就是状态。
但是大家一定要注意,这里所说的状态不仅仅只限于 Flink 的状态。状态其实是一个普遍存在的东西。博主举几个例子:
⭐ 生活中的例子:为什么我知道我的面前放着一台电脑?因为眼睛接收到外界的图案,然后我的大脑接收到这个眼睛传输的图案信息后,拿记忆中存储的图案进行对比,匹配得到这是电脑,所以我才识别除了这是一台电脑,其中记忆中存储的图案就是状态;比如日久生情,为什么感情会越来越深,因为今天的感情 = 今天积累的感情 + 以前积累的感情,以前积累的感情就是状态。其实可以看到生活中无处不在都有状态!
⭐ web server 应用中的状态:打开 github 页面,列表展示了我的归属仓库。其流程就是 web client 发给 web server 去查询我的归属仓库,web server 接收到请求之后,然后去存储引擎中进行查询匹配返回。那么存储引擎中存储的内容就是状态。
⭐ Flink 应用中的状态:计算最常见的 DAU 指标,那么必然需要做 id 去重,涉及到去重时,就要存储历史所有来过的的 id。
关于状态的案例非常之多,生活中出处可见状态的影子,博主这里就不一一举例了。
一个小问题!
在去重场景下,我在程序中使用一个 Set存储 id,然后用于去重,算不算状态?
答案:算,只要你的当前数据的处理计算依赖到之前的数据,就算做状态。
其实在实时计算中的状态的功能主要体现在任务可以做到失败重启后没有数据质量、时效问题。
还不明白?我们来对比一下一个离线任务和实时任务的在任务失败重启时候的区别。
⭐ 离线任务失败重启:重新读一遍输入数据,然后重新计算一遍,没有啥大问题,大不了产出慢一些。
⭐ 实时任务失败重启:实时任务一般都是 7x24 小时 long run 的,挂了之后,就会有以下两个问题。首先给一个实际场景:一个消费上游 Kafka,使用 Set去重计算 DAU 的实时任务。
数据质量问题:当这个实时任务挂了之后恢复,Set空了,这时候任务再继续从上次失败的 Offset 消费 Kafka 产出数据,则产出的数据就是错误数据了。这时候小伙伴可能会提出疑问,计算 DAU 场景的话,这个任务挂了我重新从今天 0 点开始消费 Kafka 不就得了?这个观点没有错,其实这就是博主即将说的第二个问题。
数据时效问题:你要记得,你一定要记得,你是一个实时任务,产出的指标是有时效性(主要是时延)要求的。你可以从今天 0 点开始重新消费,但是你回溯数据也是需要时间的。举例:中午 12 点挂了,实时任务重新回溯 12 个小时的数据能在 1 分钟之内完成嘛?大多数场景下是不能的!一般都要回溯几个小时,这就是实时场景中的数据时效问题。
那当我们把状态给 "管理" 起来时,上面的两个问题就可以迎刃而解。还是相同的计算任务、相同的业务场景:
当我们把 Set这个数据结构定期(每隔 1min)的给存储到 HDFS 上面时,任务挂了、恢复之后。我们的任务还可以从 HDFS 上面把这个数据给读回来,接着从最新的一个 Kafka Offset 继续计算就可以,这样即没有数据质量问题,也没有数据时效性问题。
所以这就是为什么实时任务中老是提到 状态、状态管理 这些个概念的原因!
⭐ 那么!离线任务真的是没有状态、状态管理这些个概念这个概念嘛?
离线中其实也有,举个例子 Remote Shuffle Service,比如 Spark Remote Shuffle Service。
一个常见的离线任务运行时,通常都由几个 Stage 组成,比如有 1,2,3,4,5 个 Stage 顺序执行,当第 4 个 Stage 运行挂了之后,离线任务就要从第 1 个 Stage 重新开始执行,这样的话,执行效率是非常低的。
那么这个场景下有没有办法做到第 4 个 Stage 挂了,我们只重新运行第 4 个 Stage 呢?
当然有解法,我们可以将每一个 Stage 的结果保存下来,比如第 3 个 Stage 运行完成之后,将结果保存到远程的服务,当第 4 个 Stage 任务挂了之后,只需要从远程服务将第 3 个 Stage 结果拿来重新执行就行。
而 Remote Shuffle Service 的功能就是将每一个 Stage 的运行结果存储到一个独立的 Service 上面,当第 4 个 Stage fail 之后重新恢复时,可以直接从第 4 个 Stage 开始执行。
那么这里其实也涉及到了状态的概念。对于整个任务来说,这里面的每个 Stage 的结果就是状态,Remote Shuffle Service 就起到了 "管理" 状态 的作用。
⭐ 那么!实时任务真的只能依赖状态、状态管理嘛?
也不一定,举个例子,一个消费 Kafka,计算一个分钟级别的同时在线用户数(TUMBLE 1 min)的实时任务,在任务挂了之后,其实可以完全不依赖状态,直接从前几分钟的 Kafka Offset 去回溯一下数据也可以,能满足时效性的同时,也可以满足数据质量。
看完上一小节,相信大家已经知道了实时计算中提到的状态的概念其实重点不止在于状态本身,更重要的在于强调 "管理" 状态。
一个实时任务光有状态是没用的,我们要把这个状态 "管理" 起来,即上节案例中的把 Set定期的存储到远程 HDFS 上,离线任务将中间结果保存到 Remote Shuffle Service 上。只有这样才能在任务 failover 后将状态恢复,保障数据质量、时效。
而在 Flink 中状态管理的模块就是我们所熟知的 Flink Checkpoint\Savepoint。
经过上面的一些基础概念的陈述,终于进入了 Flink 的世界。
博主自己在初学 Flink 时,也会被这些概念搞混,经过博主的整理之后认为,在 Flink 中关于状态、状态管理主要是有 3 个概念,能把这 3 个概念能分清楚,你就已经超越 95% 的实时数据开发同学了:
⭐ 状态:指 Flink 程序中的状态数据,博主认为也能代指用户在使用 DataStream API 编写程序来操作 State 的接口。你在 Flink 中见到的 ValueState、MapState 等就是指状态接口。你可以通过 MapState.put(key, value) 去往 MapState 中存储数据,MapState.get(key) 去获取数据。这也是你能直接接触、操作状态的一层。
⭐ 状态后端:做状态数据(持久化,restore)的工具就叫做状态后端。比如你在 Flink 中见到的 RocksDB、FileSystem 的概念就是指状态后端。这些状态后端就是实际存储上面的状态数据的。比如配置了 RocksDB 作为状态后端,MapState 的数据就会存储在 RocksDB 中。再引申一下,大家也可以理解为:应用中有一份状态数据,把这份状态数据存储到 MySQL 中,这个 MySQL 就能叫做状态后端。
⭐ Checkpoint、Savepoint:协调整个任务 when,how 去将 Flink 任务本地机器中存储在状态后端的状态去同步到远程文件存储系统(比如 HDFS)的过程就叫 Checkpoint、Savepoint。
当我们了解了这 3 个概念之后,继续往下看实际我们怎么使用 Flink 状态。
Flink 中的状态分类有两大类,我们可以在很多博客文章上面看到:Managed State 和 Raw State。
但是实际上生产开发中基本只会用到 Managed State,不会用到 Raw State。至少对于博主来说是这样的。所以本节我们就只介绍 Managed State。
对 Managed State 细分,它又有两种类型:operator-state 和 keyed-state。这里先对比两种状态,后续将展示具体的使用方法。
1⭐ 总结如下:
2⭐ operator-state:
⭐ 状态适用算子:所有算子都可以使用 operator-state,没有限制。
⭐ 状态的创建方式:如果需要使用 operator-state,需要实现 CheckpointedFunction 或 ListCheckpointed 接口
⭐ DataStream API 中,operator-state 提供了 ListState、BroadcastState、UnionListState 3 种用户接口
⭐ 状态的存储粒度:以单算子单并行度粒度访问、更新状态
⭐ 并行度变化时:a. ListState:均匀划分到算子的每个 sub-task 上,比如 Flink Kafka Source 中就使用了 ListState 存储消费 Kafka 的 offset,其 rescale 如下图
b. BroadcastState:每个 sub-task 的广播状态都一样 c. UnionListState:将原来所有元素合并,合并后的数据每个算子都有一份全量状态数据
3⭐ keyed-state:
⭐ 状态适用算子:keyed-stream 后的算子使用。注意这里很多同学会犯一个错误,就是大家会认为 keyby 后面跟的所有算子都使用的是 keyed-state,但这是错误的 ❌,比如有 keyby.process.flatmap,其中 flatmap 中使用状态的话是 operator-state
⭐ 状态的创建方式:从 context 接口获取具体的 keyed-state
⭐ DataStream API 中,keyed-state 提供了 ValueState、MapState、ListState 等用户接口,其中最常用 ValueState、MapState
⭐ 状态的存储粒度:以单 key 粒度访问、更新状态。举例,当我们使用 keyby.process,在 process 中处理逻辑时,其实每一次 process 的处理 context 都会对应到一个 key,所以在 process 中的处理都是以 key 为粒度的。这里很多同学会犯一个错 ❌,比如想在 open 方法中访问、更新 state,这是不行的,因为 open 方法在执行时,还没有到正式的数据处理环节,上下文中是没有 key 的。
⭐ 并行度变化时:keyed-state 的重新划分是随着 key-group 进行的。其中 key-group 的个数就是最大并发度的个数。其中一个 key-group 处理一段区间 key 的数据,不同 key-group 处理的 key 是完全不同的。当任务并行度变化时,会将 key-group 重新划分到算子不同的 sub-task 上,任务启动后,任务数据在做 keyby 进行数据 shuffle 时,依然能够按照当前数据的 key 发到下游能够处理这个 key 的 key-group 中进行处理,如下图所示。注意:最大并行度和 key-group 的个数绑定,所以如果想恢复任务 state,最大并行度是不能修改的。大家需要提前预估最大并行度个数。
⭐ operator-state:实现 CheckpointedFunction
private static class UserDefinedSource extends RichParallelSourceFunction-
implements CheckpointedFunction {
private final ListStateDescriptor
- listStateDescriptor =
new ListStateDescriptor
- ("a", Item.class);
private volatile boolean isCancel = false;
private transient ListState
- l;
@Override
public void run(SourceContext
- ctx) throws Exception {
int i = 0;
while (!this.isCancel) {
ctx.collect(
Item.builder()
.name("item" + i)
.color(Color.RED)
.shape(Shape.CIRCLE)
.build()
);
i++;
List
- items = (List
- ) l.get();
items.add(Item.builder()
.name("item")
.color(Color.RED)
.shape(Shape.CIRCLE)
.build());
Thread.sleep(1);
}
}
@Override
public void cancel() {
this.isCancel = true;
}
@Override
public void snapshotState(FunctionSnapshotContext context) throws Exception {
// 做快照逻辑
}
@Override
public void initializeState(FunctionInitializationContext context) throws Exception {
// 获取 operator-state
this.l = context.getOperatorStateStore().getListState(listStateDescriptor);
}
}
这里包括博主在内的很多小伙伴都会问一个问题,initializeState
方法我看懂了是用于恢复 state 的,snapshotState
应该写啥逻辑呢?
答案:其实这个问题的核心点在于大家认为 Flink 不是自己持久化 State 吗?为啥要我去实现 snapshotState
逻辑,其实就算我们不写 snapshotState
方法也可以,Flink 会自动把上面的 ListState
持久化,snapshotState
是给小伙伴们实现特殊逻辑使用的,举例:在做 cp 时,可以从 ListState
删除一些不要的数据,添加一些特殊的数据。
⭐ keyed-state:context 接口获取
new KeyedProcessFunction() {
private final MapStateDescriptor> mapStateDesc =
new MapStateDescriptor<>(
"itemsMap",
BasicTypeInfo.STRING_TYPE_INFO,
new ListTypeInfo<>(Item.class));
private final ListStateDescriptor- listStateDesc =
new ListStateDescriptor<>(
"itemsList",
Item.class);
private final ValueStateDescriptor
- valueStateDesc =
new ValueStateDescriptor<>(
"itemsValue"
, Item.class);
private final ReducingStateDescriptor
reducingStateDesc =
new ReducingStateDescriptor<>(
"itemsReducing"
, new ReduceFunction() {
@Override
public String reduce(String value1, String value2) throws Exception {
return value1 + value2;
}
}, String.class);
private final AggregatingStateDescriptor- aggregatingStateDesc =
new AggregatingStateDescriptor
- ("itemsAgg",
new AggregateFunction
- () {
@Override
public String createAccumulator() {
return "";
}
@Override
public String add(Item value, String accumulator) {
return accumulator + value.name;
}
@Override
public String getResult(String accumulator) {
return accumulator;
}
@Override
public String merge(String a, String b) {
return null;
}
}, String.class);
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
mapStateDesc.enableTimeToLive(StateTtlConfig
.newBuilder(Time.milliseconds(1))
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.cleanupInRocksdbCompactFilter(10)
.build());
}
@Override
public void processElement(Item value, Context ctx, Collector
out) throws Exception {
MapState> mapState = getRuntimeContext().getMapState(mapStateDesc);
List- l = mapState.get(value.name);
if (null == l) {
l = new LinkedList<>();
}
l.add(value);
mapState.put(value.name, l);
ListState
- listState = getRuntimeContext().getListState(listStateDesc);
listState.add(value);
Object o = listState.get();
ValueState
- valueState = getRuntimeContext().getState(valueStateDesc);
valueState.update(value);
Item i = valueState.value();
AggregatingState
- aggregatingState = getRuntimeContext().getAggregatingState(aggregatingStateDesc);
aggregatingState.add(value);
String aggResult = aggregatingState.get();
ReducingState
reducingState = getRuntimeContext().getReducingState(reducingStateDesc);
reducingState.add(value.name);
String reducingResult = reducingState.get();
System.out.println(1);
}
}
其中每一种 State 的用处:
ValueState[T]:是单一变量的状态,T 是某种具体的数据类型,比如 Double、String 或我们自己定义的复杂数据结构。我们可以使用 value() 方法获取状态,使用 update(value: T) 更新状态。
MapState[K, V]:存储一个 Key-Value map,其功能与 Java 的 Map 几乎相同。get(key: K) 可以获取某个 key 下的 value,put(key: K, value: V) 可以对某个 key 设置 value,contains(key: K) 判断某个 key 是否存在,remove(key: K) 删除某个 key 以及对应的 value,entries(): java.lang.Iterable[java.util.Map.Entry[K, V]] 返回 MapState 中所有的元素,iterator(): java.util.Iterator[java.util.Map.Entry[K, V]] 返回一个迭代器。需要注意的是,MapState 中的 key 和 Keyed State 的 key 不是同一个 key。
ListState[T]:存储了一个由 T 类型数据组成的列表。我们可以使用 add(value: T) 或 addAll(values: java.util.List[T]) 向状态中添加元素,使用 get(): java.lang.Iterable[T] 获取整个列表,使用 update(values: java.util.List[T]) 来更新列表,新的列表将替换旧的列表。
ReducingState[T]、AggregatingState[IN, OUT]、ListState[T] 同属于 MergingState[T]:与 ListState[T] 不同的是,ReducingState[T] 只有一个元素,而不是一个列表。它的原理是新元素通过 add(value: T) 加入后,与已有的状态元素使用 ReduceFunction 合并为一个元素,并更新到状态里。AggregatingState[IN, OUT] 与 ReducingState[T] 类似,也只有一个元素,只不过 AggregatingState[IN, OUT] 的输入和输出类型可以不一样。ReducingState[T] 和 AggregatingState[IN, OUT] 与窗口上进行 ReduceFunction 和 AggregateFunction 很像,都是将新元素与已有元素做聚合。
注意:
大多数情况下,常用的 State 也就是 keyed-state 中的 ValueState、MapState,其他 State 接口其实非常少用(包括 operator-state 也很少用)。
Flink 提供了 3 种状态后端用于管理和存储状态数据,我们来看看每种状态后端的适用场景:
⭐ MemoryStateBackend
原理:运行时所需的 State 数据全部保存在 TaskManager JVM 堆上内存中,执行 Checkpoint 的时候,会把 State 的快照数据保存到 JobManager 进程 的内存中。执行 Savepoint 时,可以把 State 存储到文件系统中。
适用场景:a.基于内存的 StateBackend 在生产环境下不建议使用,因为 State 大小超过 JobManager 内存就 OOM 了,此种状态后端适合在本地开发调试测试,生产环境基本不用。b.State 存储在 JobManager 的内存中。受限于 JobManager 的内存大小。c.每个 State 默认 5MB,可通过 MemoryStateBackend 构造函数调整。d.每个 Stale 不能超过 Akka Frame 大小。
⭐ FSStateBackend
原理:运行时所需的 State 数据全部保存在 TaskManager 的内存中,执行 Checkpoint 的时候,会把 State 的快照数据保存到配置的文件系统中。TM 是异步将 State 数据写入外部存储。
适用场景:a.适用于处理小状态、短窗口、或者小键值状态的有状态处理任务,不建议在大状态的任务下使用 FSStateBackend。适用的场景比如明细层 ETL 任务,小时间间隔的 TUMBLE 窗口 b.State 大小不能超过 TM 内存。
⭐ RocksDBStateBackend
原理:使用嵌入式的本地数据库 RocksDB 将流计算数据状态存储在本地磁盘中。在执行 Checkpoint 的时候,会将整个 RocksDB 中保存的 State 数据全量或者增量持久化到配置的文件系统中。
适用场景:a.最适合用于处理大状态、长窗口,或大键值状态的有状态处理任务。b.RocksDBStateBackend 是目前唯一支持增量检查点的后端。c.增量检查点非常适用于超大状态的场景。比如计算 DAU 这种大数据量去重,大状态的任务都建议直接使用 RocksDB 状态后端。
到生产环境中:
⭐ 如果状态很大,使用 Rocksdb;如果状态不大,使用 Filesystem。
⭐ Rocksdb 使用磁盘存储 State,所以会涉及到访问 State 磁盘序列化、反序列化,性能会收到影响,而 Filesystem 直接访问内存,单纯从访问状态的性能来说 Filesystem 远远好于 Rocksdb。生产环境中实测,相同任务使用 Filesystem 性能为 Rocksdb 的 n 倍,因此需要根据具体场景评估选择。
Flink 对状态做了能力扩展,即 TTL。它的能力其实和 redis 的过期策略类似,举例:
⭐ 支持 TTL 更新类型:更新 TTL 的时机
⭐ 访问到已过期数据的时的数据可见性
⭐ 过期时间语义:目前只支持处理时间
⭐ 具体过期实现:lazy,后台线程
那么首先我们看下什么场景需要用到 TTL 机制呢?举例:
比如计算 DAU 使用 Flink MapState 进行去重,到第二天的时候,第一天的 MapState 就可以删除了,就可以用 Flink State TTL 进行自动删除(当然你也可以通过代码逻辑进行手动删除)。
其实在 Flink DataStream API 中,TTL 功能还是比较少用的。Flink State TTL 在 Flink SQL 中是被大规模应用的,几乎除了窗口类、ETL(DWD 明细处理任务)类的任务之外,SQL 任务基本都会用到 State TTL。
那么我们在要怎么开启 TTL 呢?这里分 DataStream API 和 SQL API:
⭐ DataStream API:
private final MapStateDescriptor> mapStateDesc =
new MapStateDescriptor<>(
"itemsMap",
BasicTypeInfo.STRING_TYPE_INFO,
new ListTypeInfo<>(Item.class));
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
// 使用 StateTtlConfig 开启 State TTL
mapStateDesc.enableTimeToLive(StateTtlConfig
.newBuilder(Time.milliseconds(1))
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.cleanupInRocksdbCompactFilter(10)
.build());
}
关于 StateTtlConfig 的每个配置项的功能如下图所示:
⭐ SQL API:
StreamTableEnvironment
.getConfig()
.getConfiguration()
.setString("table.exec.state.ttl", "180 s");
注意:SQL 中 TTL 的策略不如 DataStream 那么多,SQL 中 TTL 只支持下图所示策略:
首先我们来想想,要做到 TTL 的话,要具备什么条件呢?
想想 Redis 的 TTL 设置,如果我们要设置 TTL 则必然需要给一条数据给一个时间戳,只有这样才能判断这条数据是否过期了。
在 Flink 中设置 State TTL,就会有这样一个时间戳,具体实现时,Flink 会把时间戳字段和具体数据字段存储作为同级存储到 State 中。
举个例子,我要将一个 String 存储到 State 中时:
⭐ 没有设置 State TTL 时,则直接将 String 存储在 State 中
⭐ 如果设置 State TTL 时,则 Flink 会将
接下来以 FileSystem 状态后端下的 MapState 作为案例来说:
⭐ 如果没有设置 State TTL,则生产的 MapState 的字段类型如下(可以看到生成的就是 HeapMapState 实例):
⭐ 如果设置了 State TTL,则生成的 MapState 的字段类型如下(可以看到使用到了装饰器的设计模式生成是 TtlMapState):
注意:
任务设置了 State TTL 和不设置 State TTL 的状态是不兼容的。这里大家在使用时一定要注意。防止出现任务从 Checkpoint 恢复不了的情况。但是你可以去修改 TTL 时长,因为修改时长并不会改变 State 存储结构。
了解了基础数据结构之后,我们再来看看 Flink 提供的 State 过期的 4 种删除策略:
⭐ lazy 删除策略:就是在访问 State 的时候根据时间戳判断是否过期,如果过期则主动删除 State 数据
⭐ full snapshot cleanup 删除策略:从状态恢复(checkpoint、savepoint)的时候采取做过期删除,但是不支持 rocksdb 增量 ck
⭐ incremental cleanup 删除策略:访问 state 的时候,主动去遍历一些 state 数据判断是否过期,如果过期则主动删除 State 数据
⭐ rocksdb compaction cleanup 删除策略:rockdb 做 compaction 的时候遍历进行删除。仅仅支持 rocksdb
访问 State 的时候根据时间戳判断是否过期,如果过期则主动删除 State 数据。以 MapState 为例,如下图所示,在 MapState.get(key) 时会进行判断是否过期:
这个删除策略是不需要用户进行配置的,只要你打开了 State TTL 功能,就会默认执行。
从状态恢复(checkpoint、savepoint)的时候采取做过期删除,但是不支持 rocksdb 增量 checkpoint。
StateTtlConfig
.newBuilder(Time.seconds(1))
.cleanupFullSnapshot()
.build()
访问 state 的时候,主动去遍历一些 state 数据判断是否过期,如果过期则主动删除 State 数据。
StateTtlConfig
.newBuilder(Time.seconds(1))
// 每访问 1 此 state,遍历 1000 条进行删除
.cleanupIncrementally(1000, true)
.build()
注意:
⭐ 如果没有 state 访问,也没有处理数据,则不会清理过期数据。
⭐ 增量清理会增加数据处理的耗时。
⭐ 现在仅 Heap state backend 支持增量清除机制。在 RocksDB state backend 上启用该特性无效。
⭐ 因为是遍历删除 State 机制,并且每次遍历的条目数是固定的,所以可能会出现部分过期的 State 很长时间都过期不掉导致 Flink 任务 OOM。
仅仅支持 rocksdb。在 rockdb 做 compaction 的时候遍历进行删除。
StateTtlConfig
.newBuilder(Time.seconds(1))
// 做 compaction 时每隔 3 个 entry,重新更新一下时间戳(这个时间戳是 Flink 用于和数据中的时间戳来比较判断是否过期)
.cleanupInRocksdbCompactFilter(3)
.build()
注意:
rocksdb compaction 时调用 TTL 过滤器会降低 compaction 速度。因为 TTL 过滤器需要解析上次访问的时间戳,并对每个将参与压缩的状态进行是否过期检查。对于集合型状态类型(比如 ListState 和 MapState),会对集合中每个元素进行检查。
通过上面的部分,我们已经学习了状态、状态后端,最后来看看 Flink Checkpoint 机制。
Checkpoint 整个流程如下:
⭐ JM 定时调度 Checkpoint 的触发:JM CheckpointCoorinator 定时触发,CheckpointCoordinator 会去通过 RPC 接口调用 Source 算子的 TM 的 StreamTask 告诉 TM 可以开始执行 Checkpoint 了。
⭐ Source 算子:接受到 JM 做 Checkpoint 的请求后,开始做本地 Checkpoint,本地执行完成之后,发 barrier 给下游算子。barrier 发送策略是随着 partition 策略走,将 barrier 发往连接到的所有下游算子(举例:keyby 就是广播,forward 就是直接送)。
⭐ 剩余的算子:接收到上游所有 barrier 之后进行触发 Checkpoint。当一个算子接收到上游一个 channel 的 barrier 之后,就停止处理这个 input channel 来的数据(本质上就是不会再去影响状态了)
注意:
实际代码中,慎用 Thread.sleep(),有可能导致任务执行线程卡住,barrier 发不下去,从而导致 Checkpoint 失败。
来看看 Flink 为 Checkpoint 都提供了哪些配置及功能来帮助我们控制 Checkpoint 执行时的行为:
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 每 30 秒触发一次 checkpoint,checkpoint 时间应该远小于(该值 + MinPauseBetweenCheckpoints),否则程序会一直做 checkpoint,影响数据处理速度
env.enableCheckpointing(30000);
// Flink 框架内保证 EXACTLY_ONCE
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
// 两个 checkpoints 之间最少有 30s 间隔(上一个 checkpoint 完成到下一个 checkpoint 开始,默认为 0,这里建议设置为非 0 值)
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(30000);
// checkpoint 超时时间(默认 600 s)
env.getCheckpointConfig().setCheckpointTimeout(600000);
// 同时只有一个checkpoint运行(默认)
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);
// 取消作业时是否保留 checkpoint (默认不保留,非常建议配置为保留)
env.getCheckpointConfig().enableExternalizedCheckpoints(CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);
// checkpoint 失败时 task 是否失败(默认 true, checkpoint 失败时,task 会失败)
env.getCheckpointConfig().setFailOnCheckpointingErrors(true);
// 对 FsStateBackend 刷出去的文件进行文件压缩,减小 checkpoint 体积
env.getConfig().setUseSnapshotCompression(true);
博主参考了很多云厂商后,建议大家的 Checkpoint 配置如下:
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 每 120 秒触发一次 checkpoint,不会特别频繁
env.enableCheckpointing(120000);
// Flink 框架内保证 EXACTLY_ONCE
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
// 两个 checkpoints 之间最少有 120s 间隔
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(120000);
// checkpoint 超时时间 600s
env.getCheckpointConfig().setCheckpointTimeout(600000);
// 同时只有一个 checkpoint 运行
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);
// 取消作业时保留 checkpoint,因为有时候任务 savepoint 可能不可用,这时我们就可以直接从 checkpoint 重启任务
env.getCheckpointConfig().enableExternalizedCheckpoints(CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);
// checkpoint 失败时 task 不失败,因为可能会有偶尔的写入 HDFS 失败,但是这并不会影响我们任务的运行
// 偶尔的由于网络抖动 checkpoint 失败可以接受,但是如果经常失败就要定位具体的问题!
env.getCheckpointConfig().setFailOnCheckpointingErrors(false);
这里也分 keyed-state 和 operator-state 进行说明。Flink 会将 Checkpoint 数据存储在一个带有编号的 chk 目录中。
比如说一个 Flink 任务的 keyed-state 的 subTask 个数是 10,operator-state 对应的 subTask 也是 10,那么 chk 会存一个元数据文件 _metadata,10 个 keyed-state 文件,10 个 operator-state 的文件。
这里主要介绍两种类型 State 在并行度发生变化时的恢复机制,如下图所示:
⭐ keyed-state
⭐ operator-state
其实 Flink SQL 发明出来就是为了屏蔽窗口、状态这些底层的东西的。
但是我们在使用 Flink SQL 时,70% 以上的场景都是不得不去关注 State 的!
举个 Flink SQL 的例子,下这个 SQL 用于计算每个 sessionId 的点击量:
SELECT
sessionId
, COUNT(*)
FROM clicks
GROUP BY
sessionId;
当 sessionId 为 1 亿时,或许还能够正常运行,但是 sessionId 为 10 亿时,State 将会变得很大,我们就不得不考虑是否要设置 State TTL 以防止无限增大的 State。
捞干的讲。
问题:哪些场景的 Flink SQL 会常常去考虑 State TTL 呢?
答案:相信大家通过上面的案例之后也能总结出来了。其实就是 unbounded Flink SQL 常常会考虑到,因为这类 Flink SQL 的 State 只会越变越大,如果没有设置合理的 State TTL 的话,任务可能会由于大 State 导致磁盘压力大,任务卡住。
⭐ 一定要分清楚 operator-state 和 keyed-state 的区别以及使用方式。博主有见过在 KeyedStream 后面错用 operator-state,operator-state 大 State 导致 OOM。建议 KeyedStream 上还是使用 keyed-state。
⭐ 一定要学会分场景使用 ValueState 和 MapState。博主有见过在 ValueState 中存储一个大 Map,并且使用 RocksDB,导致 State 访问非常慢(因为 RocksDB 访问 State 经过序列化),拖慢任务处理速度。两者的具体区别如下:
⭐ ValueState
a. 应用场景:简单的一个变量存储,比如 Long\String 等。如果状态后端为 RocksDB,极其不建议在 ValueState 中存储一个大 Map,这种场景下序列化和反序列化的成本非常高,拖慢任务处理速度,这种常见适合使用 MapState。其实这种场景也是很多小伙伴一开始使用 State 的误用之痛,一定要避免。
b. TTL:针对整个 Value 起作用
⭐ MapState
a. 应用场景:和 Map 使用方式一样一样的
b. TTL:针对 Map 的 key 生效,每个 key 一个 TTL
⭐ keyed-state 不能在 open 方法中访问、更新 state,这是不行的,因为 open 方法在执行时,还没有到正式的数据处理环节,上下文中是没有 key 的
⭐ operator-state 中的 ListState 进行以下操作会有问题。因为当实例化的 state 为 PartitionableListState
时,会先把 list clear,然后再 add,这样就会把下图中的 items 给 clear 了。你会发现 state 一致为空。