1.状态的概念
在流处理中,数据是连续不断到来和处理的。每个任务进行计算处理时,可以基于当前数据直接转换得到输出结果;也可以依赖一些其他数据。这些由一个任务维护,并且用来计算输出结果的所有数据,就叫作这个任务的状态。
什么场景会用到状态,下面列举了三种场景:
- 去重:比如上游的系统数据可能会重复,落到下游系统时希望把重复的数据都去掉。去重需要先了解哪些数据来过,哪些数据还没有来,也就是把所有的主键都记录下来,当一条数据到来后,能够看到在主键当中是否存在。
- 窗口计算:比如统计每分钟Nginx日志API被访问了多少次。窗口是一分钟计算一次,在窗口触发前,如08:00~08:01这个窗口,前59秒的数据来了需要先放入内存,即需要把这个窗口之内的数据先保留下来,等到8:01时一分钟后,再将整个窗口内触发的数据输出。未触发的窗口也是一种状态。
- 访问历史数据:比如与昨天的数据进行对比,需要访问一些历史数据。如果每次从外部去读,对资源的消耗可能比较大,所以也希望把这些历史数据也放入状态中做对比。
2.有状态的算子
在Flink中,算子任务可以分为无状态和有状态两种情况。
无状态算子:无论这条输入进来多少次,输出的结果都是一样的,因为单条输入中已经包含了所需的所有信息。所以相同输入可以得到相同输出,这就是一个无状态算子。如map、filter、flatMap,计算时不依赖其他数据,就都属于无状态的算子。
有状态的算子任务:则除当前数据之外,还需要一些其他数据来得到计算结果。这里的“其他数据”,就是所谓的状态(state),最常见的就是之前到达的数据,或者由之前数据计算出的某个结果。比如,做求和(sum)计算时,需要保存之前所有数据的和,这就是状态;窗口算子中会保存已经到达的所有数据,这些也都是它的状态。下图所示是一个有状态的算子处理流程:
3.状态管理机制
在传统的事务型处理架构中,这种额外的状态数据是保存在数据库中的。而对于实时流处理来说,这样做需要频繁读写外部数据库,如果数据规模非常大肯定就达不到性能要求了。所以Flink的解决方案是,将状态直接保存在内存中来保证性能,并通过分布式扩展来提高吞吐量。
但对于流式作业有以下要求:
- 7*24小时运行,高可靠
- 数据不丢不重,恰好计算一次
- 数据实时产出,不延迟
- 由于内存的容量是有限的,如果要做24小时的窗口计算,将24小时的数据都放到内存,可能会出现内存不足
- 作业是7*24小时运行,需要保障高可用,机器若出现故障或者宕机,需要考虑如何备份及从备份中恢复,保证运行的作业不受影响
- 还应该考虑到分布式应用的横向扩展性。比如处理的数据量增大时,我们应该相应地对计算资源扩容,调大并行度。这时就涉及到了状态的重组调整。
- 状态的访问权限。我们知道Flink上的聚合和窗口操作,一般都是基于KeyedStream的,数据会按照key的哈希值进行分区,聚合处理的结果也应该是只对当前key有效。然而同一个分区(也就是slot)上执行的任务实例,可能会包含多个key的数据,它们同时访问和更改本地变量,就会导致计算结果错误。所以这时状态并不是单纯的本地变量。
4.状态的分类
4.1.托管状态和原始状态
Flink的状态有两种:托管状态(Managed State)和原始状态(Raw State)
托管状态:就是由Flink统一管理的,状态的存储访问、故障恢复和重组等一系列问题都由Flink实现,我们只要调接口就可以。对于具体的状态内容,Flink也提供了值状态(ValueState)、列表状态(ListState)、映射状态(MapState)、聚合状态(AggregateState)等多种结构,内部支持各种数据类型。聚合、窗口等算子中内置的状态,就都是托管状态;我们也可以在富函数类(RichFunction)中通过上下文来自定义状态,这些也都是托管状态。
原始状态:是自定义的,相当于就是开辟了一块内存,需要我们自己管理,Flink不会对状态进行任何自动操作,也不知道状态的具体数据类型,只会把它当作最原始的字节(Byte)数组来存储。我们需要花费大量的精力来处理状态的管理和维护,实现状态的序列化和故障恢复。
只有在遇到托管状态无法实现的特殊需求时,我们才会考虑使用原始状态;一般情况下不推荐使用。绝大多数应用场景,我们都可以用Flink提供的算子或者自定义托管状态来实现需求。
接下来我们的重点就是托管状态。
4.2.算子状态和按键分区状态
我们知道在Flink中,一个算子任务会按照并行度分为多个并行子任务执行,而不同的子任务会占据不同的任务槽(task slot)。由于不同的slot在计算资源上是物理隔离的,所以Flink能管理的状态在并行任务间是无法共享的,每个状态只能针对当前子任务的实例有效。
而很多有状态的操作(比如聚合、窗口)都是要先做keyBy进行按键分区的。按键分区之后,任务所进行的所有计算都应该只针对当前key有效,所以状态也应该按照key彼此隔离。在这种情况下,状态的访问方式又会有所不同。
基于这样的想法,我们又可以将托管状态分为两类:算子状态和按键分区状态。
(1)算子状态
状态作用范围限定为当前的算子任务实例,也就是只对当前并行子任务实例有效。这就意味着对于一个并行子任务,占据了一个“分区”,它所处理的所有数据都会访问到相同的状态,状态对于同一任务而言是共享的,如下图所示
算子状态可以用在所有算子上,使用的时候其实就跟一个本地变量没什么区别——因为本地变量的作用域也是当前任务实例。
(2)按键分区状态
状态是根据输入流中定义的键(key)来维护和访问的,所以只能定义在按键分区流(KeyedStream)中,也就keyBy之后才可以使用,如下图所示:
按键分区状态应用非常广泛。如聚合算子必须在keyBy之后才能使用,就是因为聚合的结果是以Keyed State的形式保存的。另外,也可以通过富函数类(Rich Function)来自定义Keyed State,所以只要提供了富函数类接口的算子,也都可以使用Keyed State。
所以即使是map、filter这样无状态的基本转换算子,我们也可以通过富函数类给它们“追加”Keyed State,或者实现CheckpointedFunction接口来定义Operator State;从这个角度讲,Flink中所有的算子都可以是有状态的,不愧是“有状态的流处理”。
无论是Keyed State还是Operator State,它们都是在本地实例上维护的,也就是说每个并行子任务维护着对应的状态,算子的子任务之间状态不共享。
5.Keyed State的概念
在实际应用中,我们一般都需要将数据按照某个key进行分区,然后再进行计算处理;所以最为常见的状态类型就是Keyed State。keyBy之后的聚合、窗口计算,算子所持有的状态,都是Keyed State。
我们知道,在进行按键分区(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就会按照当前的并行度重新平均分配,保证运行时各个子任务的负载相同。
6.Keyed State的结构类型
实际应用中,需要保存为状态的数据会有各种各样的类型,有时还需要复杂的集合类型,比如列表(List)和映射(Map)。对于这些常见的用法,Flink的按键分区状态(Keyed State)提供了足够的支持。接下来我们就来了解一下Keyed State 所支持的结构类型
- 值状态(ValueState):顾名思义,状态中只保存一个“值”(value)。
- 列表状态(ListState):将需要保存的数据,以列表(List)的形式组织起来。
- 映射状态(MapState):把一些键值对(key-value)作为状态整体保存起来,可以认为就是一组key-value映射的列表。
- 规约状态(ReducingState):类似于值状态(Value),不过需要对添加进来的所有数据进行归约,将归约聚合之后的值作为状态保存下来。
- 聚合状态(AggregatingState):与归约状态非常类似,聚合状态也是一个值,用来保存添加进来的所有数据的聚合结果。与ReducingState不同的是,它的聚合逻辑是由在描述器中传入一个更加一般化的聚合函数(AggregateFunction)来定义的;这也就是之前我们讲过的AggregateFunction,里面通过一个累加器(Accumulator)来表示状态,所以聚合的状态类型可以跟添加进来的数据类型完全不同,使用更加灵活。
7.
在 Flink 中,状态始终是与特定算子相关联的;算子在使用状态前首先需要“注册”,其实就是告诉Flink当前上下文中定义状态的信息,这样运行时的 Flink 才能知道算子有哪些状态。
状态的注册,主要是通过“状态描述器”(StateDescriptor)来实现的。状态描述器中最重要的内容,就是状态的名称(name)和类型(type)。我们知道Flink中的状态,可以认为是加了一些复杂操作的内存中的变量;而当我们在代码中声明一个局部变量时,都需要指定变量类型和名称,名称就代表了变量在内存中的地址,类型则指定了占据内存空间的大小。同样地,我们一旦指定了名称和类型,Flink就可以在运行时准确地在内存中找到对应的状态,进而返回状态对象供我们使用了。所以在一个算子中,我们也可以定义多个状态,只要它们的名称不同就可以了。
另外,状态描述器中还可能需要传入一个用户自定义函数(user-defined-function,UDF),用来说明处理逻辑,比如前面提到的ReduceFunction和AggregateFunction。
以ValueState为例,我们可以定义值状态描述器如下:
ValueStateDescriptor<Long> descriptor = new ValueStateDescriptor<>( "my state", // 状态名称 Types.LONG // 状态类型 );
这里我们定义了一个叫作“my state”的长整型ValueState的描述器。
8.值状态—代码使用
案例,统计用户访问频次,使用值状态来保存count值
public class StateTest { public static void main(String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1); SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource()) .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(0)) .withTimestampAssigner((element, recordTimestamp) -> element.timestamp) ); stream.keyBy(value -> value.user) .flatMap(new RichFlatMapFunction<Event, String>() { // 定义一个值状态 ValueState<Long> myValueState; @Override public void open(Configuration parameters) throws Exception { // 定义一个值状态描述器,用来在运行时环境中声明状态 ValueStateDescriptor<Long> valueStateDescriptor = new ValueStateDescriptor<>("my-value", Long.class); myValueState = getRuntimeContext().getState(valueStateDescriptor); } @Override public void flatMap(Event value, Collector<String> out) throws Exception { // 对值状态进行处理操作 if (myValueState.value() == null) { myValueState.update(1L); } else { myValueState.update(myValueState.value() + 1); } out.collect("my value state - " + "用户" + value.user + "的访问频次为:" + myValueState.value()); } }).print(); env.execute(); } }
9.列表状态—代码使用
public class StateTest { public static void main(String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1); SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource()) .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(0)) .withTimestampAssigner((element, recordTimestamp) -> element.timestamp) ); stream.keyBy(value -> value.user) .flatMap(new RichFlatMapFunction<Event, String>() { // 定义一个列表状态变量 ListState<Event> myListState; @Override public void open(Configuration parameters) throws Exception { // 定义一个列表状态描述器 myListState = getRuntimeContext().getListState(new ListStateDescriptor<Event>("my-list", Event.class)); } @Override public void flatMap(Event value, Collector<String> out) throws Exception { // 使用列表状态 myListState.add(value); out.collect("my list state: " + myListState.get()); } }).print(); env.execute(); } }
10.
映射状态的用法和Java中的HashMap很相似。在这里我们通过MapState模拟一个URL的访问统计测试。
public class StateTest { public static void main(String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1); SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource()) .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(0)) .withTimestampAssigner((element, recordTimestamp) -> element.timestamp) ); stream.keyBy(value -> value.user) .flatMap(new RichFlatMapFunction<Event, String>() { // 定义一个映射状态变量,保存(url, count) MapState<String, Long> myMapState; @Override public void open(Configuration parameters) throws Exception { // 定义一个映射状态描述器,用来在运行时环境中声明状态 myMapState = getRuntimeContext().getMapState(new MapStateDescriptor<String, Long>("my-map", String.class, Long.class)); } @Override public void flatMap(Event value, Collector<String> out) throws Exception { // 使用映射状态 if (myMapState.contains(value.url)) { myMapState.put(value.url, myMapState.get(value.url) + 1); } else { myMapState.put(value.url, 1L); } out.collect("my map state: (" + value.url + ", " + myMapState.get(value.url) + ")"); } }).print(); env.execute(); } }
11.规约状态—代码使用
public class StateTest { public static void main(String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1); SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource()) .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(0)) .withTimestampAssigner((element, recordTimestamp) -> element.timestamp) ); stream.keyBy(value -> value.user) .flatMap(new RichFlatMapFunction<Event, String>() { // 定义一个规约状态变量,保存(url, count) ReducingState<Event> myReducingState; @Override public void open(Configuration parameters) throws Exception { // 定义一个规约状态描述器,用来在运行时环境中声明状态 ReducingStateDescriptor<Event> reducingStateDescriptor = new ReducingStateDescriptor<>( "my-reducing", ((value1, value2) -> new Event(value1.user, value1.url + ", " + value2.url, value1.timestamp > value2.timestamp ? value1.timestamp : value2.timestamp)), Event.class ); myReducingState = getRuntimeContext().getReducingState(reducingStateDescriptor); } @Override public void flatMap(Event value, Collector<String> out) throws Exception { // 使用归约状态 myReducingState.add(value); out.collect("my reducing state: " + myReducingState.get()); } }).print(); env.execute(); } }
12.聚合状态—代码使用
public class StateTest { public static void main(String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1); SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource()) .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(0)) .withTimestampAssigner((element, recordTimestamp) -> element.timestamp) ); stream.keyBy(value -> value.user) .flatMap(new RichFlatMapFunction<Event, String>() { // 声明聚合状态变量 AggregatingState<Event, String> myAggState; @Override public void open(Configuration parameters) throws Exception { // 定义一个状态描述器 AggregatingStateDescriptor<Event, Integer, String> aggregatingStateDescriptor = new AggregatingStateDescriptor<>( "my-agg", new AggregateFunction<Event, Integer, String>() { @Override public Integer createAccumulator() { return 0; } @Override public Integer add(Event value, Integer accumulator) { return accumulator + 1; } @Override public String getResult(Integer accumulator) { return "当前用户的访问频次为:" + accumulator; } @Override public Integer merge(Integer a, Integer b) { return a + b; } }, Integer.class ); myAggState = getRuntimeContext().getAggregatingState(aggregatingStateDescriptor); } @Override public void flatMap(Event value, Collector<String> out) throws Exception { // 使用聚合状态 myAggState.add(value); out.collect("my aggregating state: " + myAggState.get()); } }).print(); env.execute(); } }
13.状态的生存时间TTL
在实际应用中,很多状态会随着时间的推移逐渐增长,如果不加以限制,最终就会导致存储空间的耗尽。一个优化的思路是直接在代码中调用.clear()方法去清除状态,但是有时候我们的逻辑要求不能直接清除。这时就需要配置一个状态的“生存时间”(time-to-live,TTL),当状态在内存中存在的时间超出这个值时,就将它清除。
具体实现上,状态创建的时候,设置 失效时间 = 当前时间 + TTL;之后如果有对状态的访问和修改,我们可以再对失效时间进行更新;当设置的清除条件被触发时,就可以判断状态是否失效、从而进行清除了。
代码配置示例:
public class StateTest { public static void main(String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1); SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource()) .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(0)) .withTimestampAssigner((element, recordTimestamp) -> element.timestamp) ); stream.keyBy(value -> value.user) .flatMap(new RichFlatMapFunction<Event, String>() { // 定义一个值状态 ValueState<Long> myValueState; @Override public void open(Configuration parameters) throws Exception { // 设置一个状态生存时间,时间是1小时 StateTtlConfig ttlConfig = StateTtlConfig.newBuilder(Time.hours(1)) // 设置更新的类型 .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite) // 设置状态的可见性 .setStateVisibility(StateTtlConfig.StateVisibility.ReturnExpiredIfNotCleanedUp) .build(); // 定义一个值状态描述器 ValueStateDescriptor<Long> valueStateDescriptor = new ValueStateDescriptor<>("my-value", Long.class); // 状态描述器启动TTL功能 valueStateDescriptor.enableTimeToLive(ttlConfig); myValueState = getRuntimeContext().getState(valueStateDescriptor); } @Override public void flatMap(Event value, Collector<String> out) throws Exception { // 对值状态进行处理操作 if (myValueState.value() == null) { myValueState.update(1L); } else { myValueState.update(myValueState.value() + 1); } out.collect("my value state - " + "用户" + value.user + "的访问频次为:" + myValueState.value()); } }).print(); env.execute(); } }
这里用到了几个配置项:
.newBuilder():状态TTL配置的构造器方法,方法需要传入一个Time作为参数,这就是设定的状态生存时间。
.setUpdateType():设置更新类型。更新类型指定了什么时候更新状态失效时间,这里的OnCreateAndWrite表示只有创建状态和更改状态(写操作)时更新失效时间。另一种类型OnReadAndWrite则表示无论读写操作都会更新失效时间,也就是只要对状态进行了访问,就表明它是活跃的,从而延长生存时间。这个配置默认为OnCreateAndWrite。
.setStateVisibility():
这里需要注意,目前的TTL设置只支持处理时间。
转载请注明:西门飞冰的博客 » Flink 状态管理