目录
1. Flink中的状态
1.1 有状态算子
1.2 状态的管理
1.3 状态的分类
2. 按键分区状态(Keyed State)
2.1 基本概念和特点
2.2 支持的结构类型
2.3 代码实现
2.4 状态生存时间(TTL)
3. 算子状态(Operator State)
3.1 基本概念和特点
3.2 状态类型
3.3 代码实现
4. 广播状态(Broadcast State)
4.1 基本用法
4.2 代码示例
5. 状态持久化和状态后端
5.1 检查点(Checkpoint)
5.2 状态后端(State Backends)
Flink 处理机制的核心,就是“有状态的流式计算”。我们在之前的章节中也已经多次提到 了“状态”(state),不论是简单聚合、窗口聚合,还是处理函数的应用,都会有状态的身影出 现。在第一章中,我们已经简单介绍过有状态流处理,状态就如同事务处理时数据库中保存的 信息一样,是用来辅助进行任务计算的数据。而在 Flink 这样的分布式系统中,我们不仅需要 定义出状态在任务并行时的处理方式,还需要考虑如何持久化保存、以便发生故障时正确地恢 复。这就需要一套完整的管理机制来处理所有的状态。 本章将从状态的概念入手,详细介绍 Flink 中的状态分类、状态的使用、持久化及状态后端的配置。
1. Flink中的状态
在流处理中,数据是连续不断到来和处理的。每个任务进行计算处理时,可以基于当前数据直接转换得到输出结果;也可以依赖一些其他数据。这些由一个任务维护,并且用来计算输出结果的所有数据,就叫作这个任务的状态。
1.1 有状态算子
在 Flink 中,算子任务可以分为无状态和有状态两种情况。
无状态的算子任务只需要观察每个独立事件,根据当前输入的数据直接转换输出结果,如 图 9-1 所示。例如,可以将一个字符串类型的数据拆分开作为元组输出;也可以对数据做一些 计算,比如每个代表数量的字段加 1。我们之前讲到的基本转换算子,如 map、filter、flatMap, 计算时不依赖其他数据,就都属于无状态的算子。
而有状态的算子任务,则除当前数据之外,还需要一些其他数据来得到计算结果。这里的 “其他数据”,就是所谓的状态(state),最常见的就是之前到达的数据,或者由之前数据计算 出的某个结果。比如,做求和(sum)计算时,需要保存之前所有数据的和,这就是状态;窗 口算子中会保存已经到达的所有数据,这些也都是它的状态。另外,如果我们希望检索到某种 “事件模式”(event pattern),比如“先有下单行为,后有支付行为”,那么也应该把之前的行 为保存下来,这同样属于状态。容易发现,之前讲过的聚合算子、窗口算子都属于有状态的算子。
如图 9-2 所示为有状态算子的一般处理流程,具体步骤如下。
(1)算子任务接收到上游发来的数据;
(2)获取当前状态;
(3)根据业务逻辑进行计算,更新状态;
(4)得到计算结果,输出发送到下游任务。
1.2 状态的管理
在传统的事务型处理架构中,这种额外的状态数据是保存在数据库中的。而对于实时流处 理来说,这样做需要频繁读写外部数据库,如果数据规模非常大肯定就达不到性能要求了。所 以 Flink 的解决方案是,将状态直接保存在内存中来保证性能,并通过分布式扩展来提高吞吐 量。
在 Flink 中,每一个算子任务都可以设置并行度,从而可以在不同的 slot 上并行运行多个 实例,我们把它们叫作“并行子任务”。而状态既然在内存中,那么就可以认为是子任务实例 上的一个本地变量,能够被任务的业务逻辑访问和修改。
这样看来状态的管理似乎非常简单,我们直接把它作为一个对象交给 JVM 就可以了。然 而大数据的场景下,我们必须使用分布式架构来做扩展,在低延迟、高吞吐的基础上还要保证容错性,一系列复杂的问题就会随之而来了。
⚫ 状态的访问权限。我们知道 Flink 上的聚合和窗口操作,一般都是基于 KeyedStream 的,数据会按照 key 的哈希值进行分区,聚合处理的结果也应该是只对当前 key 有效。 然而同一个分区(也就是 slot)上执行的任务实例,可能会包含多个key 的数据,它们同时访问和更改本地变量,就会导致计算结果错误。所以这时状态并不是单纯的本地变量。
⚫ 容错性,也就是故障后的恢复。状态只保存在内存中显然是不够稳定的,我们需要将 它持久化保存,做一个备份;在发生故障后可以从这个备份中恢复状态。
⚫ 我们还应该考虑到分布式应用的横向扩展性。比如处理的数据量增大时,我们应该相应地对计算资源扩容,调大并行度。这时就涉及到了状态的重组调整。
可见状态的管理并不是一件轻松的事。好在 Flink 作为有状态的大数据流式处理框架,已经帮我们搞定了这一切。Flink 有一套完整的状态管理机制,将底层一些核心功能全部封装起 来,包括状态的高效存储和访问、持久化保存和故障恢复,以及资源扩展时的调整。这样,我们只需要调用相应的 API 就可以很方便地使用状态,或对应用的容错机制进行配置,从而将 更多的精力放在业务逻辑的开发上。
1.3 状态的分类
1、托管状态(Managed State)和原始状态(Raw State)
Flink 的状态有两种:托管状态(Managed State)和原始状态(Raw State)。托管状态就是 由 Flink 统一管理的,状态的存储访问、故障恢复和重组等一系列问题都由 Flink 实现,我们 只要调接口就可以;而原始状态则是自定义的,相当于就是开辟了一块内存,需要我们自己管 理,实现状态的序列化和故障恢复。
具体来讲,托管状态是由 Flink 的运行时(Runtime)来托管的;在配置容错机制后,状 态会自动持久化保存,并在发生故障时自动恢复。当应用发生横向扩展时,状态也会自动地重 组分配到所有的子任务实例上。对于具体的状态内容,Flink 也提供了值状态(ValueState)、 列表状态(ListState)、映射状态(MapState)、聚合状态(AggregateState)等多种结构,内部 支持各种数据类型。聚合、窗口等算子中内置的状态,就都是托管状态;我们也可以在富函数 类(RichFunction)中通过上下文来自定义状态,这些也都是托管状态。
而对比之下,原始状态就全部需要自定义了。Flink 不会对状态进行任何自动操作,也不 知道状态的具体数据类型,只会把它当作最原始的字节(Byte)数组来存储。我们需要花费大 量的精力来处理状态的管理和维护。
所以只有在遇到托管状态无法实现的特殊需求时,我们才会考虑使用原始状态;一般情况 下不推荐使用。绝大多数应用场景,我们都可以用 Flink 提供的算子或者自定义托管状态来实现需求。
2、算子状态(Operator State)和按键分区状态(Keyed State)
接下来我们的重点就是托管状态(Managed State)。
我们知道在 Flink 中,一个算子任务会按照并行度分为多个并行子任务执行,而不同的子任务会占据不同的任务槽(task slot)。由于不同的 slot 在计算资源上是物理隔离的,所以 Flink 能管理的状态在并行任务间是无法共享的,每个状态只能针对当前子任务的实例有效。
而很多有状态的操作(比如聚合、窗口)都是要先做 keyBy 进行按键分区的。按键分区之后,任务所进行的所有计算都应该只针对当前 key 有效,所以状态也应该按照 key 彼此隔离。 在这种情况下,状态的访问方式又会有所不同。
基于这样的想法,我们又可以将托管状态分为两类:算子状态和按键分区状态。
(1)算子状态(Operator State)
状态作用范围限定为当前的算子任务实例,也就是只对当前并行子任务实例有效。这就意 味着对于一个并行子任务,占据了一个“分区”,它所处理的所有数据都会访问到相同的状态, 状态对于同一任务而言是共享的,如图 9-3 所示。
算子状态可以用在所有算子上,使用的时候其实就跟一个本地变量没什么区别——因为本 地变量的作用域也是当前任务实例。在使用时,我们还需进一步实现 CheckpointedFunction 接 口。
(2)按键分区状态(Keyed State)
状态是根据输入流中定义的键(key)来维护和访问的,所以只能定义在按键分区流 (KeyedStream)中,也就 keyBy 之后才可以使用,如图所示
按键分区状态应用非常广泛。之前讲到的聚合算子必须在 keyBy 之后才能使用,就是因 为聚合的结果是以 Keyed State 的形式保存的。另外,也可以通过富函数类(Rich Function) 来自定义 Keyed State,所以只要提供了富函数类接口的算子,也都可以使用 Keyed State。
所以即使是 map、filter 这样无状态的基本转换算子,我们也可以通过富函数类给它们“追 加”Keyed State,或者实现 CheckpointedFunction 接口来定义 Operator State;从这个角度讲, Flink 中所有的算子都可以是有状态的,不愧是“有状态的流处理”。
无论是 Keyed State 还是 Operator State,它们都是在本地实例上维护的,也就是说每个并 行子任务维护着对应的状态,算子的子任务之间状态不共享。关于状态的具体使用,我们会在 下面继续展开讲解。
2. 按键分区状态(Keyed State)
在实际应用中,我们一般都需要将数据按照某个 key 进行分区,然后再进行计算处理;所 以最为常见的状态类型就是 Keyed State。之前介绍到 keyBy 之后的聚合、窗口计算,算子所 持有的状态,都是 Keyed State。
另外,我们还可以通过富函数类(Rich Function)对转换算子进行扩展、实现自定义功能, 比如 RichMapFunction、RichFilterFunction。在富函数中,我们可以调用.getRuntimeContext() 获取当前的运行时上下文(RuntimeContext),进而获取到访问状态的句柄;这种富函数中自 定义的状态也是 Keyed State。
2.1 基本概念和特点
按键分区状态(Keyed State)顾名思义,是任务按照键(key)来访问和维护的状态。它 的特点非常鲜明,就是以 key 为作用范围进行隔离。
我们知道,在进行按键分区(keyBy)之后,具有相同键的所有数据,都会分配到同一个 并行子任务中;所以如果当前任务定义了状态,Flink 就会在当前并行子任务实例中,为每个 键值维护一个状态的实例。于是当前任务就会为分配来的所有数据,按照 key 维护和处理对应 的状态。
因为一个并行子任务可能会处理多个 key 的数据,所以 Flink 需要对 Keyed State 进行一些 特殊优化。在底层,Keyed State 类似于一个分布式的映射(map)数据结构,所有的状态会根 据 key 保存成键值对(key-value)的形式。这样当一条数据到来时,任务就会自动将状态的访 问范围限定为当前数据的 key,从 map 存储中读取出对应的状态值。所以具有相同 key 的所有 数据都会到访问相同的状态,而不同 key 的状态之间是彼此隔离的。
这种将状态绑定到 key 上的方式,相当于使得状态和流的逻辑分区一一对应了:不会有别 的 key 的数据来访问当前状态;而当前状态对应 key 的数据也只会访问这一个状态,不会分发到其他分区去。这就保证了对状态的操作都是本地进行的,对数据流和状态的处理做到了分区一致性。
另外,在应用的并行度改变时,状态也需要随之进行重组。不同 key 对应的 Keyed State 可以进一步组成所谓的键组(key groups),每一组都对应着一个并行子任务。键组是 Flink 重 新分配 Keyed State 的单元,键组的数量就等于定义的最大并行度。当算子并行度发生改变时, Keyed State 就会按照当前的并行度重新平均分配,保证运行时各个子任务的负载相同。
需要注意,使用 Keyed State 必须基于 KeyedStream。没有进行 keyBy 分区的 DataStream, 即使转换算子实现了对应的富函数类,也不能通过运行时上下文访问 Keyed State。
2.2 支持的结构类型
实际应用中,需要保存为状态的数据会有各种各样的类型,有时还需要复杂的集合类型, 比如列表(List)和映射(Map)。对于这些常见的用法,Flink 的按键分区状态(Keyed State) 提供了足够的支持。接下来我们就来了解一下 Keyed State 所支持的结构类型
1、值状态(ValueState)
顾名思义,状态中只保存一个“值”(value)。ValueState本身是一个接口,源码中定义如下:
这里需要传入状态的名称和类型——这跟我们声明一个变量时做的事情完全一样。有了这 个描述器,运行时环境就可以获取到状态的控制句柄(handler)了。关于代码中状态的使用, 我们会在下一节详细介绍。
2、列表状态(ListState)
将需要保存的数据,以列表(List)的形式组织起来。在 ListState接口中同样有一个 类型参数 T,表示列表中数据的类型。ListState 也提供了一系列的方法来操作状态,使用方式 与一般的 List 非常相似
⚫ Iterable get():获取当前的列表状态,返回的是一个可迭代类型 Iterable;
⚫ update(List values):传入一个列表 values,直接对状态进行覆盖;
⚫ add(T value):在状态列表中添加一个元素 value;
⚫ addAll(List values):向列表中添加多个元素,以列表 values 形式传入。
类似地,ListState 的状态描述器就叫作 ListStateDescriptor,用法跟 ValueStateDescriptor 完全一致。
3、映射状态(MapState)
把一些键值对(key-value)作为状态整体保存起来,可以认为就是一组 key-value 映射的 列表。对应的 MapState接口中,就会有 UK、UV 两个泛型,分别表示保存的 key 和 value 的类型。同样,MapState 提供了操作映射状态的方法,与 Map 的使用非常类似。
⚫ UV get(UK key):传入一个 key 作为参数,查询对应的 value 值;
⚫ put(UK key, UV value):传入一个键值对,更新 key 对应的 value 值;
⚫ putAll(Map map):将传入的映射 map 中所有的键值对,全部添加到映射状 态中;
⚫ remove(UK key):将指定 key 对应的键值对删除;
⚫ boolean contains(UK key):判断是否存在指定的 key,返回一个 boolean 值。 另外,MapState 也提供了获取整个映射相关信息的方法:
⚫ Iterable> entries():获取映射状态中所有的键值对;
⚫ Iterable keys():获取映射状态中所有的键(key),返回一个可迭代 Iterable 类型;
⚫ Iterable values():获取映射状态中所有的值(value),返回一个可迭代 Iterable 类型;
⚫ boolean isEmpty():判断映射是否为空,返回一个 boolean 值。
4、归约状态(ReducingState)
类似于值状态(Value),不过需要对添加进来的所有数据进行归约,将归约聚合之后的值作为状态保存下来。ReducingState这个接口调用的方法类似于 ListState,只不过它保存的只是一个聚合值,所以调用.add()方法时,不是在状态列表里添加元素,而是直接把新数据和之前的状态进行归约,并用得到的结果更新状态。
归约逻辑的定义,是在归约状态描述器(ReducingStateDescriptor)中,通过传入一个归 约函数(ReduceFunction)来实现的。这里的归约函数,就是我们之前介绍 reduce 聚合算子时 讲到的 ReduceFunction,所以状态类型跟输入的数据类型是一样的。
这里的描述器有三个参数,其中第二个参数就是定义了归约聚合逻辑的 ReduceFunction, 另外两个参数则是状态的名称和类型。
5、 聚合状态(AggregatingState)
与归约状态非常类似,聚合状态也是一个值,用来保存添加进来的所有数据的聚合结果。 与 ReducingState 不同的是,它的聚合逻辑是由在描述器中传入一个更加一般化的聚合函数(AggregateFunction)来定义的;这也就是之前我们讲过的 AggregateFunction,里面通过一个 累加器(Accumulator)来表示状态,所以聚合的状态类型可以跟添加进来的数据类型完全不 同,使用更加灵活。
同样地,AggregatingState 接口调用方法也与 ReducingState 相同,调用.add()方法添加元素 时,会直接使用指定的 AggregateFunction 进行聚合并更新状态。
2.3 代码实现
了解了按键分区状态(Keyed State)的基本概念和类型,接下来我们就可以尝试在代码中 使用状态了。
1、整体介绍
在 Flink 中,状态始终是与特定算子相关联的;算子在使用状态前首先需要“注册”,其 实就是告诉 Flink 当前上下文中定义状态的信息,这样运行时的 Flink 才能知道算子有哪些状态。
状态的注册,主要是通过“状态描述器”(StateDescriptor)来实现的。状态描述器中最重 要的内容,就是状态的名称(name)和类型(type)。我们知道 Flink 中的状态,可以认为是加 了一些复杂操作的

本文详细介绍了Flink中的状态管理,包括有状态算子、状态管理、状态分类,重点讲述了按键分区状态(Keyed State)的类型、使用和代码实现,并提到了算子状态(Operator State)和广播状态(Broadcast State)。此外,还讨论了状态持久化和状态后端,包括检查点(Checkpoint)和状态后端(State Backends)的选择与配置。
最低0.47元/天 解锁文章
1002

被折叠的 条评论
为什么被折叠?



