(八)Flink DataStream API 编程指南 - 4 状态与容错 (上)

Keyed DataStream

如果你希望使用 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);

Tuple Keys 和 Expression Keys

Flink 也有两种不同定义 key 的方式:Java/Scala API(Python API 仍未支持) 的 Tuple key(通过字段索引指定的 key)和 Expression key(通过字段名称指定的 key)。 借此你可以通过 tuple 字段索引,或者是选取对象字段的表达式来指定 key。 如今我们不建议这样使用,但你可以参考 DataStream 的 Javadoc 来了解它们。 使用 KeySelector 函数显然是更好的。以几乎可以忽略的额外开销为代价,结合 Java Lambda 表达式,我们可以更方便得使用KeySelector。

使用 Keyed State

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: 保留一个单值,表示添加到状态的所有值的聚合。和 ReducingState 相反的是, 聚合类型可能与 添加到状态的元素的类型不同。 接口与 ListState 类似,但使用 add(IN) 添加的元素会用指定的 AggregateFunction 进行聚合。

  • MapState: 维护了一个映射列表。 你可以添加键值对到状态中,也可以获得反映当前所有映射的迭代器。使用 put(UK,UV) 或者 putAll(Map) 添加映射。 使用 get(UK) 检索特定 key。 使用 entries(),keys() 和 values() 分别检索映射、键和值的可迭代视图。你还可以通过 isEmpty() 来判断是否包含任何键值对。

所有类型的状态还有一个clear() 方法,清除当前 key 下的状态数据,也就是当前输入元素的 key。

请牢记,这些状态对象仅用于与状态交互。状态本身不一定存储在内存中,还可能在磁盘或其他位置。 另外需要牢记的是从状态中获取的值取决于输入元素所代表的 key。 因此,在不同 key 上调用同一个接口,可能得到不同的值。

你必须创建一个 StateDescriptor,才能得到对应的状态句柄。 这保存了状态名称(正如我们稍后将看到的,你可以创建多个状态,并且它们必须具有唯一的名称以便可以引用它们), 状态所持有值的类型,并且可能包含用户指定的函数,例如ReduceFunction。 根据不同的状态类型,可以创建ValueStateDescriptor,ListStateDescriptor, AggregatingStateDescriptor, ReducingStateDescriptor 或 MapStateDescriptor。

状态通过 RuntimeContext 进行访问,因此只能在 rich functions 中使用。请参阅这里获取相关信息, 但是我们很快也会看到一个例子。RichFunction 中 RuntimeContext 提供如下方法:

  • ValueState getState(ValueStateDescriptor)
  • ReducingState getReducingState(ReducingStateDescriptor)
  • ListState getListState(ListStateDescriptor)
  • AggregatingState getAggregatingState(AggregatingStateDescriptor)
  • MapState getMapState(MapStateDescriptor)

下面是一个 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。这意味着列表元素和映射条目独立过期。

为了使用状态 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.UpdateType.OnCreateAndWrite- 仅在创建和写入权限时
  • StateTtlConfig.UpdateType.OnReadAndWrite- 也有读取权限

(注意:如果StateTtlConfig.StateVisibility.ReturnExpiredIfNotCleanedUp 同时设置状态可见性为,状态读取缓存将被禁用,这会导致 PyFlink 中的一些性能损失)

状态可见性配置是否在读取访问时返回过期值(如果尚未清理)(默认情况下NeverReturnExpired):

  • StateTtlConfig.StateVisibility.NeverReturnExpired- 永远不会返回过期值

(注意:状态读/写缓存将被禁用,这会导致 PyFlink 中的一些性能损失)

  • StateTtlConfig.StateVisibility.ReturnExpiredIfNotCleanedUp- 如果仍然可用,则返回

在 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();

笔记:

  • 如果没有访问该状态或没有处理任何记录,则过期状态将持续存在。
  • 用于增量清理的时间会增加记录处理延迟。
  • 目前仅对堆状态后端实施增量清理。为 RocksDB 设置它不会有任何效果。
  • 如果堆状态后端与同步快照一起使用,全局迭代器会在迭代时保留所有键的副本,因为它的特定实现不支持并发修改。启用此功能将增加内存消耗。异步快照没有这个问题。
  • 对于现有作业,可以随时激活或停用此清理策略StateTtlConfig,例如从保存点重新启动后。
    RocksDB 压缩期间的清理 #
    如果使用 RocksDB 状态后端,则会调用 Flink 特定的压缩过滤器进行后台清理。RocksDB 定期运行异步压缩以合并状态更新并减少存储。Flink 压缩过滤器检查具有 TTL 的状态条目的过期时间戳并排除过期值。

可以在以下位置配置此功能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

笔记:

  • 在压缩期间调用 TTL 过滤器会减慢它的速度。TTL 过滤器必须解析上次访问的时间戳,并检查每个正在压缩的键的每个存储状态条目的过期时间。在集合状态类型(列表或地图)的情况下,还会针对每个存储的元素调用检查。
  • 如果此功能与具有非固定字节长度元素的列表状态一起使用,则本机 TTL 过滤器必须通过 JNI 为每个至少第一个元素已过期到的每个状态条目额外调用该元素的 Flink java 类型序列化器确定下一个未过期元素的偏移量。
  • 对于现有作业,可以随时激活或停用此清理策略StateTtlConfig,例如从保存点重新启动后。

Scala DataStream API 中的状态

除了上述接口之外,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的一种特殊类型。引入它是为了支持一个流的记录需要广播到所有下游任务的用例,它们用于在所有子任务之间保持相同的状态。然后可以在处理第二个流的记录时访问此状态。作为一个广播状态可以自然出现的例子,可以想象一个低吞吐量的流包含一组规则,我们希望针对来自另一个流的所有元素评估这些规则。考虑到上述类型的用例,广播状态与其他运营商状态的不同之处在于:

  1. 它有地图格式,
  2. 它仅适用于将 广播流和非广播流作为输入的特定operator算子,并且
  3. 这样的operator算子可以有多个不同名称的广播状态。

注意: Python DataStream API 仍然不支持广播状态。

使用运算符状态

要使用操作符状态,有状态函数可以实现该CheckpointedFunction 接口。

检查点函数

该CheckpointedFunction接口提供对具有不同重新分配方案的非键控状态的访问。它需要实现两种方法:

  • void snapshotState(FunctionSnapshotContext context) throws Exception;
  • void initializeState(FunctionInitializationContext context) throws Exception;

每当必须执行检查点时,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界面。

你可能感兴趣的:(flink,flink)