1.状态schema演变
Apache Flink流应用程序通常设计为无限期或长时间运行。对于所有长期运行的服务,需要更新应用程序以适应不断变化的需求。对于应用程序所针对的数据schema也是如此;它们随着应用程序的发展而发展。
本章概述了如何演进状态类型的数据schema。当前的限制因不同类型和state结构(ValueState、ListState等)而不同。需要注意的是,只有在使用由Flink自己的类型序列化框架生成的状态序列化器时,本章所述的信息才是与其相关的。也就是说,在声明状态时,所提供的状态描述符没有配置使用特定的类型序列化器或类型信息,在这种情况下,Flink会推断出关于状态类型的信息:
ListStateDescriptor<MyPojoType> descriptor =
new ListStateDescriptor<>(
"state-name",
MyPojoType.class);
checkpointedState = getRuntimeContext().getListState(descriptor);
实际上,状态schema是否可以演化取决于用于读写持久状态字节的序列化器。简单地说,注册状态的schema只有在其序列化器正确支持它的情况下才能演化。这是由Flink类型序列化框架生成的序列化器透明地处理的(当前支持仅avro类型)。
如果你打算为你的状态类型实现自定义类型序列化器,并且想了解如何实现该序列化器以支持状态schema演化,请参阅自定义状态序列化一节内容。该节还包括关于状态序列化器和Flink的状态后端之间相互作用的必要内部细节,以支持状态schema演化。
1.1 演化状态schema
要演化给定状态类型的schema,可以采取以下步骤:
- 为Flink流作业创建一个savepoint;
- 更新应用程序中的状态类型(例如,修改Avro类型schema);
- 从savepoint恢复作业。在第一次访问状态时,Flink将评估schema是否根据状态而改变,并在必要时迁移状态schema。
为适应更改的schema而迁移状态的过程自动地、独立地针对每个状态来进行。这个过程由Flink在内部执行,首先检查状态的新序列化器是否具有与前一个序列化器不同的序列化schema;如果是,则使用前面的序列化器将状态读入对象,并使用新的序列化器再次将状态写入字节。
关于迁移过程的更多细节超出了本文档的范围;请参考自定义状态序列化。
1.2 状态schema支持的数据类型
目前,schema演化仅支持Avro。因此,如果关心状态的schema演化的话,目前建议始终对状态数据类型使用Avro。
计划扩展对更多复合类型(如pojo)的支持;详情请参考FLINK-10897。
Avro类型
Flink完全支持Avro类型状态的演进模式,只要Avro的schema解析规则认为是与其相兼容的schema更改即可。
目前存在的一个限制是,当作业恢复时,Avro生成的用作状态类型的类不能被重新定位或具有不同的名称空间。
2.自定义状态序列化
在Flink 1.7之前,序列化器快照是作为TypeSerializerConfigSnapshot实现的(现在已经弃用了,将来会被删除,完全由1.7中引入的新TypeSerializerSnapshot接口替代)。此外,序列化器模式兼容性检查的职责存在于TypeSerializer中,在TypeSerializer#ensureCompatibility(TypeSerializerConfigSnapshot)方法中实现。
为了避免将来的问题,并具有迁移状态序列化器和schema的灵活性,强烈建议从旧的抽象迁移。详细信息和迁移指南我们现在开始。
此章节目标是为需要对其状态使用自定义序列化的用户提供指导方针,包括如何提供自定义状态序列化器,以及实现允许状态模式演化的序列化器的指导方针和最佳实践。
如果只是使用Flink自己的序列化器,那么此章节无影响。
2.1 使用自定义状态序列化器
在注册一个托管的operator或者keyed state时,需要一个状态描述符来指定状态的名称以及关于状态类型的信息。Flink的类型序列化框架使用类型信息为状态创建适当的序列化器。
也可以完全绕过它,让Flink使用自定义序列化器来序列化托管state,只需使用自定义的TypeSerializer实现类来直接创建StateDescriptor:
class CustomTypeSerializer extends TypeSerializer[(String, Integer)] {...}
val descriptor = new ListStateDescriptor[(String, Integer)](
"state-name",
new CustomTypeSerializer)
)
checkpointedState = getRuntimeContext.getListState(descriptor)
2.2 状态序列化器及schema演变
本节解释与状态序列化和模式演化相关的面向用户的抽象,以及Flink如何与这些抽象交互的必要内部细节。
当从保存点恢复时,Flink允许更改用于读写以前注册过地state序列化器,这样用户就不会被锁定在任何特定的序列化模式中。当状态恢复时,将为该状态注册一个新的序列化器(即与用于访问还原作业中的状态的StateDescriptor一起提供的序列化器)。这个新的序列化器可能具有与前一个序列化器不同的模式。因此,在实现状态序列化器时,除了读取/写入数据的基本逻辑之外,另一件需要记住的重要事情是如何在将来更改序列化模式。
当谈到schema时,在这种上下文中,这个术语在引用状态类型的数据模型和序列化二进制格式之间是可以互换的。一般来说,schema可以在以下几种情况下改变:
- 状态类型的数据schema已经改变,即从用作状态类型的POJO类中添加或删除字段;
- 一般来说,在更改数据schema之后,需要升级序列化器的序列化格式;
- 序列化器的配置已经更改。
为了让新的执行程序获得关于已写state schema的信息并检测它是否已更改,在获取operator的state的保存点后,需要将状态序列化器的快照与状态字节一起写入。这是一个TypeSerializerSnapshot的抽象,在下一小节中解释。
2.2.1 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是一个时间点信息,它作为state序列化的写schema的唯一来源,以及恢复与给定时间点相同的序列化所必需的任何附加信息。在writeSnapshot和readSnapshot方法中定义了关于在还原时作为序列化器快照应该写入和读取什么内容的逻辑。
注意,快照自己写schema时也可能需要随着时间的推移而改变(例如,当您希望向快照中添加关于序列化器的更多信息时)。为了方便实现这一点,需要对快照进行版本控制,并在getCurrentVersion方法中定义当前版本号。在还原时,当从保存点读取序列化快照时,写入快照的schema版本将提供给readSnapshot方法,以便读取实现能够处理不同的版本。
在还原时,应该在resolveSchemaCompatibility方法中实现检测新序列化schema是否更改的逻辑。当在operator的恢复执行中,使用新的序列化器再次注册以前的已注册的state时,将通过此方法将新的序列化器提供给前一个序列化器的快照。该方法返回一个TypeSerializerSchemaCompatibility,表示兼容性解析的结果,该结果可以是以下情况之一:
- TypeSerializerSchemaCompatibility.compatible bleasis():这个结果表明新的序列化器是兼容的,这意味着新的序列化器具有与前一个序列化器相同的schema。可能在resolveSchemaCompatibility方法中重新配置了新的序列化器,使其兼容;
- TypeSerializerSchemaCompatibility.compatibleAfterMigration():这一结果表明新的序列化器有不同的序列化schema,并可以从旧模式通过使用前面的序列化器(承认旧模式)读取字节状态对象,然后重写对象回到字节与新的序列化上(承认新模式)。
- TypeSerializerSchemaCompatibility.incompatible():这个结果表明新的序列化具有不同的序列化schema,但是不能从旧的schema迁移。
最后一点是要在需要迁移的情况下如何获得前面的序列化方式。已经序列化的TypeSerializerSnapshot的另一个重要作用是,它作为一个工厂类来恢复之前的序列化方式。更具体地说,TypeSerializerSnapshot应该实现restoreSerializer方法来创建一个序列化实例,该实例识别前一个序列化方式的schema和配置,因此可以安全地读取前一个序列化方式编写的数据。
2.2.2 Flink如何与TypeSerializer和TypeSerializerSnapshot抽象交互
最后,本节总结Flink,或者更具体地说是state后端,如何与抽象进行交互。state后端的交互略有不同,但这与状态序列化器及其序列化快照的实现是一样的。
堆外state后端(例如rocksdbstatebacked)
- 使用schema A序列化方式来注册新state
- 用于在每个state访问上读/写state的已注册的TypeSerializer;
- state是在schema A中编写的;
- 取一个保存点
- 序列化快照是通过TypeSerializer#snapshotConfiguration方法提取的;
- 序列化器快照被写入保存点,并且状态字节已用模式A序列化了;
- 已修复的执行程序使用具有模式B的state序列化方式重新访问已恢复的状态字节;
- 恢复前一个状态序列化的快照;
- 状态字节在还原时不被反序列化,只加载回state后端(因此,仍然在模式A中);
- 在接收到新的序列化器后,通过TypeSerializer# resolveschemacompatiability将其传给已恢复的前一个序列化器的快照,以检查模式兼容性;
- 将后端中的状态字节从模式A迁移到模式B
- 如果兼容性解决方案反映schema已经更改,并且可以迁移,则执行schema迁移。识别schema A的前一个状态序列化器将通过TypeSerializerSnapshot#restoreSerializer()从序列化器快照中获得,并用于将状态字节反序列化到对象中,对象又会被新的序列化器重新编写,新的序列化器识别模式B以完成迁移。在继续处理之前,将所有被访问状态一起迁移;
- 如果不兼容,则状态访问将失败;
堆内state后端(如memorystatebacked、fsstatebacked)
- 使用具有schema A的状态序列化器注册新状态
- 已注册的类型序列化器由状态后端维护;
- 取一个保存点,用schema A 序列化所有状态
- 序列化器快照是通过TypeSerializer#snapshotConfiguration方法提取的;
- 序列化器快照被写入保存点;
- 状态对象现在使用schema A来将序列化写入到保存点;
- 还原时,将状态反序列化为堆中的对象
- 恢复前一个状态序列化器的快照;
- 前面的序列化器识别schema A,它通过TypeSerializerSnapshot#restoreSerializer()从序列化器快照获得,用于将状态字节反序列化到对象;
- 从现在开始,所有的状态都已反序列化;
4.已恢复的执行程序使用具有schema B的state序列化器重新访问以前的状态
- 在接收到新的序列化器后,通过TypeSerializer# resolveschemacompatiability将其提供给已恢复的前一个序列化器的快照,以检查模式兼容性;
- 如果兼容性检查表明需要迁移,那么在这种情况下不会发生任何事情,因为对于堆后端,所有状态都已经反序列化为对象;
- 如果表明不兼容,则状态访问将异常失败;
- 以另一个保存点为例,使用schema B序列化所有状态
- 与步骤2相同,但现在状态字节都在模式B中。
2.3 实现说明和最佳实践
1. Flink通过用类名实例化来恢复序列化器快照
序列化快照是已注册状态序列化的唯一来源,它作为保存点中读取状态的入口,为了能够恢复和访问以前的状态,必须能够恢复以前的状态序列化器的快照。
Flink通过首先实例化TypeSerializerSnapshot及其类名(与快照字节一起编写)来恢复序列化器快照。因此,为了避免意外更改类名或实例化失败,TypeSerializerSnapshot类应该:
- 避免被实现为匿名类或嵌套类;
- 实体类有一个公共的空构造函数;
2. 避免在不同的序列化器之间共享相同的TypeSerializerSnapshot类
由于schema兼容性检查要通过序列化器快照,因此让多个序列化器返回与其快照相同的TypeSerializerSnapshot类将使TypeSerializerSnapshot# resolveschemacompatiability和TypeSerializerSnapshot#restoreSerializer()方法的实现变得复杂。
这也将是一个糟糕的关注点分离;单个序列化器的schema、序列化配置以及如何恢复,应该整合到它专用的TypeSerializerSnapshot类中。
3.为包含嵌套序列化器的序列化器使用CompositeSerializerSnapshot实用程序
在某些情况下,类型序列化器依赖于其他嵌套的类型序列化器;以Flink的TupleSerializer为例,其中为tuple字段配置了嵌套的类型序列化器。在这种情况下,最外层序列化器的快照还应该包含嵌套序列化器的快照。
CompositeSerializerSnapshot可以专门用于这个场景。它封装了解析复合序列化器的总体模式兼容性检查结果的逻辑。关于如何使用它的示例,可以参考Flink的ListSerializerSnapshot实现。
2.4 在Flink 1.7之前从废弃的序列化器快照api进行迁移
本节介绍如何从Flink 1.7之前的序列化器和序列化器快照迁移API。
在Flink 1.7之前,序列化器快照是作为TypeSerializerConfigSnapshot实现的(现在已经弃用了,将来会被删除,完全由新的TypeSerializerSnapshot接口替代)。此外,序列化器schema兼容性检查的职责存在于TypeSerializer中,在TypeSerializer#ensureCompatibility(TypeSerializerConfigSnapshot)方法中实现。
新旧抽象之间的另一个主要区别是,已弃用的TypeSerializerConfigSnapshot不具备实例化前一个序列化器的能力。因此,如果序列化器仍然返回TypeSerializerConfigSnapshot的子类作为其快照,则序列化器实例本身将始终使用Java序列化被写入保存点,以便在还原时可以使用上一个序列化器。这是非常不可取的,因为恢复作业是否成功取决于前一个序列化器的类的可用性,或者通常情况下,是否可以在还原时使用Java序列化读回序列化器实例。这意味着您的状态只能使用相同的序列化器,一旦您想要升级序列化器类或执行模式迁移,就会出现问题。
为了避免将来的问题,并具有迁移状态序列化器和模式的灵活性,强烈建议从旧的抽象迁移。这样做的步骤如下:
- 实现TypeSerializerSnapshot的新子类。这将是序列化器的新快照;
- 在TypeSerializer#snapshotConfiguration()方法中,返回新的TypeSerializerSnapshot作为序列化器的序列化器快照;
- 从Flink 1.7之前存在的保存点恢复作业,然后再次获取保存点。注意,在此步骤中,序列化器的旧TypeSerializerConfigSnapshot必须仍然存在于类路径中,并且不能删除TypeSerializer#ensureCompatibility(TypeSerializerConfigSnapshot)方法的实现。这个过程的目的是用序列化器新实现的TypeSerializerSnapshot替换用旧保存点编写的TypeSerializerConfigSnapshot;
- 一旦您使用Flink 1.7获取了一个保存点,保存点将包含TypeSerializerSnapshot作为状态序列化器快照,并且序列化器实例将不再被写入保存点。现在,可以安全地从序列化器中删除旧抽象的所有实现(删除旧的TypeSerializerConfigSnapshot实现,以及从序列化器中删除TypeSerializer#ensureCompatibility(TypeSerializerConfigSnapshot))。