如果你希望使用 keyed state,首先需要为DataStream指定 key(主键)。这个主键用于状态分区(也会给数据流中的记录本身分区)。 你可以使用 DataStream 中 Java/Scala API 的 keyBy(KeySelector) 或者是 Python API 的 key_by(KeySelector) 来指定 key。 它将生成 KeyedStream,接下来允许使用 keyed state 操作。
Key selector 函数接收单条记录作为输入,返回这条记录的 key。该 key 可以为任何类型,但是它的计算产生方式必须是具备确定性的。
Flink 的数据模型不基于 key-value 对,因此实际上将数据集在物理上封装成 key 和 value 是没有必要的。 Key 是“虚拟”的。它们定义为基于实际数据的函数,用以操纵分组算子。
下面的例子展示了 key selector 函数。它仅返回了对象当中的字段。
// some ordinary POJO
public class WC {
public String word;
public int count;
public String getWord() { return word; }
}
DataStream<WC> words = // [...]
KeyedStream<WC> keyed = words
.keyBy(WC::getWord);
Flink 也有两种不同定义 key 的方式:Java/Scala API(Python API 仍未支持) 的 Tuple key(通过字段索引指定的 key)和 Expression key(通过字段名称指定的 key)。 借此你可以通过 tuple 字段索引,或者是选取对象字段的表达式来指定 key。 如今我们不建议这样使用,但你可以参考 DataStream 的 Javadoc 来了解它们。 使用 KeySelector 函数显然是更好的。以几乎可以忽略的额外开销为代价,结合 Java Lambda 表达式,我们可以更方便得使用KeySelector。
keyed state 接口提供不同类型状态的访问接口,这些状态都作用于当前输入数据的 key 下。换句话说,这些状态仅可在 KeyedStream 上使用,在Java/Scala API上可以通过 stream.keyBy(…) 得到 KeyedStream,在Python API上可以通过 stream.key_by(…) 得到 KeyedStream。
接下来,我们会介绍不同类型的状态,然后介绍如何使用他们。所有支持的状态类型如下所示:
ValueState: 保存一个可以更新和检索的值(如上所述,每个值都对应到当前的输入数据的 key,因此算子接收到的每个 key 都可能对应一个值)。 这个值可以通过 update(T) 进行更新,通过 T value() 进行检索。
ListState: 保存一个元素的列表。可以往这个列表中追加数据,并在当前的列表上进行检索。可以通过 add(T) 或者 addAll(List) 进行添加元素,通过 Iterable get() 获得整个列表。还可以通过 update(List) 覆盖当前的列表。
ReducingState: 保存一个单值,表示添加到状态的所有值的聚合。接口与 ListState 类似,但使用 add(T) 增加元素,会使用提供的 ReduceFunction 进行聚合。
AggregatingState
MapState
所有类型的状态还有一个clear() 方法,清除当前 key 下的状态数据,也就是当前输入元素的 key。
请牢记,这些状态对象仅用于与状态交互。状态本身不一定存储在内存中,还可能在磁盘或其他位置。 另外需要牢记的是从状态中获取的值取决于输入元素所代表的 key。 因此,在不同 key 上调用同一个接口,可能得到不同的值。
你必须创建一个 StateDescriptor,才能得到对应的状态句柄。 这保存了状态名称(正如我们稍后将看到的,你可以创建多个状态,并且它们必须具有唯一的名称以便可以引用它们), 状态所持有值的类型,并且可能包含用户指定的函数,例如ReduceFunction。 根据不同的状态类型,可以创建ValueStateDescriptor,ListStateDescriptor, AggregatingStateDescriptor, ReducingStateDescriptor 或 MapStateDescriptor。
状态通过 RuntimeContext 进行访问,因此只能在 rich functions 中使用。请参阅这里获取相关信息, 但是我们很快也会看到一个例子。RichFunction 中 RuntimeContext 提供如下方法:
下面是一个 FlatMapFunction 的例子,展示了如何将这些部分组合起来:
public class CountWindowAverage extends RichFlatMapFunction<Tuple2<Long, Long>, Tuple2<Long, Long>> {
/**
* The ValueState handle. The first field is the count, the second field a running sum.
*/
private transient ValueState<Tuple2<Long, Long>> sum;
@Override
public void flatMap(Tuple2<Long, Long> input, Collector<Tuple2<Long, Long>> out) throws Exception {
// access the state value
Tuple2<Long, Long> currentSum = sum.value();
// update the count
currentSum.f0 += 1;
// add the second field of the input value
currentSum.f1 += input.f1;
// update the state
sum.update(currentSum);
// if the count reaches 2, emit the average and clear the state
if (currentSum.f0 >= 2) {
out.collect(new Tuple2<>(input.f0, currentSum.f1 / currentSum.f0));
sum.clear();
}
}
@Override
public void open(Configuration config) {
ValueStateDescriptor<Tuple2<Long, Long>> descriptor =
new ValueStateDescriptor<>(
"average", // the state name
TypeInformation.of(new TypeHint<Tuple2<Long, Long>>() {}), // type information
Tuple2.of(0L, 0L)); // default value of the state, if nothing was set
sum = getRuntimeContext().getState(descriptor);
}
}
// this can be used in a streaming program like this (assuming we have a StreamExecutionEnvironment env)
env.fromElements(Tuple2.of(1L, 3L), Tuple2.of(1L, 5L), Tuple2.of(1L, 7L), Tuple2.of(1L, 4L), Tuple2.of(1L, 2L))
.keyBy(value -> value.f0)
.flatMap(new CountWindowAverage())
.print();
// the printed output will be (1,4) and (1,5)
这个例子实现了一个穷人的计数窗口。我们通过第一个字段对元组进行键控(在示例中,所有元组都具有相同的键1)。该函数将计数和运行总和存储在 a 中ValueState。一旦计数达到 2,它将发出平均值并清除状态,以便我们从 重新开始0。请注意,如果我们在第一个字段中有具有不同值的元组,这将为每个不同的输入键保留不同的状态值。
可以将生存时间(TTL) 分配给任何类型的键控状态。如果配置了 TTL 并且状态值已过期,则将尽最大努力清理存储的值,这将在下面更详细地讨论。
所有状态集合类型都支持每个条目的 TTL。这意味着列表元素和映射条目独立过期。
为了使用状态 TTL,必须首先构建一个StateTtlConfig配置对象。然后可以通过传递配置在任何状态描述符中启用 TTL 功能:
import org.apache.flink.api.common.state.StateTtlConfig;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.common.time.Time;
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.seconds(1))
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.build();
ValueStateDescriptor<String> stateDescriptor = new ValueStateDescriptor<>("text state", String.class);
stateDescriptor.enableTimeToLive(ttlConfig);
该配置有几个选项需要考虑:
该newBuilder方法的第一个参数是强制性的,它是生存时间值。
更新类型配置何时刷新状态 TTL(默认情况下OnCreateAndWrite):
(注意:如果StateTtlConfig.StateVisibility.ReturnExpiredIfNotCleanedUp 同时设置状态可见性为,状态读取缓存将被禁用,这会导致 PyFlink 中的一些性能损失)
状态可见性配置是否在读取访问时返回过期值(如果尚未清理)(默认情况下NeverReturnExpired):
(注意:状态读/写缓存将被禁用,这会导致 PyFlink 中的一些性能损失)
在 NeverReturnExpired的情况下,过期状态的行为就好像它不再存在一样,即使它仍然必须被删除。该选项对于数据必须在 TTL 之后严格无法进行读取访问的用例很有用,例如处理隐私敏感数据的应用程序。
另一个选项ReturnExpiredIfNotCleanedUp允许在清理之前返回过期状态。
笔记:
状态后端将上次修改的时间戳与用户值一起存储,这意味着启用此功能会增加状态存储的消耗。堆状态后端在内存中存储了一个附加的 Java 对象,该对象具有对用户状态对象的引用和一个原始的 long 值。RocksDB 状态后端为每个存储的值、列表条目或映射条目添加 8 个字节。
当前仅支持与处理时间相关的TTL 。
尝试使用启用 TTL 的描述符来恢复先前未配置 TTL 的状态,反之亦然,将导致兼容性失败,并且StateMigrationException.
TTL 配置不是检查点或保存点的一部分,而是 Flink 在当前运行的作业中如何处理它的一种方式。
仅当用户值序列化程序可以处理空值时,具有 TTL 的映射状态当前才支持空用户值。如果序列化程序不支持空值,则可以以NullableSerializer序列化形式中的额外字节为代价包装它。
启用 TTL 配置后,实际上已弃用的defaultValueinStateDescriptor将不再生效。这旨在使语义更清晰,并让用户在状态内容为空或过期时手动管理默认值。
默认情况下,过期值会在读取时显式删除,例如ValueState#value,如果配置的是状态后端存储,则会在后台定期收集垃圾。后台清理可以在以下位置禁用StateTtlConfig:
import org.apache.flink.api.common.state.StateTtlConfig;
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.seconds(1))
.disableCleanupInBackground()
.build();
为了对后台的一些特殊清理进行更细粒度的控制,您可以单独配置它,如下所述。目前,堆状态后端依赖于增量清理,而 RocksDB 后端使用压缩过滤器进行后台清理。
完整快照中的清理
此外,您可以在拍摄完整状态快照时激活清理,这将减小其大小。本地状态在当前实现下没有被清理,但它不会包括已删除的过期状态,以防从之前的快照恢复。它可以配置在StateTtlConfig:
import org.apache.flink.api.common.state.StateTtlConfig;
import org.apache.flink.api.common.time.Time;
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.seconds(1))
.cleanupFullSnapshot()
.build();
此选项不适用于 RocksDB 状态后端中的增量检查点。
对于现有作业,可以随时激活或停用此清理策略StateTtlConfig,例如从保存点重新启动后。
增量清理
另一种选择是逐步清除某些状态条目。触发器可以是来自每个状态访问或/和每个记录处理的回调。如果此清理策略对某些状态有效,则存储后端会为此状态在其所有条目上保留一个惰性全局迭代器。每次触发增量清理时,迭代器都会前进。检查遍历的状态条目并清除过期的条目。
可以在以下位置配置此功能StateTtlConfig:
import org.apache.flink.api.common.state.StateTtlConfig;
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.seconds(1))
.cleanupIncrementally(10, true)
.build();
笔记:
可以在以下位置配置此功能StateTtlConfig:
import org.apache.flink.api.common.state.StateTtlConfig;
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.seconds(1))
.cleanupInRocksdbCompactFilter(1000)
.build();
RocksDB 压缩过滤器每次处理一定数量的状态条目后,都会从 Flink 中查询当前时间戳,用于检查过期时间。您可以更改它并将自定义值传递给 StateTtlConfig.newBuilder(…).cleanupInRocksdbCompactFilter(long queryTimeAfterNumEntries)方法。更频繁地更新时间戳可以提高清理速度,但会降低压缩性能,因为它使用来自本机代码的 JNI 调用。RocksDB 后端的默认后台清理会在每次处理 1000 个条目时查询当前时间戳。
您可以通过激活调试级别从 RocksDB 过滤器的本机代码激活调试日志FlinkCompactionFilter:
log4j.logger.org.rocksdb.FlinkCompactionFilter=DEBUG
笔记:
除了上述接口之外,Scala API 还具有用于有状态 map()或flatMap()具有单个ValueStateon的函数的快捷方式KeyedStream。ValueState用户函数获取in的当前值,Option并且必须返回将用于更新状态的更新值。
运算符状态(或非键状态)是绑定到一个并行运算符实例的状态。Kafka 连接器是在 Flink 中使用 Operator State的一个很好的激励示例。Kafka 消费者的每个并行实例都维护一个主题分区和偏移量的映射作为其 Operator State。
Operator State 接口支持在并行度改变时在并行的 operator 实例之间重新分配状态。进行这种重新分配有不同的方案。
在典型的有状态 Flink 应用程序中,您不需要操作符状态。它主要是一种特殊类型的状态,用于源/接收器实现和您没有可以对状态进行分区的键的场景。
注意: Python DataStream API 中仍然不支持 Operator 状态。
Broadcast State是Operator State的一种特殊类型。引入它是为了支持一个流的记录需要广播到所有下游任务的用例,它们用于在所有子任务之间保持相同的状态。然后可以在处理第二个流的记录时访问此状态。作为一个广播状态可以自然出现的例子,可以想象一个低吞吐量的流包含一组规则,我们希望针对来自另一个流的所有元素评估这些规则。考虑到上述类型的用例,广播状态与其他运营商状态的不同之处在于:
注意: Python DataStream API 仍然不支持广播状态。
要使用操作符状态,有状态函数可以实现该CheckpointedFunction 接口。
该CheckpointedFunction接口提供对具有不同重新分配方案的非键控状态的访问。它需要实现两种方法:
每当必须执行检查点时,snapshotState()都会调用它。initializeState()每次初始化用户定义的函数时都会调用对应的 ,无论是在函数首次初始化时,还是在函数实际从较早的检查点恢复时。鉴于此,initializeState()它不仅是初始化不同类型状态的地方,也是包含状态恢复逻辑的地方。
目前,支持列表样式的运算符状态。状态应该是一个List可序列化的对象,彼此独立,因此有资格在重新缩放时重新分配。换句话说,这些对象是可以重新分配非键控状态的最细粒度。根据状态访问方法,定义了以下重新分配方案:
均分再分配:每个算子返回一个状态元素列表。整个状态在逻辑上是所有列表的串联。在恢复/重新分配时,列表被平均划分为与并行运算符一样多的子列表。每个运算符都有一个子列表,该子列表可以为空,也可以包含一个或多个元素。例如,如果使用并行度 1,算子的检查点状态包含元素element1,并且element2当将并行度增加到 2 时,element1可能会在算子实例 0 中结束,而element2将转到算子实例 1。
联合重新分配:每个运算符返回一个状态元素列表。整个状态在逻辑上是所有列表的串联。在恢复/重新分发时,每个操作员都会获得状态元素的完整列表。如果您的列表可能具有高基数,请不要使用此功能。检查点元数据将存储每个列表条目的偏移量,这可能导致 RPC 帧大小或内存不足错误。
下面是一个有状态的示例SinkFunction,用于CheckpointedFunction 在将元素发送到外部世界之前对其进行缓冲。它演示了基本的均分再分配列表状态:
public class BufferingSink
implements SinkFunction<Tuple2<String, Integer>>,
CheckpointedFunction {
private final int threshold;
private transient ListState<Tuple2<String, Integer>> checkpointedState;
private List<Tuple2<String, Integer>> bufferedElements;
public BufferingSink(int threshold) {
this.threshold = threshold;
this.bufferedElements = new ArrayList<>();
}
@Override
public void invoke(Tuple2<String, Integer> value, Context contex) throws Exception {
bufferedElements.add(value);
if (bufferedElements.size() >= threshold) {
for (Tuple2<String, Integer> element: bufferedElements) {
// send it to the sink
}
bufferedElements.clear();
}
}
@Override
public void snapshotState(FunctionSnapshotContext context) throws Exception {
checkpointedState.clear();
for (Tuple2<String, Integer> element : bufferedElements) {
checkpointedState.add(element);
}
}
@Override
public void initializeState(FunctionInitializationContext context) throws Exception {
ListStateDescriptor<Tuple2<String, Integer>> descriptor =
new ListStateDescriptor<>(
"buffered-elements",
TypeInformation.of(new TypeHint<Tuple2<String, Integer>>() {}));
checkpointedState = context.getOperatorStateStore().getListState(descriptor);
if (context.isRestored()) {
for (Tuple2<String, Integer> element : checkpointedState.get()) {
bufferedElements.add(element);
}
}
}
}
该initializeState方法将 a 作为参数FunctionInitializationContext。这用于初始化非键控状态“容器”。这些是类型的容器,ListState其中非键状态对象将在检查点时存储。
请注意状态是如何初始化的,类似于键控状态,其中StateDescriptor包含状态名称和有关状态所持有的值的类型的信息:
ListStateDescriptor<Tuple2<String, Integer>> descriptor =
new ListStateDescriptor<>(
"buffered-elements",
TypeInformation.of(new TypeHint<Tuple2<String, Integer>>() {}));
checkpointedState = context.getOperatorStateStore().getListState(descriptor);
状态访问方法的命名约定包含其重新分配模式及其状态结构。例如,要在还原时将列表状态与联合重新分配方案一起使用,请使用getUnionListState(descriptor). 如果方法名称不包含重新分配模式,例如 getListState(descriptor),它只是暗示将使用基本的偶数拆分重新分配方案。
初始化容器后,我们使用isRestored()上下文的方法来检查我们是否在失败后恢复。如果这是true,即我们正在恢复,则应用恢复逻辑。
如修改后的代码所示,在状态初始化期间恢复的BufferingSink这个ListState被保存在一个类变量中以备将来使用snapshotState()。那里ListState清除了前一个检查点包含的所有对象,然后填充了我们想要检查点的新对象。
作为旁注,键控状态也可以在该initializeState()方法中初始化。这可以使用提供的FunctionInitializationContext.
与其他运营商相比,有状态的来源需要更多的关注。为了对状态和输出集合进行原子更新(故障/恢复时的精确一次语义需要),用户需要从源的上下文中获取锁。
public static class CounterSource
extends RichParallelSourceFunction<Long>
implements CheckpointedFunction {
/** current offset for exactly once semantics */
private Long offset = 0L;
/** flag for job cancellation */
private volatile boolean isRunning = true;
/** Our state object. */
private ListState<Long> state;
@Override
public void run(SourceContext<Long> ctx) {
final Object lock = ctx.getCheckpointLock();
while (isRunning) {
// output and state update are atomic
synchronized (lock) {
ctx.collect(offset);
offset += 1;
}
}
}
@Override
public void cancel() {
isRunning = false;
}
@Override
public void initializeState(FunctionInitializationContext context) throws Exception {
state = context.getOperatorStateStore().getListState(new ListStateDescriptor<>(
"state",
LongSerializer.INSTANCE));
// restore any state that we might already have to our fields, initialize state
// is also called in case of restore.
for (Long l : state.get()) {
offset = l;
}
}
@Override
public void snapshotState(FunctionSnapshotContext context) throws Exception {
state.clear();
state.add(offset);
}
}
当 Flink 完全确认检查点以与外界进行通信时,一些操作员可能需要这些信息。在这种情况下,请参阅org.apache.flink.api.common.state.CheckpointListener界面。