(八)Flink DataStream API 编程指南 - 4 状态与容错 (下)-- 数据类型与序列化

概览

数据类型 & 序列化

Apache Flink以一种独特的方式处理数据类型和序列化,它包含自己的类型描述符、泛型类型提取和类型序列化框架。本文档描述了这些概念及其背后的基本原理。

支持的数据类型

Flink对DataStream中的元素类型有一些限制。这样做的原因是系统分析类型来确定有效的执行策略。

以下是7类数据类型:

  1. Java Tuples and Scala Case Classes
  2. Java POJOs
  3. 基本数据类型
  4. Regular Classes(普通类)
  5. Values
  6. Hadoop Writables(支持hadoop中实现了org.apache.hadoop.Writable的数据类型)
  7. Special Types(例如scala中的Either Option 和Try)

Tuples and Case Classes

元组是复合类型,包含固定数量的具有各种类型的字段。Java API提供了从Tuple1到Tuple25的类。元组的每个字段都可以是任意的Flink类型,包括更多的元组,从而产生嵌套元组。元组获取值有两种方式,分别是tuple.f4, 或者使用tuple.getField(int position),字段索引从0开始。注意,这与Scala元组是相反的,但它与Java的通用索引更一致。

package com.flink.datastream;

import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.KeyedStream;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;

/**
 * @author DeveloperZJQ
 * @since 2022-5-30
 */
public class TupleDemo {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        DataStreamSource<Tuple2<String, Integer>> wordCounts = env.fromElements(
                new Tuple2<>("hello", 1),
                new Tuple2<>("world", 2));
        SingleOutputStreamOperator<Integer> map = wordCounts.map((MapFunction<Tuple2<String, Integer>, Integer>) value -> value.f1);
        KeyedStream<Tuple2<String, Integer>, String> keyBy = wordCounts.keyBy(one -> one.f0);
        map.print();
        keyBy.print();

        env.execute();
    }
}

POJOs

如果Java和Scala类满足以下要求,Flink会将它们视为特殊的POJO数据类型:

  • 类是public的
  • 类有默认构造方法(公共的空构造)
  • 所有字段要么是公共的,要么必须通过getter和setter方法访问对于一个名为foo的字段,getter和setter方法必须命名为getFoo()和setFoo()。
  • 已注册的序列化程序必须支持字段的类型。

pojo通常用PojoTypeInfo表示,并用PojoSerializer序列化(使用Kryo作为可配置的回退)。例外情况是pojo实际上是Avro类型(Avro Specific Records)或作为“Avro Reflect types”产生。在这种情况下,POJO是由AvroTypeInfo表示的,并用AvroSerializer序列化。如果需要,您还可以注册自己的自定义序列化器;有关更多信息,请参阅序列化。

Flink分析POJO类型的结构,即了解POJO的字段。因此,POJO类型比一般类型更容易使用。此外,Flink可以比一般类型更有效地处理pojo。

下面的示例展示了一个具有两个公共字段的简单POJO。

public class WordWithCount {

    public String word;
    public int count;

    public WordWithCount() {}

    public WordWithCount(String word, int count) {
        this.word = word;
        this.count = count;
    }
}

DataStream<WordWithCount> wordCounts = env.fromElements(
    new WordWithCount("hello", 1),
    new WordWithCount("world", 2));

wordCounts.keyBy(value -> value.word);

基本数据类型

Flink支持所有的Java和Scala原始类型,如Integer, String和Double。

通用类类型

Flink支持大多数Java和Scala类(API和自定义)。限制适用于包含不能序列化字段的类,如文件指针、I/O流或其他本机资源。一般来说,遵循Java bean约定的类工作得很好。
所有未标识为POJO类型的类(请参阅上面的POJO要求)都由Flink作为通用类类型处理。Flink将这些数据类型视为黑盒,不能访问它们的内容(例如,为了高效排序)。一般类型使用序列化框架Kryo进行反/序列化。

Values

值类型手动描述它们的序列化和反序列化。它们没有使用通用的序列化框架,而是通过实现带有读写方法的org.apache.flink.types.Value接口,为这些操作提供了自定义代码。当通用序列化效率极低时,使用Value类型是合理的。一个例子是将元素的稀疏向量实现为数组的数据类型。由于知道数组大部分为零,可以对非零元素使用特殊编码,而通用的序列化只会写入所有数组元素。

org.apache.flink.types.CopyableValue接口以类似的方式支持手动内部克隆逻辑。

Flink自带预定义的值类型,对应于基本数据类型。(ByteValue, ShortValue, IntValue, LongValue, FloatValue, DoubleValue, StringValue, CharValue, BooleanValue)这些值类型充当基本数据类型的可变变体:它们的值可以更改,从而允许程序员重用对象,减轻垃圾收集器的压力。

Hadoop Writables

您可以使用实现org.apache.hadoop.Writable接口的类型。在write()和readFields()方法中定义的序列化逻辑将用于序列化。

Special Types

你可以使用特殊的类型,包括Scala的Either、Option和Try。Java API有它自己的自定义的Either实现。与Scala的Either类似,它表示两种可能类型的值,Left或Right。对于错误处理或需要输出两种不同类型记录的操作符,这两种方法都很有用。

类型推断 & 类型引用

注意:本节仅与Java相关。

Java编译器在编译后会丢弃很多泛型类型信息。这在Java中称为类型擦除。这意味着在运行时,对象的实例不再知道其泛型类型。例如,DataStream和DataStream的实例在JVM上看起来是相同的。

