Flink状态编程(二)

目录

 

9.2.4 状态生存时间(TTL)

9.3 算子状态(Operator State)

9.3.1 基本概念和特点

9.3.2 状态类型

9.3.3 代码实现

9.4 广播状态(Broadcast State)

9.4.1 基本用法

9.4.2 代码实例

9.5 状态持久化和状态后端

9.5.1 检查点(Checkpoint)

9.5.2 状态后端(State Backends)


 

9.2.4 状态生存时间(TTL)

在实际应用中,很多状态会随着时间的推移逐渐增长,如果不加以限制,最终就会导致存储空间的耗尽。一个优化的思路是直接在代码中调用.clear()方法去清除状态,但是有时候我们 的逻辑要求不能直接清除。这时就需要配置一个状态的“生存时间”(time-to-live,TTL),当状态在内存中存在的时间超出这个值时,就将它清除。

具体实现上,如果用一个进程不停地扫描所有状态看是否过期,显然会占用大量资源做无用功。状态的失效其实不需要立即删除,所以我们可以给状态附加一个属性,也就是状态的“失效时间”。状态创建的时候,设置 失效时间 = 当前时间 + TTL;之后如果有对状态的访问和修改,我们可以再对失效时间进行更新;当设置的清除条件被触发时(比如,状态被访问的时候,或者每隔一段时间扫描一次失效状态),就可以判断状态是否失效、从而进行清除了。

配置状态的 TTL 时,需要创建一个 StateTtlConfig 配置对象,然后调用状态描述器 的.enableTimeToLive()方法启动 TTL 功能。

StateTtlConfig ttlConfig = StateTtlConfig 
 .newBuilder(Time.seconds(10)) 
 .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite) 
 .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired) 
 .build(); 
ValueStateDescriptor<String> stateDescriptor = new ValueStateDescriptor<>("my state", String.class); 
stateDescriptor.enableTimeToLive(ttlConfig); 

这里用到了几个配置项:

⚫ .newBuilder()

状态 TTL 配置的构造器方法,必须调用,返回一个 Builder 之后再调用.build()方法就可以 得到 StateTtlConfig 了。方法需要传入一个 Time 作为参数,这就是设定的状态生存时间。

⚫ .setUpdateType()

设置更新类型。更新类型指定了什么时候更新状态失效时间,这里的 OnCreateAndWrite表示只有创建状态和更改状态(写操作)时更新失效时间。另一种类型 OnReadAndWrite 则表 示无论读写操作都会更新失效时间,也就是只要对状态进行了访问,就表明它是活跃的,从而 延长生存时间。这个配置默认为 OnCreateAndWrite。

⚫ .setStateVisibility()

设置状态的可见性。所谓的“状态可见性”,是指因为清除操作并不是实时的,所以当状态过期之后还有可能基于存在,这时如果对它进行访问,能否正常读取到就是一个问题了。这里设置的 NeverReturnExpired 是默认行为,表示从不返回过期值,也就是只要过期就认为它已经被清除了,应用不能继续读取;这在处理会话或者隐私数据时比较重要。对应的另一种配置 是 ReturnExpireDefNotCleanedUp,就是如果过期状态还存在,就返回它的值

除此之外,TTL 配置还可以设置在保存检查点(checkpoint)时触发清除操作,或者配置增量的清理(incremental cleanup),还可以针对 RocksDB 状态后端使用压缩过滤器(compaction filter)进行后台清理。

这里需要注意,目前的 TTL 设置只支持处理时间。另外,所有集合类型的状态(例如ListState、MapState)在设置 TTL 时,都是针对每一项(per-entry)元素的。也就是说,一个 列表状态中的每一个元素,都会以自己的失效时间来进行清理,而不是整个列表一起清理。

//值状态的TTL配置
            ValueStateDescriptor<Event> valueStateDescriptor = new ValueStateDescriptor<>("my-ValueState", Event.class);


            myValueState=getRuntimeContext().getState(valueStateDescriptor);

            //配置状态的TTL
            StateTtlConfig ttlConfig=StateTtlConfig.newBuilder(Time.hours(1))
                    .setUpdateType(StateTtlConfig.UpdateType.OnReadAndWrite)
                    .setStateVisibility(StateTtlConfig.StateVisibility.ReturnExpiredIfNotCleanedUp)
                    .build();

            valueStateDescriptor.enableTimeToLive(ttlConfig);

