

# Working with State 工作状态

本文档解释了在开发应用程序时如何使用Flink的状态抽象。

## Keyed State and Operator State 键控状态和操作状态

在Flink中有两种基本的状态： `Keyed State`和`Operator State`。

### Keyed State
### Keyed 状态

_Keyed State_ 总是相对于键的，只能在 `KeyedStream`上的函数和运算符中使用。

您可以将键控状态视为已分区或分块的运算符状态，每个键只使用一个状态分区。每个键状态在逻辑上绑定到一个唯一的并行操作符-实例(key&gt；)的组合，而且由于每个键“属于”一个键控操作符的一个并行实例，我们可以简单地将其看作是&lt；操作符、key&gt；。

密钥状态进一步被组织成所谓的_Key Groups_。键组是flink可以重新分配键状态的原子单元；与定义的最大并行度完全一样多的键组。在执行过程中，键操作器的每个并行实例与一个或多个键组的键一起工作。

### Operator 状态

使用 _Operator State_ (或 _non-keyed state_ )，每个运算符状态都绑定到一个并行运算符实例。[KafkaConnector](//ci.apache.org/projects/flink/flink-docs-release-1.7/dev/connectors/kafka.html)是在FLink中使用运算符状态的良好激励示例。Kafka消费者的每个并行实例将主题分区和偏移的映射保持为其运营商状态。

当并行度被改变时，操作员状态接口支持并行操作员实例之间的重新分配状态。可以有用于执行这种再分配的不同方案。

## Raw and Managed State 原始和托管状态

_Keyed State_ 和 _Operator State_ 以两种形式存在： _managed_ 和 _raw_。

_Managed State_ 在由FLink运行时控制的数据结构中表示，如内部哈希表或RocksDB。示例是“ValueState”, “ListState”等。flink的运行时对状态进行编码，并将它们写入检查点。

_Raw State_ 是运算符保留在自己的数据结构中的状态。检查点时，它们只将一个字节序列写入检查点。flink对状态的数据结构一无所知，只看到原始字节。

所有数据流函数都可以使用托管状态，但在实现运算符时，只能使用原始状态接口。建议使用托管状态（而不是原始状态），因为托管状态flink能够在并行度更改时自动重新分发状态，并且还可以实现更好的内存管理。v

如果您的托管状态需要自定义序列化逻辑，请参阅[相应的指南](customalization.html)，以确保将来的兼容性。Flink的默认序列化程序不需要特殊处理。

## Using Managed Keyed State 使用托管密钥状态

托管键状态接口提供对不同类型状态的访问，这些状态的作用域都是当前输入元素的键。这意味着这种状态只能在`KeyedStream`上使用，而“KeyedStream”可以通过`stream.keyBy(…)`创建。

现在，我们将首先查看可用的不同类型的状态，然后我们将看到它们如何在程序中使用。可用的状态基元是：

*   `ValueState&lt;T&gt;`:这将保存一个可以更新和检索的值（范围为上述输入元素的关键字，因此可能有一个值用于操作所看到的每个键）。可以使用 `update(T)` 来设置值，并使用 `T value()`检索该值。

*   `ListState&lt;T&gt;`: 这将保留元素的列表。可以在所有当前存储的元素上附加元素并检索`Iterable`。使用`add(T)` 或`addAll(List&lt;T&gt;)`添加元素，可使用`Iterable&lt;T&gt; get()`来检索可迭代的元素。也可以使用 `update(List&lt;T&gt;)`覆盖现有列表“”

*   `ReducingState&lt;T&gt;`:这保留了一个表示添加到状态的所有值的聚合的值。接口类似于 `ListState` ，但使用`add(T)`添加的元素将使用指定的`ReduceFunction`还原为聚合。

*   `AggregatingState&lt;IN, OUT&gt;`: 这保留了一个表示添加到状态的所有值的聚合的值。与 `ReducingState`相反，聚合类型可能与添加到状态的元素类型不同。接口与 `ListState` 相同，但使用`add(IN)` 添加的元素使用指定的 `AggregateFunction`进行聚合。

*   `FoldingState&lt;T, ACC&gt;`: 这保留了一个值，表示添加到状态的所有值的聚合。与 `ReducingState`相反，聚合类型可能与添加到状态的元素类型不同。接口类似于 `ListState` ，但是使用 `add(T)` 添加的元素使用指定的 `FoldFunction`折叠成一个聚合。

*   `MapState&lt;UK, UV&gt;`: 这保存了一个映射列表。您可以将键值对放入状态，并在所有当前存储的映射上检索 `Iterable` 。映射使用 `put(UK, UV)` 或 `putAll(Map&lt;UK, UV&gt;)`添加。可以使用`get(UK)`检索与用户密钥相关的值。映射、键和值的可迭代视图可以分别使用`entry()`、`key()`和 `values()`检索。

所有类型的状态都有一个方法 `clear()`，用于清除当前活动键的状态，即输入元素的键。

`FoldingState` 和 `FoldingStateDescriptor` 已在Flink 1.4中被废弃，并将在今后完全删除。请使用`AggregatingState` 和`AggregatingStateDescriptor` 。

重要的是要记住，这些状态对象仅用于与状态进行接口。状态不一定存储在内部，但可能驻留在磁盘或其他地方。要记住的第二件事是，从状态中得到的值取决于输入元素的键。因此，如果所涉及的键不同，则在用户函数的一次调用中获得的值可能与另一次调用中的值不同。

要获得状态句柄，必须创建一个`StateDescriptor`。这保存了状态的名称(我们稍后会看到，您可以创建几个状态，它们必须有唯一的名称，以便您可以引用它们)、状态所持有的值的类型，以及可能是用户指定的函数，例如`ReduceFunction`。根据要检索的状态类型，可以创建 `ValueStateDescriptor`, a `ListStateDescriptor`, a `ReducingStateDescriptor`, a `FoldingStateDescriptor` or a `MapStateDescriptor`。

状态是使用`RuntimeContext`访问的，因此它只能在_rich functions_中使用。有关这方面的信息，请参阅[here](//ci.apache.org/projects/flink/flink-docs-release-1.7/dev/api_concepts.html#rich-functions)，但我们不久也将看到一个示例。在 `RuntimeContext` 中可用的`RichFunction` 具有以下访问状态的方法：

*   `ValueState&lt;T&gt; getState(ValueStateDescriptor&lt;T&gt;)`
*   `ReducingState&lt;T&gt; getReducingState(ReducingStateDescriptor&lt;T&gt;)`
*   `ListState&lt;T&gt; getListState(ListStateDescriptor&lt;T&gt;)`
*   `AggregatingState&lt;IN, OUT&gt; getAggregatingState(AggregatingStateDescriptor&lt;IN, ACC, OUT&gt;)`
*   `FoldingState&lt;T, ACC&gt; getFoldingState(FoldingStateDescriptor&lt;T, ACC&gt;)`
*   `MapState&lt;UK, UV&gt; getMapState(MapStateDescriptor&lt;UK, UV&gt;)`

这是一个`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(0)
        .flatMap(new CountWindowAverage())
        .print();

// the printed output will be (1,4) and (1,5)
```





```
class CountWindowAverage extends RichFlatMapFunction[(Long, Long), (Long, Long)] {

  private var sum: ValueState[(Long, Long)] = _

  override def flatMap(input: (Long, Long), out: Collector[(Long, Long)]): Unit = {

    // access the state value
    val tmpCurrentSum = sum.value

    // If it hasn't been used before, it will be null
    val currentSum = if (tmpCurrentSum != null) {
      tmpCurrentSum
    } else {
      (0L, 0L)
    }

    // update the count
    val newSum = (currentSum._1 + 1, currentSum._2 + input._2)

    // update the state
    sum.update(newSum)

    // if the count reaches 2, emit the average and clear the state
    if (newSum._1 >= 2) {
      out.collect((input._1, newSum._2 / newSum._1))
      sum.clear()
    }
  }

  override def open(parameters: Configuration): Unit = {
    sum = getRuntimeContext.getState(
      new ValueStateDescriptor[(Long, Long)]("average", createTypeInformation[(Long, Long)])
    )
  }
}

object ExampleCountWindowAverage extends App {
  val env = StreamExecutionEnvironment.getExecutionEnvironment

  env.fromCollection(List(
    (1L, 3L),
    (1L, 5L),
    (1L, 7L),
    (1L, 4L),
    (1L, 2L)
  )).keyBy(_._1)
    .flatMap(new CountWindowAverage())
    .print()
  // the printed output will be (1,4) and (1,5) 
  env.execute("ExampleManagedState")
}
```



这个例子实现了一个穷人的计数窗口。我们用第一个字段来键入元组(在示例中，所有元组都有相同的键 `1`)。函数将计数和运行的和存储在“ValueState”中。一旦计数达到2，它将发出平均值并清除状态，以便我们从 `0`开始。注意，如果在第一个字段中有不同值的元组，这将为每个不同的输入键保留不同的状态值。

### State Time-To-Live (TTL) 状态生存时间(TTL)

a _time-to-live_ (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);
```





```
import org.apache.flink.api.common.state.StateTtlConfig
import org.apache.flink.api.common.state.ValueStateDescriptor
import org.apache.flink.api.common.time.Time

val ttlConfig = StateTtlConfig
    .newBuilder(Time.seconds(1))
    .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
    .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
    .build

val stateDescriptor = new ValueStateDescriptor[String]("text state", classOf[String])
stateDescriptor.enableTimeToLive(ttlConfig)
```



该配置有多个选项可考虑：

 `newBuilder` 方法的第一个参数是强制性的，它是实时值。

更新类型在刷新状态TTL时配置（默认为 `OnCreateAndWrite`）：

*   `StateTtlConfig.UpdateType.OnCreateAndWrite` - 仅在创建和写入权限时
*   `StateTtlConfig.UpdateType.OnReadAndWrite` - 也是关于读访问

如果尚未清除过期值，则状态可见性将配置是否在读取访问中返回过期值(默认情况下，`NeverReturnExpired`)：

*   `StateTtlConfig.StateVisibility.NeverReturnExpired` - 过期的值永远不会返回。
*   `StateTtlConfig.StateVisibility.ReturnExpiredIfNotCleanedUp` - 如果仍然可用的话返回

在`NeverReturnExpired`的情况下，过期状态的行为就好像它不再存在了，即使它仍然必须被移除。对于数据必须在TTL之后才能读取访问的用例来说，该选项是有用的，例如。处理隐私敏感数据的应用程序。

另一个选项`ReturnExpiredIfNotCleanedUp` 允许在清理之前返回过期状态。

**注意:**

*   状态后端将上次修改的时间戳与用户值一起存储，这意味着启用此功能会增加状态存储的消耗。堆状态后端存储具有对用户状态对象的引用和在存储器中的原始长值的附加Java对象。ROCKSDB状态后端根据存储的值、列表条目或映射条目添加8个字节。

*   当前只支持引用 _processing time_ 的TTL。

*   尝试还原以前没有TTL配置的状态，使用启用TTL的描述符(反之亦然)将导致兼容性失败和 `StateMigrationException`。

*   TTL配置不是Check-或Savepoint的一部分，而是Flink在当前运行的作业中如何对待它的一种方式。

*   只有当用户值序列化程序能够处理空值时，TTL的映射状态才支持空用户值。如果序列化程序不支持空值，则可以使用 `NullableSerializer` 包装它，代价是序列化形式中的额外字节。

#### Cleanup of Expired State 清除过期状态

当前，只有在显式读取过期值（例如，通过调用`ValueState.value()`）时，才会删除过期值。

注意，这意味着默认情况下，如果未读取过期状态，则不会删除它，可能会导致状态不断增长。这可能会在未来的版本中发生变化。

此外，您可以在获取将减小其大小的完整状态快照时激活清理。在当前实现下不清除本地状态，但在从上一个快照恢复的情况下，它将不包括已删除的过期状态。它可以在 `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();
```





```
import org.apache.flink.api.common.state.StateTtlConfig
import org.apache.flink.api.common.time.Time

val ttlConfig = StateTtlConfig
    .newBuilder(Time.seconds(1))
    .cleanupFullSnapshot
    .build
```



此选项不适用于RocksDB状态后端中的增量检查点。

更多的策略将添加在未来的清理过期状态自动在后台。

### State in the Scala DataStream API Scala 数据流API中的状态

除了上面描述的接口之外，Scala API还提供了有状态的`map()` 或`flatMap()`函数的快捷方式，其中只有一个`KeyedStream`上的`ValueState` 函数。用户函数在 `Option` 中获取 `ValueState` 的当前值，并且必须返回将用于更新状态的更新值。



```
val stream: DataStream[(String, Int)] = ...

val counts: DataStream[(String, Int)] = stream
  .keyBy(_._1)
  .mapWithState((in: (String, Int), count: Option[Int]) =>
    count match {
      case Some(c) => ( (in._1, c), Some(c + in._2) )
      case None => ( (in._1, 0), Some(in._2) )
    })
```



## Using Managed Operator State 使用托管运营商状态

To use managed operator state, a stateful function can implement either the more general `CheckpointedFunction` interface, or the `ListCheckpointed&lt;T extends Serializable&gt;` interface.
要使用托管操作符状态，有状态函数可以实现更通用的 `CheckpointedFunction` 接口，也可以实现 `ListCheckpointed&lt;T extends Serializable&gt;`接口。

#### CheckpointedFunction 校验点函数

`CheckpointedFunction`接口提供了对具有不同再分配方案的非键控状态的访问。它需要实施两种方法：



```
void snapshotState(FunctionSnapshotContext context) throws Exception;

void initializeState(FunctionInitializationContext context) throws Exception;
```



必须执行检查点时，调用`snapshotState()`。对应的`initializeState()`在每次用户定义的函数被初始化时被调用，当函数首先被初始化时，或者当函数实际从较早的检查点恢复时。因此， `initializeState()` 不仅是不同类型状态被初始化的地方，而且还包括其中包括状态恢复逻辑的地方。

当前，支持列表样式的托管运算符状态。该状态应为 _serializable_ object的 `List`，彼此独立，因此在重新调用时符合重新分发的条件。换句话说，这些对象是可以重新分配非键控状态的最佳粒度。根据状态访问方法，定义了以下重新分配方案：

*   **Even-split redistribution 偶数再分配:** 每个操作符返回一个状态元素列表。整个状态在逻辑上是所有列表的连接。在恢复/重新分配时，列表被平均地划分为与并行运算符相同的子列表。每个运算符都会获得一个子列表，该子列表可以是空的，也可以包含一个或多个元素。例如，如果使用并行主义1，运算符的校验点状态包含元素`element1` 和  `element2` ，则当将并行性增加到2时，`element1` 可能最终出现在运算符实例0中，而`element2`将转到运算符`element1` 。

*   **Union redistribution UNION再分配 :** 每个运算符返回一个状态元素列表。整个状态在逻辑上是所有列表的连接。在恢复/重新分配时，每个运算符都会获得状态元素的完整列表。

下面是一个有状态的`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) 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);
            }
        }
    }
}
```





```
class BufferingSink(threshold: Int = 0)
  extends SinkFunction[(String, Int)]
    with CheckpointedFunction {

  @transient
  private var checkpointedState: ListState[(String, Int)] = _

  private val bufferedElements = ListBuffer[(String, Int)]()

  override def invoke(value: (String, Int)): Unit = {
    bufferedElements += value
    if (bufferedElements.size == threshold) {
      for (element <- bufferedElements) {
        // send it to the sink
      }
      bufferedElements.clear()
    }
  }

  override def snapshotState(context: FunctionSnapshotContext): Unit = {
    checkpointedState.clear()
    for (element <- bufferedElements) {
      checkpointedState.add(element)
    }
  }

  override def initializeState(context: FunctionInitializationContext): Unit = {
    val descriptor = new ListStateDescriptor[(String, Int)](
      "buffered-elements",
      TypeInformation.of(new TypeHint[(String, Int)]() {})
    )

    checkpointedState = context.getOperatorStateStore.getListState(descriptor)

    if(context.isRestored) {
      for(element <- checkpointedState.get()) {
        bufferedElements += element
      }
    }
  }

}
```



`initializeState`方法以 `FunctionInitializationContext`作为参数。这用于初始化无键状态的“containers”。这是一个类型为 `ListState` 的容器，在该容器中，无键状态对象将在检查点时存储。

请注意如何初始化状态，类似于键控状态，使用`StateDescriptor`，其中包含状态名称和有关状态所持有值的类型的信息：



```
ListStateDescriptor<Tuple2<String, Integer>> descriptor =
    new ListStateDescriptor<>(
        "buffered-elements",
        TypeInformation.of(new TypeHint<Tuple2<Long, Long>>() {}));

