1.状态类型
State 按照是否有 key 划分为 KeyedState 和 OperatorState
Keyed State:KeyedStream 流上的每一个 Key 都对应一个 State
Keyed State
表示和 Key 相关的一种 State ,只能用于 KeydStream 类型数据集对应的 Functions 和 Operators 之上。 Keyed State 是Operator State的特例,区别在于 Keyed State 事先按照 key 对数据集进行了分区,每个 Key State 仅对应ー个Operator和 Key 的组合。 Keyed State可以通过 Key Groups 进行管理,主要用于当算子并行度发生变化时,自动重新分布Keyed State数据 。在系统运行过程中,一个 Keyed 算子实例可能运行一个或者多个 Key Groups 的 keys。分配方法如下:
// maxParallelism 为最大并行度
MathUtils.murmurHash(key.hashCode()) % maxParallelism;
maxParallelism
是
flink程序的最大并行度(计算规则
operatorParallelism * 1.5
,下限 128
,上限 32768
),这个值一般我们不会去手动设置,使用默认的值(128)。maxParallelism
和我们运行程序时指定的算子并行度(
parallelism
)不同,
parallelism
不能大于
maxParallelism , parallelism 最多只能设置为 maxParallelism。 为什么会有 Key Group 这个概念呢?举个栗子, 我们通常写程序,会给算子指定一个并行度,运行一段时间后,积累了一些 state ,这时候数据量大了,需要增大并 行度;我们修改并行度后重新提交,那这些已经存在的 state 该如何分配到各个 Operator 呢?这就有了最大并行度 (maxParallelism ) 和 Key Group 的概念。上面计算 Key Group 的公式也说明了 Key Group 的个数最多是 maxParallelism 个。当并行度更改后,我们再计算这个 key 被分配到的 Operator :
// maxParallelism 为最大并行度 MathUtils.murmurHash(key.hashCode()) % maxParallelism;
(2)Operator State
与 Keyed State 不同的是, Operator State 只和并行的算子实例绑定,和数据元素中的 key 无关,每个算子实例中持有所有数据元素中的一部分状态数据。Operator State 支持当算子实例并行度发生变化时自动重新分配状态数据。
同时在 Flink 中 Keyed State 和 Operator State 均具有两种形式,其中一种为 托管状态( Managed State ) 形式,由Flink Runtime中控制和管理状态数据,并将状态数据转换成为内存 Hash tables 或 ROCKSDB 的对象存储,然后将这些状态数据通过内部的接口持久化到 Checkpoints 中,任务异常时可以通过这些状态数据恢复任务。另外一种是 原 生状态( Raw State ) 形式,由算子自己管理数据结构,当触发 Checkpoint 过程中, Flink 并不知道状态数据内部的数据结构,只是将数据转换成bys 数据存储在 Checkpoints 中,当从 Checkpoints 恢复任务时,算子自己再反序列化出状态的数据结构。Datastream API 支持使用 Managed State 和 Raw State 两种状态形式, 在 Flink中推荐用户使用Managed State管理状态数据 ,主要原因是 Managed State 能够更好地支持状态数据的重平衡以及更加完善的内存管理。
managed operator state 以 list 的形式存在。这些状态是一个 可序列化对象的集合 List ,彼此独立,方便在改变并发后进行状态的重新分派。 换句话说,这些对象是重新分配 non-keyed state 的最细粒度。根据状态的不同访问方式,有如下几种重新分配的模式:
- Even-split redistribution: 每个算子都保存一个列表形式的状态集合,整个状态由所有的列表拼接而成。当作业恢复或重新分配的时候,整个状态会按照算子的并发度进行均匀分配。 比如说,算子 A 的并发读为 1,包含两个元素 element1 和 element2,当并发读增加为 2 时,element1 会被分到并发 0 上,element2 则会被分到并发 1 上。
- Union redistribution: 每个算子保存一个列表形式的状态集合。整个状态由所有的列表拼接而成。当作业恢复或重新分配时,每个算子都将获得所有的状态数据。
2.状态存储
一. State 存储方式
Flink 为 state 提供了三种开箱即用的后端存储方式 (state backend) :
1. Memory State Backend
2. File System (FS) State Backend
3. RocksDB State Backend
1.1 MemoryStateBackend
MemoryStateBackend 将工作状态数据保存在 taskmanager 的 java 内存中。 key/value 状态和 window 算子使用哈希表存储数值和触发器。进行快照时(checkpointing ),生成的快照数据将和 checkpoint ACK 消息一起发送给jobmanager, jobmanager 将收到的所有快照保存在 java 内存中。 MemoryStateBackend 现在被默认配置成异步的,这样避免阻塞主线程的 pipline 处理。 MemoryStateBackend 的状态存取的速度都非常快,但是不适合在生产环境中使用。这是因为 MemoryStateBackend 有以下限制:
- 每个 state 的默认大小被限制为 5 MB(这个值可以通过 MemoryStateBackend 构造函数设置)
- 每个 task 的所有 state 数据 (一个 task 可能包含一个 pipline 中的多个 Operator) 大小不能超过 RPC 系统的帧大小(akka.framesize,默认 10MB)
- jobmanager 收到的 state 数据总和不能超过 jobmanager 内存
1.2 FsStateBackend
FsStateBackend 需要配置一个 checkpoint 路径,例如 “hdfs://namenode:40010/flflink/checkpoints” 或者 “fifile:///data/flflink/checkpoints”,我们一般配置为 hdfs 目录 FsStateBackend 将工作状态数据保存在 taskmanager的 java 内存中。进行快照时,再将快照数据写入上面配置的路径,然后将写入的文件路径告知 jobmanager 。 jobmanager 中保存所有状态的元数据信息 ( 在 HA 模式下,元数据会写入 checkpoint 目录 ) 。 FsStateBackend 默认使用异步方式进行快照,防止阻塞主线程的 pipline 处理。可以通过 FsStateBackend 构造函数取消该模式:
new FsStateBackend(path, false);
FsStateBackend 适合的场景:
大状态、长窗口、大键值(键或者值很大)状态的作业
适合高可用方案
1.3 RocksDBStateBackend
RocksDBStateBackend 也需要配置一个 checkpoint 路径,例如: “hdfs://namenode:40010/flflink/checkpoints” 或者 “fifile:///data/flflink/checkpoints” ,一般配置为 hdfs 路径。 RocksDB 是一种可嵌入的持久型的 key-value 存储引擎,提供 ACID 支持。由 Facebook 基于 levelDB 开发,使用 LSM 存储引擎,是内存和磁盘混合存储。RocksDBStateBackend 将工作状态保存在 taskmanager 的 RocksDB 数据库中; checkpoint 时, RocksDB 中的所有数据会被传输到配置的文件目录,少量元数据信息保存在 jobmanager 内存中 ( HA 模式下,会保存在 checkpoint目录) 。 RocksDBStateBackend 使用异步方式进行快照。 RocksDBStateBackend 的限制:
- 由于 RocksDB 的 JNI bridge API 是基于 byte[] 的,RocksDBStateBackend 支持的每个 key 或者每个 value 的最大值不超过 2^31 bytes((2GB))。
- 要注意的是,有 merge 操作的状态(例如 ListState),可能会在运行过程中超过 2^31 bytes,导致程序失败。
RocksDBStateBackend 适用于以下场景:
- 超大状态、超长窗口(天)、大键值状态的作业
- 适合高可用模式
使用 RocksDBStateBackend 时,能够限制状态大小的是 taskmanager 磁盘空间(相对于 FsStateBackend 状态大小限制于 taskmanager 内存 )。这也导致 RocksDBStateBackend 的吞吐比其他两个要低一些。因为 RocksDB 的状态数据的读写都要经过反序列化/序列化。
3.状态过期 ttl
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.seconds(1)) // 状态存活时间
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite) // TTL 何时被更新,这里配置的 state 创建和写入时
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.build();// 设置过期的 state 不被读取
注意:
- 状态的最新访问时间会和状态数据保存在一起,所以开启 TTL 特性会增大 state 的大小。Heap state backend会额外存储一个包括用户状态以及时间戳的 Java 对象,RocksDB state backend 会在每个状态值(list 或者 map 的每个元素)序列化后增加 8 个字节。
- 暂时只支持基于 processing time 的 TTL。
- 尝试从 checkpoint/savepoint 进行恢复时,TTL 的状态(是否开启)必须和之前保持一致,否则会遇到“StateMigrationException”。
- TTL 的配置并不会保存在 checkpoint/savepoint 中,仅对当前 Job 有效。
- 当前开启 TTL 的 map state 仅在用户值序列化器支持 null 的情况下,才支持用户值为 null。如果用户值序列化器不支持 null, 可以用 NullableSerializer 包装一层。
过期的 state 何时被删除?
默认情况下,过期的 state 数据只有被显示读取的时候才会被删除,例如,调用 ValueState.value() 时。 注意:如果 过期的数据如果之后不被读取,那么这个过期数据就不会被删除,可能导致状态不断增大。目前有两种方式解决这个问题:
1. 从全量快照恢复时删除
可以配置从全量快照恢复时删除过期数据:
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.seconds(1)) // state 存活时间,这里设置的 1 秒过期
.cleanupFullSnapshot()
.build();
局限是正常运行的程序的过期状态还是无法删除,全量快照时,过期状态还是被备份了,只是在从上一个快照恢复时会过滤掉过期数据。
- 注意:使用 RocksDB 增量快照时,该配置无效。
- 这种清理方式可以在任何时候通过 StateTtlConfifig 启用或者关闭,比如在从 savepoint 恢复时。
2. 后台程序删除(flink-1.8 之后的版本支持)
flink-1.8 引入了后台清理过期 state 的特性,通过 StateTtlConfifig 开启,显式调用 cleanupInBackground() ,使用示例如下:
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.seconds(1)) // state 存活时间,这里设置的 1 秒过期
.cleanupInBackground()
.build();
官方介绍,使用 cleanupInBackground() 时,可以让不同 statebackend 自动选择 cleanupIncrementally(heap state backend) 或者 cleanupInRocksdbCompactFilter(rocksdb state backend) 策略进行后台清理。也就是说,不同的 statebackend 的具体清理过期 state 原理也是不一样的。而且,配置为 cleanupInBackground() 时,只能使用默认配置的参数。想要更改参数时,需要显式配置上面提到的两种清理方式,并且要和 statebackend 对应:
heap state backend 支持的增量清理 在状态访问或处理时进行。如果某个状态开启了该清理策略,则会在存储后端 保留一个所有状态的惰性全局迭代器 。 每次触发增量清理时,从迭代器中选择已经过期的进行清理。通过StateTtlConfifig 配置,显式调用 cleanupIncrementally() :
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.seconds(1))
.cleanupIncrementally(10, true)
.build();
使用 cleanupIncrementally() 策略时,当 state 被访问时会触发清理逻辑。 cleanupIncrementally() 包含两个参数:
第一个参数表示每次清理被触发时,要检查的 state 条目个数;第二个参数表示是否在每条数据被处理时都触发清理逻辑。如果使用 cleanupInBackground() 的话,这里的默认值是 (5, false) 。 还有以下几点需要注意: a. 如果没有state 访问,也没有处理数据,则不会清理过期数据 。 b. 增量清理会增加数据处理的耗时。 c. 现在仅 Heap state backend 支持增量清除机制。在 RocksDB state backend 上启用该特性无效。 d. 如果 Heap state backend 使用同步快照方式,则会保存一份所有 key 的拷贝,从而防止并发修改问题,因此会增加内存的使用。但异步快照则没有这 个问题。 e. 对已有的作业,这个清理方式可以在任何时候通过 StateTtlConfifig 启用或禁用该特性,比如从 savepoint 重启后。
RocksDB 进行 compaction(压缩合并) 时清理 如果使用 RocksDB state backend ,可以使用 Flink 为 RocksDB定制的 compaction fifilter 。 RocksDB 会周期性的对数据进行异步合并压缩从而减少存储空间。 Flink 压缩过滤器会在压缩时过滤掉已经过期的状态数据。 该特性默认是关闭的,可以通过 Flink 的配置项state.backend.rocksdb.ttl.compaction.fifilter.enabled 或者调用 RocksDBStateBackend::enableTtlCompactionFilter 启用该特性。然后通过如下方式让任何具有 TTL 配置的状态使用过滤器:
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.seconds(1))
.cleanupInRocksdbCompactFilter(1000)
.build();
使用这种策略需要注意: a. 压缩时调用 TTL 过滤器会降低速度。TTL 过滤器需要解析上次访问的时间戳 ,并对每个将参与压缩的状态进行是否过期检查。 对于集合型状态类型(比如 list 和 map ),会对集合中每个元素进行检查。 b. 对于元素序列化后长度不固定的列表状态, TTL 过滤器需要在每次 JNI 调用过程中, 额外调用 Flink 的 java 序列化器 , 从而确定下一个未过期数据的位置。 c. 对已有的作业,这个清理方式可以在任何时候通过 StateTtlConfifig 启用或禁用该特性,比如从 savepoint 重启后。
4.statebackend 如何保存 managed keyed/operator state
在 flink 的实际实现中,对于同一种 statebackend ,不同的 state 在运行时会有细分的 statebackend 托管,例如MemeoryStateBackend,就有 DefaultOperatorStateBackend 管理 Operator state , HeapKeydStateBackend 管理 Keyed state 。我们看到 MemoryStateBackend 和 FsStateBackend 对于 keyed state 和 Operator state 的存储都符合我们之前的理解,运行时 state 数据保存于内存, checkpoint 时分别将数据备份在 jobmanager 内存和磁盘; RocksDBStateBackend 运行时 Operator state 的保存位置需要注意下,并不是保存在 RocksDB 中 ,而是通过 DefaultOperatorStateBackend 保存在 taskmanager 内存 ,创建源码如下:
// RocksDBStateBackend.java
// 创建 keyed statebackend
public <K> AbstractKeyedStateBackend<K> createKeyedStateBackend(...){
...
return new RocksDBKeyedStateBackend<>( ...);
}
// 创建 Operator statebackend
public OperatorStateBackend createOperatorStateBackend( Environment env, String operatorIdentifier) throws Exception {
//the default for RocksDB; eventually there can be a operator state backend based on RocksDB, too.
final boolean asyncSnapshots = true;
return new DefaultOperatorStateBackend( ...);
}
Operator State 在内存中对应两种数据结构:
ListState: 对应的实际实现类为 PartitionableListState,创建并注册的代码如下
// DefaultOperatorStateBackend.java private <S> ListState<S> getListState(...){
partitionableListState = new PartitionableListState<>(
new RegisteredOperatorStateBackendMetaInfo<>(name, partitionStateSerializer, mode));
registeredOperatorStates.put(name, partitionableListState);
}
PartitionableListState 中通过 ArrayList 来保存 state 数据:
// PartitionableListState.java
/**
* The internal list the holds the elements of the state
*/
private final ArrayList<S> internalList;
BroadcastState:对应的实际实现类为 HeapBroadcastState,创建并注册的代码如下:
public <K, V> BroadcastState<K, V> getBroadcastState(...) {
broadcastState = new HeapBroadcastState<>(
new RegisteredBroadcastStateBackendMetaInfo<>( name,
OperatorStateHandle.Mode.BROADCAST, broadcastStateKeySerializer,
broadcastStateValueSerializer));
registeredBroadcastStates.put(name, broadcastState);
}
对于 HeapKeydStateBackend , state 数据被保存在一个由多层
java Map
嵌套而成的数据结构中。这个图表示的是 window 中的
keyed state
保存方式,而
window
-
contents
是
flink
中
window
数据的
state
描述符的名称,当然描述符类型是根据实际情况变化的。比如我们经常在 window
后执行聚合操作
(aggregate)
,
flink
就有可能创建一个名字为 window-contents
的
AggregatingStateDescriptor
:
HeadKeyedStateBackend 会通过一个叫 StateTable 的数据结构,查找 key 对应的 StateMap:
// StateTable.java
/***
Map for holding the actual state objects. The outer array represents the key-groups.
* All array positions will be initialized with an empty state map.
*/
protected final StateMap<K, N, S>[] keyGroupedStateMaps;
根据是否开启异步 checkpoint , StateMap 会分别对应两个实现类: CopyOnWriteStateMap<K, N, S> 和 NestedStateMap<K, N, S>。 对于 NestedStateMap ,实际存储数据如下:
// NestedStateMap.java
private final Map<N, Map<K, S>> namespaceMap;
CopyOnWriteStateMap 是一个支持 Copy-On-Write 的 StateMap 子类 ,实际上参考了 HashMap 的实现, 它支持渐进式哈希(incremental rehashing) 和异步快照特性 。
对于 RocksDBKeyedStateBackend,每个 state 存储在一个单独的 column family 内,KeyGroup、key、 namespace 进行序列化存储在 DB 作为 key,状态数据作为 value 。
五. state 文件格式
当我们创建 state 时,数据是如何保存的呢? 对于不同的 statebackend ,有不同的存储格式。但是都是使用 flink 序列化器,将键值转化为字节数组保存起来。这里使用 RocksDBStateBackend 示例。 每个 taskmanager 会创建多个RocksDB 目录 ,每个目录保存一个 RocksDB 数据库;每个数据库包含多个 column famiilies ,这些 column families 由 state descriptors 定义。 每个 column family 包含多个 key-value 对, key 是 Operator 的 key , value是对应的状态数据。
RocksDB目录含义:
大致分为 3 部分:
1. JOB_ID: JobGraph 创建时分配的随机 id
2. OPERATOR_ID: 由 4 部分组成, 算子基类 Murmur3( 算子 uid) task 索引 _task总并行度。对于 StatefulMapTest这个算子,4 个 部分分别为
- StreamFlatMap
- Murmur3_128(“stateful_map_test”) -> 11f49afc24b1cce91c7169b1e5140284
- 1,因为总并行度指定了1,所以只有这一个 task
- 1,因为总并行度指定了1
3. UUID: 随机的 UUID
值 每个目录都包含一个 RocksDB 实例,其文件结构如下:
- .sst 文件是 RocksDB 生成的 SSTable,包含真实的状态数据。
- LOG 文件包含 commit log
- MANIFEST 文件包含元数据信息,例如 column families
- OPTIONS 文件包含创建 RocksDB 实例时使用的配置