9.3 算子状态(Operator State)

除按键分区状态(Keyed State)之外,另一大类受控状态就是算子状态(Operator State)。 从某种意义上说,算子状态是更底层的状态类型,因为它只针对当前算子并行任务有效,不需 要考虑不同 key 的隔离。算子状态功能不如按键分区状态丰富,应用场景较少,它的调用方法 也会有一些区别。

9.3.1 基本概念和特点

算子状态(Operator State)就是一个算子并行实例上定义的状态,作用范围被限定为当前 算子任务。算子状态跟数据的 key 无关,所以不同 key 的数据只要被分发到同一个并行子任务, 就会访问到同一个 Operator State。

算子状态的实际应用场景不如 Keyed State 多,一般用在 Source 或 Sink 等与外部系统连接 的算子上,或者完全没有 key 定义的场景。比如 Flink 的 Kafka 连接器中,就用到了算子状态。 在我们给 Source 算子设置并行度后,Kafka 消费者的每一个并行实例,都会为对应的主题(topic)分区维护一个偏移量, 作为算子状态保存起来。这在保证 Flink 应用“精确一次” (exactly-once)状态一致性时非常有用。

当算子的并行度发生变化时,算子状态也支持在并行的算子任务实例之间做重组分配。根 据状态的类型不同,重组分配的方案也会不同。

9.3.2 状态类型

1. 列表状态(ListState)

与 Keyed State 中的 ListState 一样,将状态表示为一组数据的列表。

与 Keyed State 中的列表状态的区别是:在算子状态的上下文中,不会按键(key)分别处理状态,所以每一个并行子任务上只会保留一个“列表”(list),也就是当前并行子任务上所 有状态项的集合。列表中的状态项就是可以重新分配的最细粒度,彼此之间完全独立。

当算子并行度进行缩放调整时,算子的列表状态中的所有元素项会被统一收集起来,相当 于把多个分区的列表合并成了一个“大列表”,然后再均匀地分配给所有并行任务。这种“均 匀分配”的具体方法就是“轮询”(round-robin),与之前介绍的 rebanlance 数据传输方式类似, 是通过逐一“发牌”的方式将状态项平均分配的。这种方式也叫作“平均分割重组”(even-split redistribution)。

算子状态中不会存在“键组”(key group)这样的结构,所以为了方便重组分配,就把它直接定义成了“列表”(list)。这也就解释了,为什么算子状态中没有最简单的值状态 (ValueState)。

2. 联合列表状态(UnionListState)

与 ListState 类似,联合列表状态也会将状态表示为一个列表。它与常规列表状态的区别 在于,算子并行度进行缩放调整时对于状态的分配方式不同。 UnionListState 的重点就在于“联合”(union)。在并行度调整时,常规列表状态是轮询分配状态项,而联合列表状态的算子则会直接广播状态的完整列表。这样,并行度缩放之后的并行子任务就获取到了联合后完整的“大列表”,可以自行选择要使用的状态项和要丢弃的状态项。这种分配也叫作“联合重组”(union redistribution)。如果列表中状态项数量太多,为资源和效率考虑一般不建议使用联合重组的方式

3. 广播状态(BroadcastState)

有时我们希望算子并行子任务都保持同一份“全局”状态,用来做统一的配置和规则设定。 这时所有分区的所有数据都会访问到同一个状态,状态就像被“广播”到所有分区一样,这种 特殊的算子状态,就叫作广播状态(BroadcastState)。

因为广播状态在每个并行子任务上的实例都一样,所以在并行度调整的时候就比较简单, 只要复制一份到新的并行任务就可以实现扩展;而对于并行度缩小的情况,可以将多余的并行 子任务连同状态直接砍掉——因为状态都是复制出来的,并不会丢失。

在底层,广播状态是以类似映射结构(map)的键值对(key-value)来保存的,必须基于一个“广播流”(BroadcastStream)来创建。

9.3.3 代码实现