Flink在准备程序执行时(程序的主方法被调用时)需要类型信息。Flink Java API试图重新构建以各种方式丢弃的类型信息,并显式地将其存储在数据集和操作符中。您可以通过DataStream.getType()检索类型。该方法返回TypeInformation的一个实例,这是Flink表示类型的内部方式。

类型推断有其局限性,在某些情况下需要程序员的“配合”。例如从集合中创建数据集的方法,例如StreamExecutionEnvironment.fromCollection(),您可以在其中传递一个描述类型的参数。但是像MapFunction<I, O>这样的泛型函数可能需要额外的类型信息。

ResultTypeQueryable接口可以通过输入格式和函数来实现,以显式地告诉API它们的返回类型。调用函数的输入类型通常可以通过前面操作的结果类型推断出来。

Flink中的类型处理

Flink试图推断出在分布式计算期间交换和存储的数据类型的大量信息。可以把它想象成一个推断表模式的数据库。在大多数情况下,Flink可以自己无缝地推断出所有必要的信息。拥有类型信息可以让Flink做一些很酷的事情:

  • Flink对数据类型了解得越多,序列化和数据布局方案就越好。这对于Flink中的内存使用范式非常重要(尽可能在堆内外处理序列化数据,并使序列化非常便宜)。
  • 在大多数情况下,它还使用户不必担心序列化框架和注册类型。

一般来说,在执行前阶段 - 即在程序调用DataStream时,以及在调用execute()、print()、count()或collect()之前,需要有关数据类型的信息。

最常见问题

用户需要与Flink的数据类型处理进行交互时,最常见的问题是:

  • 注册子类型: 如果函数签名只描述超类型,但实际上在执行过程中使用了这些超类型的子类型,那么让Flink知道这些子类型可能会大大提高性能。为此,在StreamExecutionEnvironment上为每个子类型调用. registertype (clazz)。
  • 注册自定义序列化器:Flink退回到Kryo来处理它自己不能透明处理的类型。并不是所有类型都能被Kryo无缝处理(Flink也不例外)。例如,许多谷歌Guava收集类型在默认情况下不能很好地工作。解决方案是为导致问题的类型注册额外的序列化器。StreamExecutionEnvironment调用.getConfig().addDefaultKryoSerializer(clazz, serializer)。在许多库中都有其他的Kryo序列化器。有关使用外部序列化器的详细信息,请参阅第三方序列化器。
  • 添加类型提示:有时,当Flink尽管使用了各种技巧但仍无法推断泛型类型时,用户必须传递类型提示。这通常只在Java API中需要。类型提示部分对此进行了更详细的描述。
  • 手动创建类型信息:对于某些API调用,由于Java泛型类型擦除,Flink无法推断数据类型时,这可能是必要的。详细信息请参见创建类型信息或TypeSerializer。

Flink’s TypeInformation class

类TypeInformation是所有类型描述符的基类。它揭示了类型的一些基本属性,并可以生成序列化器,在专门化中,还可以生成类型的比较器。(注意,Flink中的比较器不仅仅是定义一个顺序——它们基本上是处理键的实用程序)

在内部,Flink对类型进行了以下区分:

  • 基本类型:所有Java原语及其装箱形式,加上void、String、Date、BigDecimal和BigInteger。
  • 基本数组和对象数组
  • 组合类型(java tuple,scala case class,Row,POJOS)
  • 其他类型也叫辅助类型(Option, Either, Lists, Maps, …)
  • 泛型类型:这些类型不会被Flink本身序列化,而是由Kryo序列化。

pojo特别有趣,因为它们支持创建复杂类型。它们对运行时也是透明的,可以通过Flink非常有效地处理。

POJO类型的规则

如果满足以下条件,Flink将数据类型识别为POJO类型(并允许“by-name”字段引用):

  • 该类是公共且独立的(没有非静态内部类)
  • 该类有一个公共的无参数构造函数
  • 类(以及所有超类)中的所有非静态、非瞬态字段要么是公共的(而且是非final的),要么有一个公共的getter和setter方法,该方法遵循Java bean对getter和setter的命名约定。

注意,当用户定义的数据类型不能被识别为POJO类型时,必须将其处理为GenericType并使用Kryo进行序列化。

创建一个TypeInformation or TypeSerializer

要为类型创建TypeInformation对象,请使用特定于语言的方式:
因为Java通常会擦除泛型类型信息,所以需要将类型传递给TypeInformation构造:
对于非泛型类型,你可以传递Class:

TypeInformation<String> info = TypeInformation.of(String.class);

对于泛型类型,您需要通过TypeHint“捕获”泛型类型信息:

TypeInformation<Tuple2<String, Double>> info = TypeInformation.of(new TypeHint<Tuple2<String, Double>>(){});

在内部,这创建了TypeHint的一个匿名子类,用于捕获泛型信息,并将其保存到运行时。

要创建一个TypeSerializer,只需在TypeInformation对象上调用typeInfo.createSerializer(config)。

config参数的类型为ExecutionConfig,保存有关程序注册的自定义序列化器的信息。在任何可能的地方,试着给程序传递正确的ExecutionConfig。您通常可以通过调用getExecutionConfig()从DataStream获得它。在函数内部(如MapFunction),您可以通过将函数设置为Rich function并调用getRuntimeContext(). getexecutionconfig()来获得它。

Java API中的类型信息

在一般情况下,Java会擦除泛型类型信息。Flink试图通过反射重构尽可能多的类型信息,使用Java保留的少量信息(主要是函数签名和子类信息)。对于函数的返回类型依赖于输入类型的情况,此逻辑还包含一些简单的类型推断:

public class AppendOne<T> implements MapFunction<T, Tuple2<T, Long>> {

    public Tuple2<T, Long> map(T value) {
        return new Tuple2<T, Long>(value, 1L);
    }
}

在某些情况下,Flink无法重建所有泛型类型信息。在这种情况下,用户必须通过键入提示来帮助解决。

Type Hints in the Java API

在Flink无法重建被擦除的泛型类型信息的情况下,Java API提供了所谓的类型提示。类型提示告诉系统由函数产生的数据流或数据集的类型:

DataStream<SomeType> result = stream
    .map(new MyGenericNonInferrableFunction<Long, SomeType>())
        .returns(SomeType.class);

returns 语句指定生成的类型,在本例中是通过一个类。提示支持通过

  • 类,用于非参数化类型(无泛型)
  • TypeHints 形式为返回(new TypeHint<Tuple2<Integer, SomeType>>(){})。TypeHint类可以捕获泛型类型信息并在运行时保留它(通过一个匿名子类)。

Java 8 lambdas的类型提取

Java 8 lambdas的类型提取与非lambdas的工作方式不同,因为lambdas不与扩展函数接口的实现类相关联。

目前,Flink试图找出哪个方法实现了lambda,并使用Java的泛型签名来确定参数类型和返回类型。但是,并非所有编译器都为lambdas生成这些签名。如果观察到意外行为,请使用returns方法手动指定返回类型。

POJO类型的序列化

PojoTypeInfo正在为POJO内的所有字段创建序列化器。标准类型如int、long、String等由Flink附带的序列化器处理。对于所有其他类型,我们回到Kryo。

如果Kryo不能处理该类型,您可以要求PojoTypeInfo使用Avro序列化POJO。要这样做,你必须代码声明

final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.getConfig().enableForceAvro();

注意,Flink会使用Avro序列化器自动序列化Avro生成的pojo。

如果您想让Kryo序列化器处理整个POJO类型,设置

final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.getConfig().enableForceKryo();

如果Kryo不能序列化您的POJO,您可以使用

env.getConfig().addDefaultKryoSerializer(Class<?> type, Class<? extends Serializer<?>> serializerClass);

这些方法有不同的变体。

禁用Kryo回退

在某些情况下,程序可能希望显式地避免使用Kryo作为泛型类型的回退。最常见的一种是希望确保通过Flink自己的序列化器或用户定义的自定义序列化器有效地序列化所有类型。

每当遇到经过Kryo的数据类型时,下面的设置将引发异常:

env.getConfig().disableGenericTypes();

使用工厂定义类型信息

类型信息工厂允许将用户定义的类型信息插入到Flink类型系统中。你必须实现org.apache.flink.api.common.typeinfo.TypeInfoFactory来返回你的自定义类型信息。如果相应的类型或使用此类型的POJO的字段已经使用@org.apache.flink.api.common.typeinfo.TypeInfo注释,则在类型提取阶段调用该工厂。

类型信息工厂可以在Java和Scala API中使用。

在类型层次结构中,向上遍历时将选择距离最近的工厂,但是内置工厂的优先级最高。工厂的优先级也高于Flink的内置类型,因此您应该知道自己在做什么。

下面的示例展示了如何将自定义类型MyTuple注释为MyTuple,并使用Java中的工厂为其提供自定义类型信息。

带注释的自定义类型:

@TypeInfo(MyTupleTypeInfoFactory.class)
public class MyTuple<T0, T1> {
  public T0 myfield0;
  public T1 myfield1;
}

提供定制类型信息的工厂:

public class MyTupleTypeInfoFactory extends TypeInfoFactory<MyTuple> {

  @Override
  public TypeInformation<MyTuple> createTypeInfo(Type t, Map<String, TypeInformation<?>> genericParameters) {
    return new MyTupleTypeInfo(genericParameters.get("T0"), genericParameters.get("T1"));
  }
}

除了注释类型本身,这对于第三方代码来说是不可能的,你还可以像这样在有效的Flink POJO中注释这种类型的用法:

public class MyPojo {
  public int id;

  @TypeInfo(MyTupleTypeInfoFactory.class)
  public MyTuple<Integer, String> tuple;
}

方法createTypeInfo(Type, Map<String, TypeInformation<?>>)为工厂的目标类型创建类型信息。参数提供了关于类型本身以及类型泛型类型参数(如果有的话)的附加信息。

如果您的类型包含可能需要从Flink函数的输入类型派生的泛型参数,请确保还实现org.apache.flink.api.common.typeinfo.TypeInformation#getGenericParameters,用于泛型参数到类型信息的双向映射。

状态模式演变

Apache Flink 流应用程序通常设计为无限期或长时间运行。与所有长期运行的服务一样,应用程序需要更新以适应不断变化的需求。这对于应用程序所针对的数据模式也是如此;它们随着应用程序的发展而发展。

本页概述了如何改进状态类型的数据架构。当前的限制因不同类型和状态结构(ValueState、ListState等)而异。

请注意,仅当您使用由 Flink 自己的类型序列化框架生成的状态序列化器时,此页面上的信息才相关。也就是说,在声明您的状态时,提供的状态描述符未配置为使用特定的TypeSerializer or TypeInformation,在这种情况下,Flink 会推断有关状态类型的信息:

ListStateDescriptor<MyPojoType> descriptor =
    new ListStateDescriptor<>(
        "state-name",
        MyPojoType.class);