checkpointedState = context.getOperatorStateStore().getListState(descriptor);
```





```
val descriptor = new ListStateDescriptor[(String, Long)](
    "buffered-elements",
    TypeInformation.of(new TypeHint[(String, Long)]() {})
)

checkpointedState = context.getOperatorStateStore.getListState(descriptor)
```



状态访问方法的命名约定包含它的重新分布模式，然后是它的状态结构。例如，要在RESTORE上使用LIST状态和联合重新分配方案，可以使用`getUnionListState(descriptor)`访问状态。如果方法名称不包含重新分配模式，则仅意味着将使用基本的均匀再分配方案。

初始化容器后，我们使用上下文的`isRestored()`方法检查故障后是否正在恢复。如果这是`true`，_即_we正在恢复，则应用恢复逻辑。

如修改的`BufferingSink`的代码所示，在状态初始化过程中恢复的这个`ListState`保存在类变量中，以便将来在 `snapshotState()`中使用。在那里，`ListState` 被清除了以前检查点所包含的所有对象，然后用我们想要检查点的新对象来填充。

作为一个侧面注释，键控状态也可以在`initializeState()`方法中初始化。这可以使用提供的`FunctionInitializationContext`来完成。

#### ListCheckpointed 列表校验点

`ListCheckpointed`接口是`CheckpointedFunction`的一个更有限的变体，它只支持列表样式的状态，在还原时采用均匀分割的重新分配方案。它还要求采用两种方法：



```
List<T> snapshotState(long checkpointId, long timestamp) throws Exception;