我们已经知道,状态从本质上来说就是算子并行子任务实例上的一个特殊本地变量。它的特殊之处就在于 Flink 会提供完整的管理机制,来保证它的持久化保存,以便发生故障时进行 状态恢复;另外还可以针对不同的 key 保存独立的状态实例。按键分区状态(Keyed State)对这两个功能都要考虑;而算子状态(Operator State)并不考虑 key 的影响,所以主要任务就是要让 Flink 了解状态的信息、将状态数据持久化后保存到外部存储空间

看起来算子状态的使用应该更加简单才对。不过仔细思考又会发现一个问题:我们对状态 进行持久化保存的目的是为了故障恢复;在发生故障、重启应用后,数据还会被发往之前分配 的分区吗?显然不是,因为并行度可能发生了调整,不论是按键(key)的哈希值分区,还是 直接轮询(round-robin)分区,数据分配到的分区都会发生变化。这很好理解,当打牌的人数 从 3 个增加到 4 个时,即使牌的次序不变,轮流发到每个人手里的牌也会不同。数据分区发生 变化,带来的问题就是,怎么保证原先的状态跟故障恢复后数据的对应关系呢?

对于 Keyed State 这个问题很好解决:状态都是跟 key 相关的,而相同 key 的数据不管发 往哪个分区,总是会全部进入一个分区的;于是只要将状态也按照 key 的哈希值计算出对应的 分区,进行重组分配就可以了。恢复状态后继续处理数据,就总能按照 key 找到对应之前的状 态,就保证了结果的一致性。所以 Flink 对 Keyed State 进行了非常完善的包装,我们不需实现 任何接口就可以直接使用。

而对于Operator State 来说就会有所不同。因为不存在 key,所有数据发往哪个分区是不可 预测的;也就是说,当发生故障重启之后,我们不能保证某个数据跟之前一样,进入到同一个 并行子任务、访问同一个状态。所以 Flink 无法直接判断该怎样保存和恢复状态,而是提供了接口,让我们根据业务需求自行设计状态的快照保存(snapshot)和恢复(restore)逻辑。

1. CheckpointedFunction 接口

在 Flink 中,对状态进行持久化保存的快照机制叫作“检查点”(Checkpoint)。于是使用 算子状态时,就需要对检查点的相关操作进行定义,实现一个 CheckpointedFunction 接口。 CheckpointedFunction 接口在源码中定义如下:

public interface CheckpointedFunction { 
// 保存状态快照到检查点时,调用这个方法 
void snapshotState(FunctionSnapshotContext context) throws Exception 
// 初始化状态时调用这个方法,也会在恢复状态时调用 
 void initializeState(FunctionInitializationContext context) throws Exception; 
}

每次应用保存检查点做快照时,都会调用.snapshotState()方法,将状态进行外部持久化。 而在算子任务进行初始化时,会调用. initializeState()方法。这又有两种情况:

一种是整个应用 第一次运行,这时状态会被初始化为一个默认值(default value);

另一种是应用重启时,从检 查点(checkpoint)或者保存点(savepoint)中读取之前状态的快照,并赋给本地状态。

所以, 接口中的.snapshotState()方法定义了检查点的快照保存逻辑,而. initializeState()方法不仅定义 了初始化逻辑,也定义了恢复逻辑。

这里需要注意,CheckpointedFunction 接口中的两个方法,分别传入了一个上下文(context) 作为参数。不同的是,.snapshotState()方法拿到的是快照的上下文 FunctionSnapshotContext, 它可以提供检查点的相关信息,不过无法获取状态句柄;而. initializeState()方法拿到的是

FunctionInitializationContext,这是函数类进行初始化时的上下文,是真正的“运行时上下文”

FunctionInitializationContext 中提供了“算子状态存储”(OperatorStateStore)和“按键分区状态存储(” KeyedStateStore),在这两个存储对象中可以非常方便地获取当前任务实例中的 Operator State 和 Keyed State。

ListStateDescriptor<String> descriptor = 
 new ListStateDescriptor<>( 
 "buffered-elements", 
 Types.of(String)); 
ListState<String> checkpointedState = 
context.getOperatorStateStore().getListState(descriptor); 

