如何优雅的升级 Flink Job?
阅读原文时间:2022年07月18日阅读:3

Flink 作为有状态计算的流批一体分布式计算引擎,会在运行过程中保存很多的「状态」数据,并依赖这些数据完成任务的 Failover 以及任务的重启恢复。

那么,请思考一个问题:如果程序升级迭代调整了这些「状态」的数据结构以及类型,Flink 能不能从旧的「状态」文件(一般就是 Savepoint 文件)中恢复?

数据类型

上一篇我们介绍过 Flink 内置的一些用于状态存储的集合工具,如 ValueState、ListState、MapState 等。这些只是装数据的容器,具体能存储哪些类型的数据或许你还不清楚。

实际上,Flink 支持以下一些数据类型:

Flink 中默认提供对一些特定条件下的状态数据结构升级的自动兼容,无需用户介入。

POJO 类型

Flink 基于下面的规则来支持 POJO 类型结构的升级:

  • 可以删除字段。一旦删除,被删除字段的前值将会在将来的 checkpoints 以及 savepoints 中删除。

  • 可以添加字段。新字段会使用类型对应的默认值进行初始化,比如 Java 类型。

  • 不可以修改字段的声明类型。

  • 不可以改变 POJO 类型的类名,包括类的命名空间。

其中,比较重要的是,对于一个 POJO 对象的某些字段的类型修改是不被支持的,因为 Savepoint 文件是按照二进制位紧凑存储的,不同类型占用的 bit 位长度是不一样的。

按照目前的 Flink 内置支持能力,最多对于 POJO 类型增加或者删除字段等基本操作。

Avro 类型

Avro 的 Schema 用 JSON 表示。Schema 定义了简单数据类型和复杂数据类型。Flink 完全支持 Avro 的 Schema 升级。

因为 Avro 本身就是一个高性能的数据序列化框架,它使用JSON 来定义数据类型和通讯协议,使用压缩二进制格式来序列化数据。

Flink 中相当于借助它完成数据的序列化和反序列化,那么理论上只要用户的 Schema 升级是 Avro 支持的,那么 Flink 也是完全支持的。

除了上述两种 Flink 内置支持的两种类型外,其余所有类型均不支持 Schema 升级。那么我们就只有通过自定义状态序列化器来完成对状态 Schema 升级的兼容。

序列化反序列化的流程

HashMapStateBackend 这种基于内存的状态后端和 EmbeddedRocksDBStateBackend 这种基于 RocksDb 的状态后端的序列化与反序列化流程稍有不同。

基于内存状态后端的序列化反序列化流程:

  • Job的相关状态的数据是以Object的形式存储在JVM内存堆中

  • 通过Checkpoint/Savepoint机制将内存中的状态数据序列化到外部存储介质

  • 新序列化器反序列化的时候会通过旧的序列化器反序列化数据到内存

  • 基于内存中状态更新后再通过新序列化器序列化数据到外部存储介质

基于RocksDb状态后端的序列化反序列化流程:

  • Job的相关状态数据直接经过序列化器序列化好存储在JVM堆外内存中

  • 通过Checkpoint/Savepoint机制将内存中序列化好的数据原样传输到外部存储介质

  • 新序列化器反序列化的时候会从外部介质直接读取状态数据到内存(不做反序列化操作)

  • 对于使用到的状态数据会使用旧序列化器先反序列化,再修改,再使用新序列化器序列化

其中,对于后面两个步骤与内存状态后端是有区别的,相当于是一种 lazy 的模式,只有用到才会去反序列化。

举个例子:状态中有个 KeyedState(我们知道每个Key会对应一个状态),那么如果某些 Key 的状态数据恢复到内存后没有被程序使用或者更新,那么下一次序列化的时候就不会使用新序列化器操作。

那么,结果就是:对于一个 Job 的 Checkpoint/Savepoint 文件里是存在多个版本的。这也是待会儿要提到的,对于每一次序列化都会把序列化器的相关配置以快照的形式和数据一起存储,这样才保证了多个版本状态数据存在的可能。

演示一个 Schema 升级状态恢复失败的 Demo