checkpointedState = getRuntimeContext().getListState(descriptor);

在幕后,状态模式是否可以进化取决于用于读取/写入持久状态字节的序列化程序。简而言之,注册状态的模式只有在其序列化程序正确支持的情况下才能进化。这由 Flink 的类型序列化框架生成的序列化器透明地处理(当前支持的范围在下面列出)。

如果您打算TypeSerializer为您的状态类型实现自定义并想了解如何实现序列化程序以支持状态模式演变,请参阅 自定义状态序列化。那里的文档还涵盖了有关状态序列化程序和 Flink 状态后端之间相互作用的必要内部细节,以支持状态模式演变。

不断发展的状态模式

要演化给定状态类型的模式,您将采取以下步骤:

  1. 保存您的 Flink 流式作业的保存点。
  2. 更新应用程序中的状态类型(例如,修改 Avro 类型模式)。
  3. 从保存点恢复作业。第一次访问 state 时,Flink 会评估该 state 的 schema 是否发生变化,必要时迁移 state schema。

迁移状态以适应变化的模式的过程是自动发生的,并且对于每个状态都是独立的。这个过程由 Flink 内部执行,首先检查状态的新序列化器是否与之前的序列化器具有不同的序列化模式;如果是这样,则使用先前的序列化程序将状态读取到对象,并使用新的序列化程序再次写回字节。

有关迁移过程的更多详细信息超出了本文档的范围;请参考 这里

架构演化 支持的数据类型

目前,模式演变仅支持 POJO 和 Avro 类型。因此,如果您关心状态的模式演变,目前建议始终将 Pojo 或 Avro 用于状态数据类型。

POJO 类型

Flink 支持POJO 类型的进化模式,基于以下规则集:

  1. 可以删除字段。删除后,已删除字段的先前值将在以后的检查点和保存点中删除。
  2. 可以添加新字段。新字段将被初始化为其类型的默认值,如 Java 所定义。
  3. 声明的字段类型不能更改。
  4. POJO类型的类名不能改变,包括类的命名空间。

请注意,POJO 类型状态的模式只能在使用 Flink 版本高于 1.8.0 的先前保存点恢复时进行演变。使用早于 1.8.0 的 Flink 版本进行恢复时,无法更改架构。

Avro 类型

Flink 完全支持 Avro 类型状态的进化模式,只要模式变化被 Avro 的模式解析规则认为是兼容的。

一个限制是,当作业恢复时,用作状态类型的 Avro 生成的类不能重新定位或具有不同的命名空间。

架构迁移限制

Flink 的模式迁移有一些限制,需要确保正确性。对于需要解决这些限制并了解它们在特定用例中的安全性的用户,请考虑使用自定义序列化程序或 状态处理器 api。

不支持密钥的模式演变

无法迁移密钥的结构,因为这可能会导致不确定的行为。例如,如果将 POJO 用作键并且删除了一个字段,那么可能会突然出现多个现在相同的单独键。Flink 没有办法合并对应的值。

此外,RocksDB 状态后端依赖于二进制对象标识,而不是hashCode方法。对键的对象结构的任何更改都可能导致不确定的行为。

Kryo不能用于模式演化

使用 Kryo 时,框架无法验证是否进行了任何不兼容的更改。

托管状态的自定义序列化

本节旨在为需要对其状态使用自定义序列化的用户提供指南,涵盖如何提供自定义状态序列化程序以及实现允许状态模式演变的序列化程序的指南和最佳实践。

如果你只是使用 Flink 自己的序列化器,这个页面是无关紧要的,可以忽略。

使用自定义状态序列化器

注册托管操作符或键控状态时,StateDescriptor需要指定状态名称以及有关状态类型的信息。Flink 的 类型序列化框架使用类型信息来为状态创建适当的序列化器。

也可以完全绕过这一点,让 Flink 使用您自己的自定义序列化程序来序列化托管状态,只需StateDescriptor使用您自己的TypeSerializer实现直接实例化 即可:

public class CustomTypeSerializer extends TypeSerializer<Tuple2<String, Integer>> {...};

ListStateDescriptor<Tuple2<String, Integer>> descriptor =
    new ListStateDescriptor<>(
        "state-name",
        new CustomTypeSerializer());

checkpointedState = getRuntimeContext().getListState(descriptor);

状态序列化器和模式演变

本节解释了与状态序列化和模式演变相关的面向用户的抽象,以及 Flink 如何与这些抽象交互的必要内部细节。

从保存点恢复时,Flink 允许更改用于读取和写入先前注册状态的序列化程序,这样用户就不会被锁定在任何特定的序列化模式中。当状态恢复时,将为该状态注册一个新的序列化器(即,StateDescriptor用于访问恢复的作业中的状态的序列化器)。这个新的序列化程序可能具有与以前的序列化程序不同的模式。因此,在实现状态序列化器时,除了读/写数据的基本逻辑之外,另一个需要牢记的重要事情是将来如何更改序列化模式。

当谈到schema时,在这种情况下,该术语在指称状态类型的数据模型和状态类型的序列化二进制格式之间是可以互换的。一般来说,架构可以在以下几种情况下发生变化:

  • 状态类型的数据模式已经发展,即从用作状态的 POJO 中添加或删除字段。
  • 一般来说,数据模式改变后,序列化器的序列化格式需要升级。
  • 序列化程序的配置已更改。

为了让新的执行获得有关写入的状态模式的信息并检测模式是否已更改,在获取操作员状态的保存点时,需要将状态序列化程序的快照与状态字节一起写入。这是抽象的 a TypeSerializerSnapshot,将在下一小节中解释。