我们看到,算子状态的注册和使用跟 Keyed State 非常类似,也是需要先定义一个状态描述器(StateDescriptor),告诉 Flink 当前状态的名称和类型,然后从上下文提供的算子状态存储(OperatorStateStore)中获取对应的状态对象。如果想要从 KeyedStateStore 中获取 Keyed State也是一样的,前提是必须基于定义了 key 的 KeyedStream,这和富函数类中的方式并不矛盾。 通过这里的描述可以发现,CheckpointedFunction 是 Flink 中非常底层的接口,它为有状态的流处理提供了灵活且丰富的应用。

package com.atguigu.chapter09;

import com.atguigu.chapter05.ClickSource;
import com.atguigu.chapter05.Event;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.state.ListState;
import org.apache.flink.api.common.state.ListStateDescriptor;
import org.apache.flink.runtime.state.FunctionInitializationContext;
import org.apache.flink.runtime.state.FunctionSnapshotContext;
import org.apache.flink.streaming.api.checkpoint.CheckpointedFunction;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.sink.SinkFunction;

import java.time.Duration;
import java.util.ArrayList;
import java.util.List;

public class BufferingSinkExample {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        SingleOutputStreamOperator<Event> stream=env.addSource(new ClickSource())
                //1.WaterMark的生成器
                .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ZERO)//变成有序
                        .withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
                            @Override
                            //2.时间戳的提取器
                            public long extractTimestamp(Event event, long l) {
                                return event.timestamp;
                            }
                        })
                );

        //数据显示
        stream.print("data");

        //批量缓存输出
        stream.addSink(new BufferingSink(10));

        env.execute();
    }
    //自定义实现SinkFunction
    public static class BufferingSink implements SinkFunction<Event>, CheckpointedFunction {
        //定义当前类的属性,批量
        private final int threshold;

        public BufferingSink(int threshold) {
            this.threshold = threshold;
            this.bufferedElements=new ArrayList<>();
        }

        //本地变量
        private List<Event> bufferedElements;

        //定义一个算子状态
        private ListState<Event> checkPointedState;

        @Override
        public void invoke(Event value, Context context) throws Exception {
            bufferedElements.add(value);//缓存到列表
            //判断如果达到阈值,就批量写入
            if (bufferedElements.size()==threshold){
                //用打印到控制台模拟写入到外部系统
                for(Event element:bufferedElements){
                    System.out.println(element);
                }
                System.out.println("========输出完毕========");
                bufferedElements.clear();//清空列表
            }
        }

        // 保存状态快照到检查点时,调用这个方法
        @Override
        public void snapshotState(FunctionSnapshotContext functionSnapshotContext) throws Exception {
           //清空状态
            checkPointedState.clear();

            //对状态进行持久化,复制缓存的列表到列表状态
            for(Event element:bufferedElements){
                checkPointedState.add(element);
            }
        }

        // 初始化状态时调用这个方法,也会在恢复状态时调用
        @Override
        public void initializeState(FunctionInitializationContext Context) throws Exception {
            //定义算子状态
            ListStateDescriptor<Event> descriptor = new ListStateDescriptor<>("buffered-elements", Event.class);
            checkPointedState=Context.getOperatorStateStore().getListState(descriptor);

            //如果从故障恢复,需要将ListState中的元素复制到列表中
            if(Context.isRestored()){
                for(Event element:checkPointedState.get()){
                    bufferedElements.add(element);
                }
            }
        }
    }
}

9.4 广播状态(Broadcast State)

算子状态中有一类很特殊,就是广播状态(Broadcast State)。从概念和原理上讲,广播状态非常容易理解:状态广播出去,所有并行子任务的状态都是相同的;并行度调整时只要直接复制就可以了。然而在应用上,广播状态却与其他算子状态大不相同。本节我们就专门来讨论 一下广播状态的使用。

9.4.1 基本用法

让所有并行子任务都持有同一份状态,也就意味着一旦状态有变化,所有子任务上的实例 都要更新。什么时候会用到这样的广播状态呢?

一个最为普遍的应用,就是“动态配置”或者“动态规则”。我们在处理流数据时,有时 会基于一些配置(configuration)或者规则(rule)。简单的配置当然可以直接读取配置文件, 一次加载,永久有效;但数据流是连续不断的,如果这配置随着时间推移还会动态变化,那又该怎么办呢?