模拟一个订单系统上报数据的场景,计算每十秒系统的订单量以及下单数最多的用户

1、自定义一个 SourceFunction 模拟上游源源不断产生数据:

public&nbsp;class&nbsp;MakeDataSource&nbsp;extends&nbsp;RichSourceFunction<OrderModel>&nbsp;{&nbsp;&nbsp;&nbsp;&nbsp;private&nbsp;boolean&nbsp;flag&nbsp;=&nbsp;true;&nbsp;&nbsp;&nbsp;&nbsp;@Override&nbsp;&nbsp;&nbsp;&nbsp;public&nbsp;void&nbsp;run(SourceContext<OrderModel>&nbsp;sourceContext)&nbsp;throws&nbsp;Exception&nbsp;{&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;List<String>&nbsp;userIdSet&nbsp;=&nbsp;Arrays.asList("joha",&nbsp;"nina",&nbsp;"gru",&nbsp;"andi");&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Random&nbsp;random&nbsp;=&nbsp;new&nbsp;Random();&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;while(flag){&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;OrderModel&nbsp;order&nbsp;=&nbsp;new&nbsp;OrderModel();&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;order.setCreateTs(System.currentTimeMillis());&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;order.setOrderId(UUID.randomUUID().toString());&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;order.setUserId(userIdSet.get(random.nextInt(4)));&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;sourceContext.collect(order);&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}&nbsp;&nbsp;&nbsp;&nbsp;}&nbsp;&nbsp;&nbsp;&nbsp;@Override&nbsp;&nbsp;&nbsp;&nbsp;public&nbsp;void&nbsp;cancel()&nbsp;{&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;flag&nbsp;=&nbsp;false;&nbsp;&nbsp;&nbsp;&nbsp;}}

2、写一个 job 每十秒聚合一次窗口,输出用户产生的订单数量:

&nbsp;&nbsp;&nbsp;&nbsp;public&nbsp;static&nbsp;void&nbsp;main(String[]&nbsp;args)&nbsp;throws&nbsp;Exception&nbsp;{&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;StreamExecutionEnvironment&nbsp;env&nbsp;=&nbsp;StreamExecutionEnvironment.getExecutionEnvironment();&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;env.enableCheckpointing(1000&nbsp;*&nbsp;60);&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;env.getCheckpointConfig().setCheckpointStorage("file:///data/");&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;env.addSource(new&nbsp;MakeDataSource())&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;.assignTimestampsAndWatermarks(&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;WatermarkStrategy.<OrderModel>forBoundedOutOfOrderness(Duration.ofSeconds(2))&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;.withTimestampAssigner((SerializableTimestampAssigner<OrderModel>)&nbsp;(orderModel,&nbsp;l)&nbsp;->&nbsp;orderModel.getCreateTs())&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;)&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;.keyBy((KeySelector<OrderModel,&nbsp;String>)&nbsp;orderModel&nbsp;->&nbsp;orderModel.getUserId())&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;.window(TumblingEventTimeWindows.of(Time.seconds(10)))&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;.process(new&nbsp;ProcessWindowFunction<OrderModel,&nbsp;Tuple2<Long,String>,&nbsp;String,&nbsp;TimeWindow>()&nbsp;{&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;@Override&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;public&nbsp;void&nbsp;process(String&nbsp;key,&nbsp;Context&nbsp;context,&nbsp;Iterable<OrderModel>&nbsp;elements,&nbsp;Collector<Tuple2<Long,&nbsp;String>>&nbsp;out)&nbsp;throws&nbsp;Exception&nbsp;{&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Iterator<OrderModel>&nbsp;iterator&nbsp;=&nbsp;elements.iterator();&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;int&nbsp;count&nbsp;=&nbsp;0;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;while&nbsp;(iterator.hasNext()){&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;iterator.next();&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;count++;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;logger.info("userid:{},count:{}",key,count);&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;});&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;env.execute("test_job");&nbsp;&nbsp;&nbsp;&nbsp;}

WebUi 上看大概就是这个样子,由于我是本地 docker 起的集群,所以资源不是特别充足,并行度都是 1。

我们执行以下命令停止任务并生成 Savepoint:

root@ae894850e6ae:/opt/flink#&nbsp;flink&nbsp;stop&nbsp;613c3662a4a4f5affa8eb8fb04bf4592Suspending&nbsp;job&nbsp;"613c3662a4a4f5affa8eb8fb04bf4592"&nbsp;with&nbsp;a&nbsp;savepoint.Savepoint&nbsp;completed.&nbsp;Path:&nbsp;file:/data/flink-savepoints/savepoint-613c36-3f0f01590c70

然后我们给我们的状态对象 OrderModel 的 orderId 字段类型从 String 给他改成 Integer。

再指定 Savepoint 重启 Job,不出意外的话,你应该也会得到这么个错误:

自定义状态序列化器

1、需要继承 TypeSerializer 并实现其中相关方法,其中比较重要的有这么几个:

//创建一个待序列化的数据类型实例public&nbsp;abstract&nbsp;T&nbsp;createInstance();//序列化操作public&nbsp;abstract&nbsp;void&nbsp;serialize(T&nbsp;record,&nbsp;DataOutputView&nbsp;target)&nbsp;throws&nbsp;IOException;//反序列化操作public&nbsp;abstract&nbsp;T&nbsp;deserialize(DataInputView&nbsp;source)&nbsp;throws&nbsp;IOException;//这个比较重要,用于对序列化器进行快照存储public&nbsp;abstract&nbsp;TypeSerializerSnapshot<T>&nbsp;snapshotConfiguration();

2、对于 TypeSerializerSnapshot 来说它实际上就是提供了对序列化器的快照存储以及版本兼容处理,核心方法有这么几个:

//把当前序列化器以二进制格式和数据写到一起void&nbsp;writeSnapshot(DataOutputView&nbsp;out)&nbsp;throws&nbsp;IOException;//从当前输入流中,读出序列化器,一般会有一个类私有变量来存储void&nbsp;readSnapshot(int&nbsp;readVersion,&nbsp;DataInputView&nbsp;in,&nbsp;ClassLoader&nbsp;userCodeClassLoader)throws&nbsp;IOException;//检验当前序列化器是否能兼容之前版本TypeSerializerSchemaCompatibility<T>&nbsp;resolveSchemaCompatibility(TypeSerializer<T>&nbsp;newSerializer);//重置当前序列化器为之前的一个序列化器&nbsp;&nbsp;TypeSerializer<T>&nbsp;restoreSerializer();

这里面有两个比较核心,resolveSchemaCompatibility 传入一个新的序列化器,然后判断这个序列化器是否能够兼容反序列化之前版本序列化器序列化的数据,TypeSerializerSchemaCompatibility.type 枚举定义了可返回的类型:

enum&nbsp;Type&nbsp;{&nbsp;&nbsp;//兼容,并且今后使用用户新定义的&nbsp;Serializer&nbsp;&nbsp;COMPATIBLE_AS_IS&nbsp;&nbsp;//不兼容,需要重置之前序列化器反序列化后再使用新序列化器序列化&nbsp;&nbsp;COMPATIBLE_AFTER_MIGRATION&nbsp;&nbsp;//兼容,需要返回一个reconfiguredNewSerializer,替换传入的序列化器&nbsp;&nbsp;COMPATIBLE_WITH_RECONFIGURED_SERIALIZER&nbsp;&nbsp;//不兼容,作业抛异常退出&nbsp;&nbsp;INCOMPATIBLE}

那么对于我们上面的案例,把 POJO 中字段类型从 String 改成 Integer 的情况,其实只要重写 TypeSerializerSnapshot.resolveSchemaCompatibility 方法,返回 COMPATIBLE_AFTER_MIGRATION 类型,然后再 resolveSchemaCompatibility 中返回上一个版本的序列化器(可以反序列化String)即可。

限于篇幅就不再演示了,欢迎交流!

本文所有测试来自本地 docker 起的 Flink session 集群,如有需要 docker-compose.yml 文件的可以公众号回复「flink-docker」领取

手机扫一扫

移动阅读更方便

阿里云服务器
腾讯云服务器
七牛云服务器

你可能感兴趣的文章