void restoreState(List<T> state) throws Exception;
```



在 `snapshotState()`上，操作符应该将对象列表返回给检查点，`restoreState` 在恢复时必须处理这样的列表。如果状态不可再分区，则始终可以在`snapshotState()`中返回`Collections.singletonList(MY_STATE)`。

### Stateful Source Functions 有状态源函数

与其他操作符相比，有状态源需要更多的注意。为了使状态和输出集合的更新是原子的(在失败/恢复时只需要一次语义)，用户需要从源的上下文中获得一个锁。



```
public static class CounterSource
        extends RichParallelSourceFunction<Long>
        implements ListCheckpointed<Long> {

    /**  current offset for exactly once semantics */
    private Long offset;

    /** flag for job cancellation */
    private volatile boolean isRunning = true;

    @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 List<Long> snapshotState(long checkpointId, long checkpointTimestamp) {
        return Collections.singletonList(offset);
    }

    @Override
    public void restoreState(List<Long> state) {
        for (Long s : state)
            offset = s;
    }
}
```





```
class CounterSource
       extends RichParallelSourceFunction[Long]
       with ListCheckpointed[Long] {

  @volatile
  private var isRunning = true

  private var offset = 0L

  override def run(ctx: SourceFunction.SourceContext[Long]): Unit = {
    val lock = ctx.getCheckpointLock

    while (isRunning) {
      // output and state update are atomic
      lock.synchronized({
        ctx.collect(offset)

        offset += 1
      })
    }
  }

  override def cancel(): Unit = isRunning = false

  override def restoreState(state: util.List[Long]): Unit =
    for (s <- state) {
      offset = s
    }

  override def snapshotState(checkpointId: Long, timestamp: Long): util.List[Long] =
    Collections.singletonList(offset)

}
```



当一个检查点被flink完全确认以与外部世界进行通信时，一些运营商可能需要该信息。在这种情况下，请参见 `org.apache.flink.runtime.state.CheckpointListener` 界面。