一个简单的想法是,定期扫描配置文件,发现改变就立即更新。但这样就需要另外启动一 个扫描进程,如果扫描周期太长,配置更新不及时就会导致结果错误;如果扫描周期太短,又 会耗费大量资源做无用功。解决的办法,还是流处理的“事件驱动”思路——我们可以将这动态的配置数据看作一条流,将这条流和本身要处理的数据流进行连接(connect),就可以实时 地更新配置进行计算了。

由于配置或者规则数据是全局有效的,我们需要把它广播给所有的并行子任务。而子任务需要把它作为一个算子状态保存起来,以保证故障恢复后处理结果是一致的。这时的状态,就 是一个典型的广播状态。我们知道,广播状态与其他算子状态的列表(list)结构不同,底层 是以键值对(key-value)形式描述的,所以其实就是一个映射状态(MapState)。

在代码上,可以直接调用 DataStream 的.broadcast()方法,传入一个“映射状态描述器” (MapStateDescriptor)说明状态的名称和类型,就可以得到一个“广播流”(BroadcastStream); 进而将要处理的数据流与这条广播流进行连接(connect),就会得到“广播连接流” (BroadcastConnectedStream)。注意广播状态只能用在广播连接流中。

关于广播连接流,这里可以复习一下:

MapStateDescriptor<String, Rule> ruleStateDescriptor = new 
MapStateDescriptor<>(...); 
BroadcastStream<Rule> ruleBroadcastStream = ruleStream 
 .broadcast(ruleStateDescriptor); 
DataStream<String> output = stream 
 .connect(ruleBroadcastStream) 
 .process( new BroadcastProcessFunction<>() {...} ); 

这里我们定义了一个“规则流”ruleStream,里面的数据表示了数据流 stream 处理的规则, 规则的数据类型定义为 Rule。于是需要先定义一个 MapStateDescriptor 来描述广播状态,然后 传入 ruleStream.broadcast()得到广播流,接着用 stream 和广播流进行连接。这里状态描述器中 的 key 类型为 String,就是为了区分不同的状态值而给定的 key 的名称。

对 于 广 播 连 接 流 调 用 .process() 方 法 , 可 以 传 入 “ 广 播 处 理 函 数 ”

KeyedBroadcastProcessFunction 或者 BroadcastProcessFunction 来进行处理计算。广播处理函数 里面有两个方法.processElement()和.processBroadcastElement(),源码中定义如下:

public abstract class BroadcastProcessFunction<IN1, IN2, OUT> extends 
BaseBroadcastProcessFunction { 
... 
 public abstract void processElement(IN1 value, ReadOnlyContext ctx, 
Collector<OUT> out) throws Exception; 
 public abstract void processBroadcastElement(IN2 value, Context ctx, 
Collector<OUT> out) throws Exception; 
... 
} 

这里的.processElement()方法,处理的是正常数据流,第一个参数 value 就是当前到来的流数据;而.processBroadcastElement()方法就相当于是用来处理广播流的,它的第一个参数 value就是广播流中的规则或者配置数据。两个方法第二个参数都是一个上下文 ctx,都可以通过调 用.getBroadcastState()方法获取到当前的广播状态;区别在于,.processElement()方法里的上下 文 是 “ 只读 ” 的 ( ReadOnly ), 因 此 获 取 到 的 广 播 状 态 也 只 能 读 取 不 能 更 改 ; 而.processBroadcastElement()方法里的 Context 则没有限制,可以根据当前广播流中的数据更新状态。

Rule rule = ctx.getBroadcastState( new MapStateDescriptor<>("rules", Types.String, 
Types.POJO(Rule.class))).get("my rule"); 

通过调用 ctx.getBroadcastState()方法,传入一个 MapStateDescriptor,就可以得到当前的叫 作“rules”的广播状态;调用它的.get()方法,就可以取出其中“my rule”对应的值进行计算 处理。

9.4.2 代码实例

接下来我们举一个广播状态的应用案例。考虑在电商应用中,往往需要判断用户先后发生 的行为的“组合模式”,比如“登录-下单”或者“登录-支付”,检测出这些连续的行为进行统 计,就可以了解平台的运用状况以及用户的行为习惯。

具体代码如下:

package com.atguigu.chapter09;

import org.apache.flink.api.common.state.BroadcastState;