TypeSerializerSnapshot抽象

public interface TypeSerializerSnapshot<T> {
    int getCurrentVersion();
    void writeSnapshot(DataOuputView out) throws IOException;
    void readSnapshot(int readVersion, DataInputView in, ClassLoader userCodeClassLoader) throws IOException;
    TypeSerializerSchemaCompatibility<T> resolveSchemaCompatibility(TypeSerializer<T> newSerializer);
    TypeSerializer<T> restoreSerializer();
}
public abstract class TypeSerializer<T> {    
    
    // ...
    
    public abstract TypeSerializerSnapshot<T> snapshotConfiguration();
}

序列化TypeSerializerSnapshot器是一个时间点信息,它作为状态序列化器写入模式的唯一真实来源,以及恢复与给定时间点相同的序列化器所必需的任何附加信息。作为序列化程序快照,在恢复时应该写入和读取什么的逻辑在writeSnapshot和readSnapshot方法中定义。

请注意,快照自己的写入模式也可能需要随着时间的推移而改变(例如,当您希望将有关序列化程序的更多信息添加到快照时)。为此,快照是版本化的,当前版本号在getCurrentVersion方法中定义。在恢复时,当从保存点读取序列化程序快照时,写入快照的模式版本将提供给readSnapshot方法,以便读取实现可以处理不同的版本。

在恢复时,检测新序列化程序的模式是否已更改的逻辑应在该resolveSchemaCompatibility方法中实现。当先前注册的状态在操作员的恢复执行中再次注册到新的序列化程序时,新的序列化程序会通过此方法提供给先前的序列化程序的快照。此方法返回一个TypeSerializerSchemaCompatibility表示兼容性解决方案的结果,它可以是以下之一:

  1. TypeSerializerSchemaCompatibility.compatibleAsIs():这个结果表明新的序列化器是兼容的,这意味着新的序列化器与以前的序列化器具有相同的模式。新的序列化程序可能已在resolveSchemaCompatibility方法中重新配置,以使其兼容。
  2. TypeSerializerSchemaCompatibility.compatibleAfterMigration():这个结果表明新的序列化器具有不同的序列化模式,并且可以通过使用以前的序列化器(识别旧模式)将字节读入状态对象,然后将对象重写回旧模式带有新序列化程序的字节(识别新模式)。
  3. TypeSerializerSchemaCompatibility.incompatible():此结果表明新的序列化程序具有不同的序列化模式,但无法从旧模式迁移。

最后一点细节是在需要迁移的情况下如何获得之前的序列化器。序列化器的另一个重要作用TypeSerializerSnapshot是它充当恢复先前序列化器的工厂。更具体地说,TypeSerializerSnapshot应该实现restoreSerializer方法来实例化一个序列化器实例,该实例识别前一个序列化器的模式和配置,因此可以安全地读取前一个序列化器写入的数据。

Flink 如何TypeSerializer与TypeSerializerSnapshot抽象交互

最后,本节总结了 Flink,或者更具体地说,状态后端是如何与抽象交互的。交互根据状态后端略有不同,但这与状态序列化器及其序列化器快照的实现是正交的。

堆外状态后端(例如EmbeddedRocksDBStateBackend)
  1. 使用具有模式A的状态序列化程序注册新状态
  • 注册TypeSerializer的状态用于在每个状态访问时读取/写入状态。
  • 状态写在模式A中。
  1. 获取保存点
  • 通过该TypeSerializer#snapshotConfiguration方法提取序列化程序快照。
  • 序列化程序快照被写入保存点,以及已经序列化的状态字节(使用模式A)。
  1. 恢复的执行使用具有模式B的新状态序列化程序重新访问恢复的状态字节
  • 先前状态序列化程序的快照已恢复。
  • 状态字节在恢复时不会反序列化,只会加载回状态后端(因此,仍然在模式A中)。
  • 收到新的序列化程序后,它会通过 提供给恢复的先前序列化程序的快照,TypeSerializer#resolveSchemaCompatibility以检查模式兼容性。
  1. 将后端中的状态字节从模式A迁移到模式B
  • 如果兼容性解决方案反映架构已更改并且可以迁移,则执行架构迁移。识别模式A的先前状态序列化程序将从序列化程序快照中获取,通过 TypeSerializerSnapshot#restoreSerializer(), 并用于将状态字节反序列化为对象,然后用新的序列化程序再次重写对象,新的序列化程序识别模式B以完成迁移。访问状态的所有条目在处理继续之前一起迁移。
  • 如果解决方案表明不兼容,则状态访问将失败并出现异常。
堆状态后端(例如HashMapStateBackend)
  1. 使用具有模式A的状态序列化程序注册新状态
  • 注册TypeSerializer由状态后端维护。
  1. 获取一个保存点,使用模式A序列化所有状态
  • 通过该TypeSerializer#snapshotConfiguration方法提取序列化程序快照。
  • 序列化程序快照被写入保存点。
  • 状态对象现在被序列化到保存点,写在模式A中。
  1. 还原时,将状态反序列化为堆中的对象
  • 先前状态序列化程序的快照已恢复。
  • 前面的序列化器,它识别模式A,是从序列化器快照中获得的,通过 TypeSerializerSnapshot#restoreSerializer(),用于将状态字节反序列化为对象。
  • 从现在开始,所有的状态都已经反序列化了。
  1. 恢复的执行使用具有模式B的新状态序列化程序重新访问先前的状态
  • 收到新的序列化程序后,它会通过 提供给恢复的先前序列化程序的快照, - TypeSerializer#resolveSchemaCompatibility以检查模式兼容性。
  • 如果兼容性检查表明需要迁移,则在这种情况下不会发生任何事情,因为对于堆后端,所有状态都已反序列化为对象。
  • 如果解决方案表明不兼容,则状态访问将失败并出现异常。
  1. 获取另一个保存点,使用模式B序列化所有状态
  • 与步骤 2 相同,但现在状态字节都在模式B中。