import org.apache.flink.api.common.state.MapStateDescriptor;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.datastream.BroadcastStream;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import
        org.apache.flink.streaming.api.functions.co.KeyedBroadcastProcessFunction;
import org.apache.flink.util.Collector;

public class BehaviorPatternDetectExample {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env =
                StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        // 读取用户行为事件流
        DataStreamSource<Action> actionStream = env.fromElements(
                new Action("Alice", "login"),
                new Action("Alice", "pay"),
                new Action("Bob", "login"),
                new Action("Bob", "buy")
                );

        // 定义行为模式流,代表了要检测的标准
        DataStreamSource<Pattern> patternStream = env.fromElements(
                        new Pattern("login", "pay"),
                        new Pattern("login", "buy")
                );

        // 定义广播状态的描述器,创建广播流
        MapStateDescriptor<Void, Pattern> bcStateDescriptor = new MapStateDescriptor<>("patterns", Types.VOID, Types.POJO(Pattern.class));
        BroadcastStream<Pattern> bcPatterns = patternStream.broadcast(bcStateDescriptor);

        // 将事件流和广播流连接起来,进行处理
        DataStream<Tuple2<String, Pattern>> matches = actionStream
                .keyBy(data -> data.userId)
                .connect(bcPatterns)
                .process(new PatternEvaluator());

        matches.print();

        env.execute();
    }

    public static class PatternEvaluator extends KeyedBroadcastProcessFunction<String, Action, Pattern,
        Tuple2<String, Pattern>> {

        // 定义一个值状态,保存上一次用户行为
        ValueState<String> prevActionState;

        @Override
        public void open(Configuration conf) {
            prevActionState = getRuntimeContext().getState(
                    new ValueStateDescriptor<>("lastAction", Types.STRING));
        }

        @Override
        public void processBroadcastElement(
                Pattern pattern,
                Context ctx,
                Collector<Tuple2<String, Pattern>> out) throws Exception {
            BroadcastState<Void, Pattern> bcState = ctx.getBroadcastState(new MapStateDescriptor<>("patterns", Types.VOID, Types.POJO(Pattern.class)));

            // 将广播状态更新为当前的 pattern
            bcState.put(null, pattern);
        }

        @Override
        public void processElement(Action action, ReadOnlyContext ctx, Collector<Tuple2<String, Pattern>> out) throws Exception {
            Pattern pattern = ctx.getBroadcastState(new MapStateDescriptor<>("patterns", Types.VOID, Types.POJO(Pattern.class))).get(null);

            String prevAction = prevActionState.value();
            if (pattern != null && prevAction != null) {
                // 如果前后两次行为都符合模式定义,输出一组匹配
                if (pattern.action1.equals(prevAction) &&
                        pattern.action2.equals(action.action)) {
                    out.collect(new Tuple2<>(ctx.getCurrentKey(), pattern));
                }
            }
            // 更新状态
            prevActionState.update(action.action);
        }
    }

    // 定义用户行为事件 POJO 类
    public static class Action {
        public String userId;
        public String action;

        public Action() {
        }

        public Action(String userId, String action) {
            this.userId = userId;
            this.action = action;
        }

        @Override
        public String toString() {
            return "Action{" +
                    "userId=" + userId +
                    ", action='" + action + '\'' +
                    '}';
        }
    }

    // 定义行为模式 POJO 类,包含先后发生的两个行为
    public static class Pattern {
        public String action1;
        public String action2;

        public Pattern() {
        }

        public Pattern(String action1, String action2) {
            this.action1 = action1;
            this.action2 = action2;
        }

        @Override
        public String toString() {
            return "Pattern{" +
                    "action1='" + action1 + '\'' +
                    ", action2='" + action2 + '\'' +
                    '}';
        }
    }
}

9.5 状态持久化和状态后端

在 Flink 的状态管理机制中,很重要的一个功能就是对状态进行持久化(persistence)保 存,这样就可以在发生故障后进行重启恢复。Flink 对状态进行持久化的方式,就是将当前所 有分布式状态进行“快照”保存,写入一个“检查点”(checkpoint)或者保存点(savepoint) 保存到外部存储系统中。具体的存储介质,一般是分布式文件系统(distributed file system)。

9.5.1 检查点(Checkpoint)

有状态流应用中的检查点(checkpoint),其实就是所有任务的状态在某个时间点的一个快照(一份拷贝)。简单来讲,就是一次“存盘”,让我们之前处理数据的进度不要丢掉。在一个 流应用程序运行时,Flink 会定期保存检查点,在检查点中会记录每个算子的 id 和状态;如果 发生故障,Flink 就会用最近一次成功保存的检查点来恢复应用的状态,重新启动处理流程, 就如同“读档”一样。

如果保存检查点之后又处理了一些数据,然后发生了故障,那么重启恢复状态之后这些数据带来的状态改变会丢失。为了让最终处理结果正确,我们还需要让源(Source)算子重新读取这些数据,再次处理一遍。

这就需要流的数据源具有“数据重放”的能力,一个典型的例子 就是 Kafka,我们可以通过保存消费数据的偏移量、故障重启后重新提交来实现数据的重放。 这是对“至少一次”(at least once)状态一致性的保证,如果希望实现“精确一次”(exactly once) 的一致性,还需要数据写入外部系统时的相关保证。

默认情况下,检查点是被禁用的,需要在代码中手动开启。直接调用执行环境 的.enableCheckpointing()方法就可以开启检查点。

StreamExecutionEnvironment env = StreamExecutionEnvironment.getEnvironment(); 
env.enableCheckpointing(1000); 

这里传入的参数是检查点的间隔时间,单位为毫秒。

除了检查点之外,Flink 还提供了“保存点”(savepoint)的功能。保存点在原理和形式上 跟检查点完全一样,也是状态持久化保存的一个快照;区别在于,保存点是自定义的镜像保存,所以不会由 Flink 自动创建,而需要用户手动触发。这在有计划地停止、重启应用时非常有用。

9.5.2 状态后端(State Backends)

检查点的保存离不开 JobManager 和 TaskManager,以及外部存储系统的协调。在应用进 行检查点保存时,首先会由 JobManager 向所有 TaskManager 发出触发检查点的命令;TaskManger 收到之后,将当前任务的所有状态进行快照保存,持久化到远程的存储介质中; 完成之后向 JobManager 返回确认信息。这个过程是分布式的,当 JobManger 收到所有TaskManager 的返回信息后,就会确认当前检查点成功保存。而这一切工作的协调,就需要一个“专职人员”来完成。

 在 Flink 中,状态的存储、访问以及维护,都是由一个可插拔的组件决定的,这个组件就 叫作状态后端(state backend)。

状态后端主要负责两件事:一是本地的状态管理,二是将检查点(checkpoint)写入远程的持久化存储

1. 状态后端的分类

状态后端是一个“开箱即用”的组件,可以在不改变应用程序逻辑的情况下独立配置。

Flink 中提供了两类不同的状态后端,一种是“哈希表状态后端”(HashMapStateBackend),另 一种是“内嵌 RocksDB 状态后端”(EmbeddedRocksDBStateBackend)。如果没有特别配置, 系统默认的状态后端是 HashMapStateBackend

(1)哈希表状态后端(HashMapStateBackend)

这种方式就是我们之前所说的,把状态存放在内存里。具体实现上,哈希表状态后端在内部会直接把状态当作对象(objects),保存在 Taskmanager 的 JVM 堆(heap)上。普通的状态, 以及窗口中收集的数据和触发器(triggers),都会以键值对(key-value)的形式存储起来,所以底层是一个哈希表(HashMap),这种状态后端也因此得名。

对于检查点的保存,一般是放在持久化的分布式文件系统(file system)中,也可以通过配置“检查点存储”(CheckpointStorage)来另外指定。HashMapStateBackend 是将本地状态全部放入内存的,这样可以获得最快的读写速度,使计算性能达到最佳;代价则是内存的占用。它适用于具有大状态、长窗口、大键值状态的作业, 对所有高可用性设置也是有效的。

(2)内嵌 RocksDB 状态后端(EmbeddedRocksDBStateBackend)

RocksDB 是一种内嵌的 key-value 存储介质,可以把数据持久化到本地硬盘。配置EmbeddedRocksDBStateBackend 后,会将处理中的数据全部放入 RocksDB 数据库中,RocksDB默认存储在 TaskManager 的本地数据目录里。与 HashMapStateBackend 直接在堆内存中存储对象不同,这种方式下状态主要是放在RocksDB 中的。数据被存储为序列化的字节数组(Byte Arrays),读写操作需要序列化/反序列 化,因此状态的访问性能要差一些。另外,因为做了序列化,key 的比较也会按照字节进行, 而不是直接调用.hashCode()和.equals()方法。