预定义的方便TypeSerializerSnapshot类

TypeSerializerSnapshotFlink 提供了两个可用于典型场景的 抽象基类:SimpleTypeSerializerSnapshot和CompositeTypeSerializerSnapshot.

提供这些预定义快照作为其序列化程序快照的序列化程序必须始终具有自己的独立子类实现。这对应于不跨不同序列化程序共享快照类的最佳实践,下一节将对此进行更全面的解释。

实现SimpleTypeSerializerSnapshot

SimpleTypeSerializerSnapshot用于没有任何状态或配置的序列化程序,本质上意味着序列化程序的序列化模式仅由序列化程序的类定义。

将SimpleTypeSerializerSnapshot 用作序列化程序的快照类时,兼容性分辨率只有两种可能的结果:

  • TypeSerializerSchemaCompatibility.compatibleAsIs(),如果新的序列化程序类保持相同
  • TypeSerializerSchemaCompatibility.incompatible(),如果新的序列化程序类与前一个不同

下面是一个如何使用的示例SimpleTypeSerializerSnapshot,以 FlinkIntSerializer为例:

public class IntSerializerSnapshot extends SimpleTypeSerializerSnapshot<Integer> {
    public IntSerializerSnapshot() {
        super(() -> IntSerializer.INSTANCE);
    }
}

IntSerializer没有状态或配置。序列化格式仅由序列化程序类本身定义,并且只能由另一个IntSerializer. 因此,它适合 SimpleTypeSerializerSnapshot.

无论快照当前是正在恢复还是在快照期间写入,基础超级构造函数都SimpleTypeSerializerSnapshot需要相应序列化程序的实例。Supplier该供应商用于创建恢复序列化程序,以及类型检查以验证新序列化程序是否属于相同的预期序列化程序类。

实现CompositeTypeSerializerSnapshot

它CompositeTypeSerializerSnapshot适用于依赖多个嵌套序列化器进行序列化的序列化器。

在进一步解释之前,我们将依赖于多个嵌套序列化器的序列化器称为此上下文中的“外部”序列化器。这方面的示例可能是MapSerializer, ListSerializer,GenericArraySerializer等MapSerializer。例如 - 键和值序列化器将是嵌套序列化器,而MapSerializer其本身是“外部”序列化器。

在这种情况下,外部序​​列化器的快照也应该包含嵌套序列化器的快照,以便可以独立检查嵌套序列化器的兼容性。在解决外层序列化器的兼容性问题时,需要考虑每个嵌套序列化器的兼容性。

CompositeTypeSerializerSnapshot被提供来帮助实现这些复合序列化器的快照。它处理嵌套序列化程序快照的读取和写入,以及在考虑所有嵌套序列化程序的兼容性的情况下解决最终的兼容性结果。

下面是一个如何使用的示例CompositeTypeSerializerSnapshot,以 FlinkMapSerializer为例:

public class MapSerializerSnapshot<K, V> extends CompositeTypeSerializerSnapshot<Map<K, V>, MapSerializer> {

    private static final int CURRENT_VERSION = 1;

    public MapSerializerSnapshot() {
        super(MapSerializer.class);
    }

    public MapSerializerSnapshot(MapSerializer<K, V> mapSerializer) {
        super(mapSerializer);
    }

    @Override
    public int getCurrentOuterSnapshotVersion() {
        return CURRENT_VERSION;
    }

    @Override
    protected MapSerializer createOuterSerializerWithNestedSerializers(TypeSerializer<?>[] nestedSerializers) {
        TypeSerializer<K> keySerializer = (TypeSerializer<K>) nestedSerializers[0];
        TypeSerializer<V> valueSerializer = (TypeSerializer<V>) nestedSerializers[1];
        return new MapSerializer<>(keySerializer, valueSerializer);
    }

    @Override
    protected TypeSerializer<?>[] getNestedSerializers(MapSerializer outerSerializer) {
        return new TypeSerializer<?>[] { outerSerializer.getKeySerializer(), outerSerializer.getValueSerializer() };
    }
}

在将新的序列化程序快照实现为 的子类时CompositeTypeSerializerSnapshot,必须实现以下三个方法:

  • getCurrentOuterSnapshotVersion():此方法定义当前外部序列化程序快照的序列化二进制格式的版本。
  • getNestedSerializers(TypeSerializer):给定外部序列化器,返回其嵌套序列化器。
  • createOuterSerializerWithNestedSerializers(TypeSerializer[]):给定嵌套的序列化器,创建一个外部序列化器的实例。

上面的示例是CompositeTypeSerializerSnapshot除了嵌套序列化程序的快照之外没有额外信息要被快照的情况。因此,可以预期它的外部快照版本永远不需要上升。然而,其他一些序列化程序包含一些额外的静态配置,需要与嵌套组件序列化程序一起保留。一个例子是 Flink 的 GenericArraySerializer,它包含作为配置的数组元素类型的类,除了嵌套元素序列化器。