对于检查点,同样会写入到远程的持久化文件系统中。 EmbeddedRocksDBStateBackend 始终执行的是异步快照,也就是不会因为保存检查点而阻 塞数据的处理;而且它还提供了增量式保存检查点的机制,这在很多情况下可以大大提升保存 效率。

由于它会把状态数据落盘,而且支持增量化的检查点,所以在状态非常大、窗口非常长、 键/值状态很大的应用场景中是一个好选择,同样对所有高可用性设置有效。

2. 如何选择正确的状态后端

HashMap 和 RocksDB 两种状态后端最大的区别,就在于本地状态存放在哪里:前者是内存,后者是 RocksDB。在实际应用中,选择那种状态后端,主要是需要根据业务需求在处理 性能和应用的扩展性上做一个选择。 HashMapStateBackend 是内存计算,读写速度非常快;但是,状态的大小会受到集群可用 内存的限制,如果应用的状态随着时间不停地增长,就会耗尽内存资源。

而 RocksDB 是硬盘存储,所以可以根据可用的磁盘空间进行扩展,而且是唯一支持增量 检查点的状态后端,所以它非常适合于超级海量状态的存储。不过由于每个状态的读写都需要 做序列化/反序列化,而且可能需要直接从磁盘读取数据,这就会导致性能的降低,平均读写 性能要比 HashMapStateBackend 慢一个数量级。

我们可以发现,实际应用就是权衡利弊后的取舍。最理想的当然是处理速度快且内存不受 限制可以处理海量状态,那就需要非常大的内存资源了,这会导致成本超出项目预算。比起花 更多的钱,稍慢的处理速度或者稍小的处理规模,老板可能更容易接受一点。

3. 状态后端的配置

在不做配置的时候,应用程序使用的默认状态后端是由集群配置文件 flink-conf.yaml 中指定的,配置的键名称为 state.backend。这个默认配置对集群上运行的所有作业都有效,我们可 以通过更改配置值来改变默认的状态后端。另外,我们还可以在代码中为当前作业单独配置状态后端,这个配置会覆盖掉集群配置文件的默认值。

(1)配置默认的状态后端

在 flink-conf.yaml 中,可以使用 state.backend 来配置默认状态后端。配置项的可能值为 hashmap,这样配置的就是 HashMapStateBackend;也可以是 rocksdb,这样配置的就是 EmbeddedRocksDBStateBackend。另外,也可以是一个实现了状态后端工厂StateBackendFactory 的类的完全限定类名。

下面是一个配置 HashMapStateBackend 的例子:

# 默认状态后端 
state.backend: hashmap 
# 存放检查点的文件路径 
state.checkpoints.dir: hdfs://namenode:40010/flink/checkpoints 

这里的 state.checkpoints.dir 配置项,定义了状态后端将检查点和元数据写入的目录。

(2)为每个作业(Per-job)单独配置状态后端每个作业独立的状态后端,可以在代码中,基于作业的执行环境直接设置。代码如下:

StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment(); 
env.setStateBackend(new HashMapStateBackend());

上面代码设置的是 HashMapStateBackend,如果想要设置 EmbeddedRocksDBStateBackend, 可以用下面的配置方式:

StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment(); 
env.setStateBackend(new EmbeddedRocksDBStateBackend()); 

需要注意,如果想在 IDE 中使用 EmbeddedRocksDBStateBackend,需要为 Flink 项目添加 依赖:

<dependency> 
 <groupId>org.apache.flink</groupId> 
<artifactId>flink-statebackend-rocksdb_${scala.binary.version}</artifactId> 
 <version>1.13.0</version> 
</dependency> 

而由于 Flink 发行版中默认就包含了 RocksDB,所以只要我们的代码中没有使用 RocksDB的相关内容,就不需要引入这个依赖。即使我们在 flink-conf.yaml 配置文件中设定了state.backend 为 rocksdb,也可以直接正常运行,并且使用 RocksDB 作为状态后端。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值