在这些情况下,需要在 上实现另外三种方法CompositeTypeSerializerSnapshot:

  • writeOuterSnapshot(DataOutputView):定义外部快照信息的写入方式。
  • readOuterSnapshot(int, DataInputView, ClassLoader):定义如何读取外部快照信息。
  • resolveOuterSchemaCompatibility(TypeSerializer):根据外部快照信息检查兼容性。

默认情况下,CompositeTypeSerializerSnapshot假设没有任何外部快照信息可读取/写入,因此上述方法的默认实现为空。如果子类具有外部快照信息,则必须实现所有三个方法。

下面是一个示例,说明如何将CompositeTypeSerializerSnapshot用于具有外部快照信息的复合序列化程序快照,以 FlinkGenericArraySerializer为例:

public final class GenericArraySerializerSnapshot<C> extends CompositeTypeSerializerSnapshot<C[], GenericArraySerializer> {

    private static final int CURRENT_VERSION = 1;

    private Class<C> componentClass;

    public GenericArraySerializerSnapshot() {
        super(GenericArraySerializer.class);
    }

    public GenericArraySerializerSnapshot(GenericArraySerializer<C> genericArraySerializer) {
        super(genericArraySerializer);
        this.componentClass = genericArraySerializer.getComponentClass();
    }

    @Override
    protected int getCurrentOuterSnapshotVersion() {
        return CURRENT_VERSION;
    }

    @Override
    protected void writeOuterSnapshot(DataOutputView out) throws IOException {
        out.writeUTF(componentClass.getName());
    }

    @Override
    protected void readOuterSnapshot(int readOuterSnapshotVersion, DataInputView in, ClassLoader userCodeClassLoader) throws IOException {
        this.componentClass = InstantiationUtil.resolveClassByName(in, userCodeClassLoader);
    }

    @Override
    protected boolean resolveOuterSchemaCompatibility(GenericArraySerializer newSerializer) {
        return (this.componentClass == newSerializer.getComponentClass())
            ? OuterSchemaCompatibility.COMPATIBLE_AS_IS
            : OuterSchemaCompatibility.INCOMPATIBLE;
    }

    @Override
    protected GenericArraySerializer createOuterSerializerWithNestedSerializers(TypeSerializer<?>[] nestedSerializers) {
        TypeSerializer<C> componentSerializer = (TypeSerializer<C>) nestedSerializers[0];
        return new GenericArraySerializer<>(componentClass, componentSerializer);
    }

    @Override
    protected TypeSerializer<?>[] getNestedSerializers(GenericArraySerializer outerSerializer) {
        return new TypeSerializer<?>[] { outerSerializer.getComponentSerializer() };
    }
}

在上面的代码片段中有两件重要的事情需要注意。首先,由于此 实现具有作为快照的一部分写入的外部快照信息,因此只要外部快照信息的序列化格式发生更改,就必须升级CompositeTypeSerializerSnapshot由 定义的外部快照版本。getCurrentOuterSnapshotVersion()

其次,请注意我们在编写组件类时如何避免使用 Java 序列化,只编写类名并在读回快照时动态加载它。避免 Java 序列化以编写序列化程序快照的内容通常是一个很好的做法。下一节将介绍有关此的更多详细信息。

实施说明和最佳实践

1. Flink 通过使用它们的类名实例化它们来恢复序列化程序快照

序列化程序的快照,作为注册状态如何被序列化的唯一真实来源,用作在保存点中读取状态的入口点。为了能够恢复和访问之前的状态,之前的状态序列化器的快照必须能够被恢复。

Flink 通过首先TypeSerializerSnapshot使用其类名(与快照字节一起写入)实例化 来恢复序列化程序快照。因此,为了避免意外的类名更改或实例化失败,TypeSerializerSnapshot类应该:

  • 避免被实现为匿名类或嵌套类
  • 有一个用于实例化的公共、空值构造函数

2. 避免在不同的序列化 器之间 共享同一个类

由于模式兼容性检查通过序列化程序快照进行,因此让多个序列化程序返回TypeSerializerSnapshot与其快照相同的类会使 TypeSerializerSnapshot#resolveSchemaCompatibilityandTypeSerializerSnapshot#restoreSerializer()方法的实现复杂化。

这也是一个不好的关注点分离;单个序列化程序的序列化模式、配置以及如何恢复它,应该合并到它自己的专用TypeSerializerSnapshot类中。

3.避免对序列化器快照内容使用Java序列化

在编写持久序列化程序快照的内容时,根本不应该使用 Java 序列化。例如,一个序列化程序需要将其目标类型的类作为其快照的一部分进行持久化。关于类的信息应该通过写入类名来持久化,而不是直接使用 Java 序列化类。读取快照时,读取类名,用于通过名称动态加载类。

这种做法可确保始终可以安全地读取序列化程序快照。在上面的示例中,如果类型类是使用 Java 序列化持久化的,则一旦类实现发生更改,快照可能不再可读,并且根据 Java 序列化细节不再是二进制兼容的。

从 Flink 1.7 之前已弃用的序列化程序快照 API 迁移

本节是从 Flink 1.7 之前存在的序列化器和序列化器快照迁移 API 的指南。

在 Flink 1.7 之前,序列化程序快照被实现为 a TypeSerializerConfigSnapshot(现在已弃用,将来最终将被删除以完全被新TypeSerializerSnapshot接口取代)。此外,序列化程序模式兼容性检查的责任TypeSerializer在TypeSerializer#ensureCompatibility(TypeSerializerConfigSnapshot)方法中实现。

新旧抽象之间的另一个主要区别是不推荐使用的抽象TypeSerializerConfigSnapshot 没有实例化以前的序列化程序的能力。因此,在您的序列化程序仍然返回一个子类的情况下TypeSerializerConfigSnapshot作为其快照,序列化程序实例本身将始终使用 Java 序列化写入保存点,以便之前的序列化程序在恢复时可用。这是非常不可取的,因为恢复作业是否成功会受到先前序列化程序类的可用性的影响,或者一般而言,是否可以使用 Java 序列化在恢复时读回序列化程序实例。这意味着您的状态仅限于使用相同的序列化程序,并且一旦您想要升级序列化程序类或执行模式迁移,可能会出现问题。

为了适应未来并灵活地迁移您的状态序列化程序和模式,强烈建议从旧的抽象迁移。执行此操作的步骤如下:

  1. 实现一个新的子类TypeSerializerSnapshot。这将是您的序列化程序的新快照。
  2. 在方法中返回新TypeSerializerSnapshot的作为序列化程序的序列化程序快照 TypeSerializer#snapshotConfiguration()。
  3. 从 Flink 1.7 之前存在的保存点恢复作业,然后再次获取保存点。请注意,在这一步中,旧TypeSerializerConfigSnapshot的序列化程序必须仍然存在于类路径中,并且TypeSerializer#ensureCompatibility(TypeSerializerConfigSnapshot)不得删除该方法的实现。TypeSerializerConfigSnapshot此过程的目的是用新实现TypeSerializerSnapshot的序列化程序替换写入的旧保存点。
  4. 一旦您使用 Flink 1.7 获取了保存点,保存点将包含TypeSerializerSnapshot状态序列化程序快照,并且序列化程序实例将不再写入保存点。此时,现在可以安全地删除旧抽象的所有实现(从序列化程序中删除旧 TypeSerializerConfigSnapshot实现 TypeSerializer#ensureCompatibility(TypeSerializerConfigSnapshot))。

三方序列化器

如果您在 Flink 程序中使用了无法被 Flink 类型序列化器序列化的自定义类型,则 Flink 会退回到使用通用 Kryo 序列化器。您可以使用 Kryo 注册自己的序列化程序或序列化系统,如 Google Protobuf 或 Apache Thrift。为此,只需在ExecutionConfigFlink 程序中注册类型类和序列化程序。

final ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();

// register the class of the serializer as serializer for a type
env.getConfig().registerTypeWithKryoSerializer(MyCustomType.class, MyCustomSerializer.class);

// register an instance as serializer for a type
MySerializer mySerializer = new MySerializer();
env.getConfig().registerTypeWithKryoSerializer(MyCustomType.class, mySerializer);

请注意,您的自定义序列化程序必须扩展 Kryo 的 Serializer 类。对于 Google Protobuf 或 Apache Thrift,这已经为您完成了:

final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

// register the Google Protobuf serializer with Kryo
env.getConfig().registerTypeWithKryoSerializer(MyCustomType.class, ProtobufSerializer.class);

// register the serializer included with Apache Thrift as the standard serializer
// TBaseSerializer states it should be initialized as a default Kryo serializer
env.getConfig().addDefaultKryoSerializer(MyCustomType.class, TBaseSerializer.class);

为了使上面的示例正常工作,您需要在 Maven 项目文件 (pom.xml) 中包含必要的依赖项。在依赖项部分,为 Apache Thrift 添加以下内容:

<dependency>
	<groupId>com.twitter</groupId>
	<artifactId>chill-thrift</artifactId>
	<version>0.7.6</version>
	<!-- exclusions for dependency conversion -->
	<exclusions>
		<exclusion>
			<groupId>com.esotericsoftware.kryo</groupId>
			<artifactId>kryo</artifactId>
		</exclusion>
	</exclusions>
</dependency>
<!-- libthrift is required by chill-thrift -->
<dependency>
	<groupId>org.apache.thrift</groupId>
	<artifactId>libthrift</artifactId>
	<version>0.11.0</version>
	<exclusions>
		<exclusion>
			<groupId>javax.servlet</groupId>
			<artifactId>servlet-api</artifactId>
		</exclusion>
		<exclusion>
			<groupId>org.apache.httpcomponents</groupId>
			<artifactId>httpclient</artifactId>
		</exclusion>
	</exclusions>
</dependency>

对于 Google Protobuf,您需要以下 Maven 依赖项:

<dependency>
	<groupId>com.twitter</groupId>
	<artifactId>chill-protobuf</artifactId>
	<version>0.7.6</version>
	<!-- exclusions for dependency conversion -->
	<exclusions>
		<exclusion>
			<groupId>com.esotericsoftware.kryo</groupId>
			<artifactId>kryo</artifactId>
		</exclusion>
	</exclusions>
</dependency>
<!-- We need protobuf for chill-protobuf -->
<dependency>
	<groupId>com.google.protobuf</groupId>
	<artifactId>protobuf-java</artifactId>
	<version>3.7.0</version>
</dependency>

使用 Kryo 的问题JavaSerializer

如果您为自定义类型注册 Kryo’s ,即使您的自定义类型类包含在提交的用户代码 jar 中JavaSerializer,您也可能会遇到s。ClassNotFoundException这是由于 Kryo’s 的一个已知问题JavaSerializer,它可能错误地使用了错误的类加载器。

在这种情况下,您应该改用它org.apache.flink.api.java.typeutils.runtime.kryo.JavaSerializer 来解决问题。这是JavaSerializer在 Flink 中重新实现的,确保使用用户代码类加载器。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

京河小蚁

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值