最新 Flink 1.13 状态编程与容错机制(状态、状态后端、检查点、精确一次、端到端的状态一致性)快速入门、详细教程

Flink 状态编程与容错机制


下一章: Flink 1.13 的 Flink CEP 复杂事件处理

一、Flink 中的状态

1. 什么是状态

在流处理中,数据是连续不断到来和处理的。每个任务进行计算处理时,可以基于当前数据直接转换得到输出结果;也可以依赖一些其他数据。这些由一个任务维护,并且用来计算输出结果的所有数据,就叫作这个任务的状态。

算子任务可以分为无状态和有状态两种情况。

上面是有状态算子的一般处理流程,具体步骤如下:

  • 算子任务接收到上游发来的数据;
  • 获取当前状态;
  • 根据业务逻辑进行计算,更新状态;
  • 得到计算结果,输出发送到下游任务。

2. 状态的管理

  • 状态的访问权限。
    基于 KeyedStream 的转换,数据会按照 key 的哈希值进行分区,聚合处理的结果也应该是只对当前 key 有效。

    然而同一个分区(也就是 slot)上执行的任务实例,可能会包含多个 key 的数据,它们同时访问和更改本地变量,就会导致计算结果错误。所以这时状态并不是单纯的本地变量。

  • 容错性,也就是故障后的恢复。
    状态只保存在内存中显然是不够稳定的,我们需要将它持久化保存,做一个备份;在发生故障后可以从这个备份中恢复状态。

  • 横向扩展性,状态的重组调整。
    处理的数据量增大时,我们应该相应地对计算资源扩容,调大并行度。

3. 状态的分类

  1. 托管状态(Managed State)和原始状态(Raw State)
    Flink的状态有两种:托管状态(Managed State)和原始状态(Raw State)。
  • 托管状态就是 由 Flink 统一管理的,状态的存储访问、故障恢复和重组等一系列问题都由 Flink 实现,我们 只要调接口就可以;托管状态分为按键分区状态和 算子状态。
  • 原始状态则是自定义的,相当于就是开辟了一块内存,需要我们自己管理,实现状态的序列化和故障恢复。只有在遇到托管状态无法实现的特殊需求时,我们才会考虑使用原始状态;一般情况下不推荐使用。
  1. 按键分区状态(Keyed State)和 算子状态(Operator State)
  • 按键分区状态,是任务按照键(key)来访问和维护的状态。它的特点非常鲜明,就是以 key 为作用范围进行隔离。在底层,Keyed State 类似于一个分布式的映射(map)数据结构,所有的状态会根据 key 保存成键值对(key-value)的形式。
  • 算子状态作用范围限定为当前的算子任务实例,也就是只对当前并行子任务实例有效,算子的多个并行实例各自维护一个状态。这就意味着对于一个并行子任务,占据了一个“分区”,它所处理的所有数据都会访问到相同的状态。

二、按键分区状态(Keyed State)

1. 概念和特点

在底层,按键分区状态(键控状态)类似于一个分布式的映射(map)数据结构,所有的状态会根据 key 保存成键值对(key-value)的形式。这样当一条数据到来时,任务就会自动将状态的访问范围限定为当前数据的 key,从 map 存储中读取出对应的状态值。所以具有相同 key 的所有数据都会到访问相同的状态,而不同 key 的状态之间是彼此隔离的。

在应用的并行度改变时,状态也需要随之进行重组。不同 key 对应的 Keyed State 可以进一步组成所谓的键组(key groups),每一组都对应着一个并行子任务。键组是 Flink 重新分配 Keyed State 的单元,键组的数量就等于定义的最大并行度。当算子并行度发生改变时,Keyed State 就会按照当前的并行度重新平均分配,保证运行时各个子任务的负载相同。

使用 Keyed State 必须基于 KeyedStream。没有进行 keyBy 分区的 DataStream,即使转换算子实现了对应的富函数类,也不能通过运行时上下文访问Keyed State。

2. 按键分区状态支持的结构类型

  1. 值状态(ValueState)
    ⚫ T value():获取当前状态的值;
    ⚫ update(T value):对状态进行更新,传入的参数 value 就是要覆写的状态值。

  2. 列表状态(ListState)
    ⚫ Iterable get():获取当前的列表状态,返回的是一个可迭代类型 Iterable;
    ⚫ update(List values):传入一个列表 values,直接对状态进行覆盖;
    ⚫ add(T value):在状态列表中添加一个元素 value;
    ⚫ addAll(List values):向列表中添加多个元素,以列表 values 形式传入。

  3. 映射状态(MapState)
    ⚫ 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 值。
    ⚫ Iterable entries():获取映射状态中所有的键值对;
    ⚫ Iterable keys():获取映射状态中所有的键(key),返回一个可迭代 Iterable 类型;
    ⚫ Iterable values():获取映射状态中所有的值(value),返回一个可迭代 Iterable 类型;
    ⚫ boolean isEmpty():判断映射是否为空,返回一个 boolean 值。

  4. 归约状态(ReducingState)
    类似于值状态(Value),不过需要对添加进来的所有数据进行归约,将归约聚合之后的值作为状态保存下来。调用 .add() 方法时,不是在状态列表里添加元素,而是直接把新数据和之前的状态进行归约,并用得到的结果更新状态。
    归约逻辑的定义,是在归约状态描述器(ReducingStateDescriptor)中,通过传入一个归约函数(ReduceFunction)来实现的。这里的归约函数,就是reduce 聚合算子时的 ReduceFunction,所以状态类型跟输入的数据类型是一样的。

public ReducingStateDescriptor( String name, ReduceFunction reduceFunction, Class typeClass) {...}
  1. 聚合状态(AggregatingState)
    与归约状态非常类似,聚合状态也是一个值,用来保存添加进来的所有数据的聚合结果。其聚合逻辑是由在描述器中传入一个更加一般化的聚合函数(AggregateFunction)来定义的;里面通过一个累加器(Accumulator)来表示状态,聚合的状态类型可以跟添加进来的数据类型完全不同,使用更加灵活。调用.add()方法添加元素时,会直接使用指定的 AggregateFunction 进行聚合并更新状态。

3. 按键分区状态的代码示例

  // 统计每个用户的点击频次,到达5次就输出统计结果 
  stream.keyBy(data -> data.user) 
    .flatMap(new AvgTsResult()) 
    .print();
  
  public static class AvgTsResult extends RichFlatMapFunction<Event, String>{
    AggregatingState<Event, Long> avgTsAggState; //定义聚合状态,用来计算平均时间戳
    ValueState<Long> countState; // 定义一个值状态,用来保存当前用户访问频次
    @Override
    public void open(Configuration parameters) throws Exception {
      avgTsAggState = getRuntimeContext().getAggregatingState(
         new AggregatingStateDescriptor<Event, Tuple2<Long, Long>, Long>("avg-ts",
          new AggregateFunction<Event, Tuple2<Long, Long>, Long>() {
            @Override
            public Tuple2<Long, Long> createAccumulator(){return Tuple2.of(0L, 0L);}
            @Override
            public Tuple2<Long,Long> add(Event value,Tuple2<Long,Long> accumulator){
               return Tuple2.of(accumulator.f0+value.timestamp, accumulator.f1+1);
            }
            @Override
            public Long getResult(Tuple2<Long, Long> accumulator) {
                return accumulator.f0 / accumulator.f1;
            }
            @Override
            public Tuple2<Long,Long> merge(Tuple2<Long,Long> a,Tuple2<Long,Long> b){
   				return null;
  			   }
           },
           Types.TUPLE(Types.LONG, Types.LONG)
          ));
          countState = getRuntimeContext().getState(
                  new ValueStateDescriptor<Long>("count", Long.class));
      }
  
      @Override
      public void flatMap(Event value, Collector<String> out) throws Exception {
          Long count = countState.value();
          if (count == null){ count = 1L; } else { count ++; }
          countState.update(count);
          avgTsAggState.add(value);
          if (count == 5){ //达到5次就输出结果,并清空状态
              out.collect(value.user + " 平 均 时 间 戳 : " + new Timestamp(avgTsAggState.get()));
              countState.clear();
          }
      }
  }

4. 状态生存时间(TTL)

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

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

StateTtlConfig ttlConfig = StateTtlConfig 
    .newBuilder(Time.seconds(10))  //状态生存时间
    .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite) //[OnReadAndWrite] 
	//状态的可见性 默认NeverReturnExpired过期不可见,
    //           可选ReturnExpireDefNotCleanedUp表示如果过期后状态没来得急删除还存在,就返回值
    .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired) 
    .build(); 
ValueStateDescriptor<String> stateDescriptor = new ValueStateDescriptor<>("my state", String.class); 
stateDescriptor.enableTimeToLive(ttlConfig);

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

三、算子状态(Operator State)

1. 概念和特点

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

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

当算子的并行度发生变化时,算子状态也支持在并行的算子任务实例之间做重组分配。

2. 算子状态支持的结构类型

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

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

    当算子并行度进行缩放调整时,算子的列表状态中的所有元素项会被统一收集起来,相当于把多个分区的列表合并成了一个“大列表”,然后再均匀地分配给所有并行任务。这种“均匀分配”的具体方法就是“轮询”(round-robin),也叫作“平均分割重组”。
    算子状态中不会存在“键组”(key group)这样的结构,所以为了方便重组分配,就把它直接定义成了“列表”(list)。这也就解释了,为什么算子状态中没有最简单的值状态。

  2. 联合列表状态(UnionListState)
    与ListState类似,联合列表状态也会将状态表示为一个列表。它与常规列表状态的区别在于,算子并行度进行缩放调整时对于状态的分配方式不同。

    UnionListState 的重点就在于“联合”(union)。在并行度调整时,常规列表状态是轮询分配状态项,而联合列表状态的算子则会直接广播状态的完整列表。并行度缩放之后的并行子任务就获取到了联合后完整的“大列表”,可以自行选择要使用的状态项和要丢弃的状态项。这种分配也叫作“联合重组”

    如果列表中状态项数量太多,为资源和效率考虑一般不建议使用联合重组的方式。

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

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

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

3. 算子状态的代码示例

  • 列表状态的写出外部系统故障恢复示例
    KeyedState只要将状态也按照 key 的哈希值计算出对应的分区,进行重组分配就可以了。恢复状态后继续处理数据,就总能按照 key 找到对应之前的状态,就保证了结果的一致性。

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

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

    示例:

    public static class BufferingSink implements SinkFunction<Event>, CheckpointedFunction {
    	private final int threshold;
      //transient:将不需要序列化的属性前添加关键字transient,序列化对象的时候,这个属性就不会被序列化。
    	private transient ListState<Event> checkpointedState; 
    	private List<Event> bufferedElements; 
    	public BufferingSink(int threshold) {             
    		this.threshold = threshold;             
    		this.bufferedElements = new ArrayList<>();         
    	} 
    	@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 context) 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",Types.POJO(Event.class));
    		checkpointedState = context.getOperatorStateStore().getListState(descriptor);  
    		// 如果是从故障中恢复,就将ListState中的所有元素添加到局部变量中 
    		if (context.isRestored()) {
    			for (Event element : checkpointedState.get()) {
    				bufferedElements.add(element); 
    			} 
    		} 
    	}
    }
    
  • 广播状态的动态配置示例
    常规获取动态配置或动态规则,是定期扫描配置文件,发现改变就立即更新。但这样就需要另外启动一个扫描进程,如果扫描周期太长,配置更新不及时就会导致结果错误;如果扫描周期太短,又会耗费大量资源做无用功。

    我们可以将这动态的配置数据看作一条流,将这条流和本身要处理的数据流进行连接(connect),就可以实时地更新配置进行计算了。 由于配置或者规则数据是全局有效的,我们需要把它广播给所有的并行子任务。而子任务需要把它作为一个算子状态保存起来,以保证故障恢复后处理结果是一致的。

    广播状态与其他算子状态的列表(list)结构不同,底层是以键值对(key-value)形式描述的,所以其实就是一个映射状态(MapState)。

    注意广播状态只能用在广播连接流中。

    // 读取用户行为事件流 
    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(         //KeyedBroadcastProcessFunction的泛型依次是KS,  IN1,  IN2,  OUT
    		new 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); // 更新状态   
    			} 
    		}
    	); 
    

四、状态持久化和状态后端

1. 状态持久化

Flink 对状态进行持久化的方式,就是将当前所有分布式状态进行“快照”保存,写入一个“检查点”(checkpoint)或者保存点(savepoint)保存到外部存储系统中。具体的存储介质,一般是分布式文件系统。

  • 检查点(Checkpoint)
    所有任务的状态在某个时间点的一个快照(一份拷贝)。默认情况下,检查点是被禁用的,需要在代码中手动开启env.enableCheckpointing(1000);

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

2. 状态后端

2.1 概念与特点

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

在 Flink 中,状态的存储、访问以及维护,都是由一个可插拔的组件决定的,这个组件就叫作状态后端(state backend)。状态后端主要负责两件事:一是本地的状态管理,二是将检查点(checkpoint)写入远程的持久化存储。

2.2 状态后端的分类
  • (1) 哈希表状态后端(HashMapStateBackend)
    哈希表状态后端在内部会直接把状态当作对象,保存在 Taskmanager 的 JVM 堆上。普通的状态,以及窗口中收集的数据和触发器(triggers),都会以键值对的形式存储起来,底层是一个哈希表,这种状态后端也因此得名。

    对于检查点的保存,一般是放在持久化的分布式文件系统(file system)中,也可以通过配置CheckpointStorage来另外指定。

    HashMapStateBackend 是将本地状态全部放入内存的,这样可以获得最快的读写速度,使计算性能达到最佳;代价则是内存的占用。它适用于具有大状态、长窗口、大键值状态的作业,对所有高可用性设置也是有效的。

  • (2) 内嵌 RocksDB 状态后端(EmbeddedRocksDBStateBackend)
    RocksDB是一种内嵌的 key-value 存储介质[数据库],RocksDB 默认把数据持久化到 TaskManager 的本地数据目录里。

    数据被存储为序列化的字节数组,读写操作需要序列化/反序列化,因此状态的访问性能要差一些。另外,因为做了序列化,key 的比较也会按照字节进行,而不是直接调用.hashCode()和.equals()方法。

    对于检查点,同样会写入到远程的持久化文件系统中。

    其始终执行的是异步快照,不阻塞数据的处理;还提供了增量式保存检查点的机制,提升保存效率。由于它会把状态数据落盘,而且支持增量化的检查点,所以在状态非常大、窗口非常长、键/值状态很大的应用场景中是一个好选择,同样对所有高可用性设置有效。

2.3 如何选择正确的状态后端

HashMapStateBackend是内存计算,读写速度非常快;随着时间不停地增长,会耗尽内存资源。

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

实际应用就是权衡利弊后的取舍。

2.4 状态后端的配置

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

# 默认状态后端 
state.backend: hashmap 
# 存放检查点的文件路径 状态后端将检查点和元数据写入的目录
state.checkpoints.dir: hdfs://namenode:40010/flink/checkpoints 

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

env.setStateBackend(new HashMapStateBackend());
env.setStateBackend(new EmbeddedRocksDBStateBackend())
2.5 不同状态后端的状态保存位置
本地状态Checkpoint
内存TaskManager的内存JobManager内存
文件TaskManager的内存HDFS
RocksDBRocksDBHDFS

五、检查点

1. 检查点的保存

将之前某个时间点所有的状态保存下来,这份“存档”就是所谓的“检查点”。

检查点是 Flink 容错机制的核心。这里所谓的“检查”,其实是针对故障恢复的结果而言的:故障恢复之后继续处理的结果,应该与发生故障前完全一致,我们需要“检查”结果的正确性。所以,有时又会把 checkpoint 叫作“一致性检查点”。

  • 周期性的触发保存
    “随时存档”确实恢复起来方便,可是需要我们不停地做存档操作,耗资源且数据处理速度受影响。

    在 Flink 中,检查点的保存是周期性触发的,间隔时间可以进行设置。

  • 保存的时间点
    当所有任务都恰好处理完一个相同的输入数据的时候,将它们的状态保存下来。首先,这样避免了除状态之外其他额外信息的存储,提高了检查点保存的效率。其次,一个数据要么就是被所有任务完整地处理完,状态得到了保存;要么就是没处理完,状态全部没保存:这就相当于构建了一个“事务”(transaction)。

    如果出现故障,我们恢复到之前保存的状态,故障时正在处理的所有数据都需要重新处理;所以我们只需要让源(source)任务向数据源重新提交偏移量、请求重放数据就可以了。

  • 保存的具体流程
    当我们需要保存检查点(checkpoint)时,就是在所有任务处理完同一条数据后,对状态做个快照保存下来。

    例如下图中,已经处理了 3 条数据:“hello”“world”“hello”,所以我们会看到 Source 算子的偏移量为 3;后面的 Sum 算子处理完第三条数据“hello”之后,此时已经有 2 个“hello”和 1 个“world”,所以对应的状态为“hello”-> 2,“world”-> 1。此时所有任务都已经处理完了前三个数据,所以我们可以把当前的状态保存成一个检查点,写入外部存储中。

    外部存储位置由状态后端的配置项“检查点存储”( CheckpointStorage )来决定的,有作业管理器的堆内存和文件系统两种选择。一般是写入持久化的分布式文件系统。

2. 从检查点恢复状态

当发生故障时,就需要找到最近一次成功保存的检查点来恢复状态。
具体步骤如下:

  1. 重启应用
    遇到故障之后,第一步当然就是重启。我们将应用重新启动后,所有任务的状态会清空。
  2. 读取检查点,重置状态
    找到最近一次保存的检查点,从中读出每个算子任务状态的快照,分别填充到对应的状态中。
  3. 重放数据
    为了不丢数据,我们应该从保存检查点后开始重新读取数据,通过Source任务向外部数据源重新提交偏移量(offset)来实现。整个系统的状态已经完全回退到了检查点保存完成的那一时刻。
  4. 继续处理数据
    接下来,我们就可以正常处理数据了。

在分布式系统中,这叫作实现了“精确一次”(exactly-once)的状态一致性保证。

这里我们也可以发现,想要正确地从检查点中读取并恢复状态,必须知道每个算子任务状态的类型和它们的先后顺序(拓扑结构);因此为了可以从之前的检查点中恢复状态,我们在改动程序、修复 bug 时要保证状态的拓扑顺序和类型不变。状态的拓扑结构在 JobManager 上可以由 JobGraph 分析得到,而检查点保存的定期触发也是由 JobManager 控制的;所以故障恢复的过程需要 JobManager 的参与。

3. 检查点算法

在 Flink 中,采用了基于Chandy-Lamport 算法的分布式快照。

  • 检查点分界线(Barrier)
    借鉴水位线(watermark)的设计,在数据流中插入一个特殊的数据结构,专门用来表示触发检查点保存的时间点。收到保存检查点的指令后,Source 任务可以在当前数据流中插入这个结构;之后的所有任务只要遇到它就开始对状态做持久化快照保存。由于数据流是保持顺序依次处理的,因此遇到这个标识就代表之前的数据都处理完了,可以保存一个检查点。

    这种特殊的数据形式,把一条流上的数据按照不同的检查点分隔开,所以就叫作检查点的“分界线”(Checkpoint Barrier)。

    在 JobManager 中有一个“检查点协调器”(checkpoint coordinator),专门用来协调处理检查点的相关工作。检查点协调器会定期向 TaskManager 发出指令,要求保存检查点(带着检查点 ID);

    TaskManager 会让所有的 Source 任务把自己的偏移量(算子状态)保存起来,并将带有检查点 ID 的分界线(barrier)插入到当前的数据流中,然后像正常的数据一样像下游传递;之后 Source 任务就可以继续读入新的数据了。

    每个算子任务只要处理到这个 barrier,就把当前的状态进行快照。

  • 分布式快照算法(asynchronous barrier snapshotting)
    Flink 使用了Chandy-Lamport 算法的一种变体,被称为“异步分界线快照”算法。

    • 算法的核心就是两个原则:

      1. 当上游任务向多个并行下游任务发送 barrier 时,需要广播出去;
      2. 而当多个上游任务向同一个下游任务传递 barrier 时,需要在下游任务执行“分界线对齐”(barrier alignment)操作,需要等到所有并行分区的 barrier 都到齐,才可以开始状态的保存。
    • 算法具体步骤:

      1. JobManager周期性地向每个TaskManager发送一条带有新检查点 ID 的消息,触发检查点的保存;收到指令后,TaskManger会在所有Source任务中插入一个分界线barrier,并将偏移量保存到远程的持久化存储中。
      2. 状态快照保存完成后通知Source,Source任务就会向JobManager确认检查点完成,分界线barrier向下游传递
      3. 向下游多个并行子任务广播分界线,执行分界线对齐 (缓存暂不处理,等到状态保存之后再做处理)
      4. 分界线对齐后,保存状态到持久化存储;将barrier向下游传递,并通知JobManager 保存完毕。
      5. 先处理缓存数据,然后正常继续处理
      6. 当JobManager收到所有任务成功保存状态的信息,就可以确认当前检查点成功保存。之后遇到故障就可以从这里恢复了。

4. 检查点配置

// 启用检查点,间隔时间1秒,  不传参默认500ms 
env.enableCheckpointing(1000); 
CheckpointConfig checkpointConfig = env.getCheckpointConfig(); 

//检查点存储(Checkpoint Storage)
// 配置存储检查点到JobManager堆内存
checkpointConfig.setCheckpointStorage(new 			
				JobManagerCheckpointStorage()); 
// 配置存储检查点到文件系统 
checkpointConfig.setCheckpointStorage(new FileSystemCheckpointStorage(
				"hdfs://namenode:40010/flink/checkpoints"));
// 设置检查点存储,可以直接传入一个String,指定文件系统的路径 
checkpointConfig.setCheckpointStorage("hdfs://my/checkpoint/dir")

// 设置精确一次模式 [至少一次at-least-once]
checkpointConfig.setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE); 

// 最小间隔时间500毫秒 
checkpointConfig.setMinPauseBetweenCheckpoints(500); 
// 超时时间1分钟  
checkpointConfig.setCheckpointTimeout(60000);
// 同时只能有一个检查点 
checkpointConfig.setMaxConcurrentCheckpoints(1);

//检查点发生异常是否任务直接失败退出failOnCheckpointingErrors, 默认为 true

// 开启检查点的外部持久化保存,作业取消后依然保留
	//  DELETE_ON_CANCELLATION作业取消自动删除,作业失败退出则保留。
checkpointConfig.enableExternalizedCheckpoints( 
    ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);

// 启用不对齐的检查点保存方式 
checkpointConfig.enableUnalignedCheckpoints(); 

生产经验:

  1. 间隔、语义: 1min~10min,3min,语义默认精准一次
    因为一些异常原因可能导致某些barrier无法向下游传递,造成job失败,对于一些时效性要求高、精准性要求不是特别严格的指标,可以设置为至少一次。

  2. 超时 : 参考间隔, 0.5~2倍之间, 建议0.5倍

  3. 最小等待间隔:上一次ck结束到下一次ck开始 之间的时间间隔,设置间隔的0.5倍

  4. 设置保存ck:Retain

  5. 失败次数:5

  6. Task重启策略(Failover):
    固定延迟重启策略: 重试几次、每次间隔多久
    失败率重启策略: 重试次数、重试区间、重试间隔

5. 保存点(Savepoint)

它的原理和算法与检查点完全相同,只是多了一些额外的元数据。事实上,保存点就是通过检查点的机制来创建流式作业状态的一致性镜像(consistent image)的。

保存点中的状态快照,是以算子 ID 和状态名称组织起来的,相当于一个键值对。从保存点启动应用程序时,Flink 会将保存点的状态数据重新分配给相应的算子任务。

检查点是由 Flink 自动管理的,定期创建,发生故障之后自动读取进行恢复,这是一个“自动存盘”的功能;而保存点不会自动创建,必须由用户明确地手动触发保存操作,所以就是“手动存盘”。

检查点主要用来做故障恢复,是容错机制的核心;保存点则更加灵活,可以用来做有计划的手动备份和恢复。

  • 保存点的用途
    保存点可以当作一个强大的运维工具来使用。我们可以在需要的时候创建一个保存点,然后停止应用,做一些处理调整之后再从保存点重启。

    1. 版本管理和归档存储 对重要的节点进行手动备份,设置为某一版本,归档(archive)存储应用程序的状态。
    2. 更新 Flink 版本
    3. 更新应用程序 我们可以及时修复应用程序中的逻辑bug和更改程序,前提状态的拓扑结构和数据类型不变。保存点中状态都是以算子 ID-状态名称这样的key-value组织起来的,强烈建议在程序中为每一个算子手动指定ID,方便后续的维护(SingleOutputStreamOperator的.uid()方法)
    4. 调整并行度 资源不足或已经有了大量剩余
    5. 暂停应用程序 单纯地希望把应用暂停、释放一些资源来处理更重要的应用程序
  • 使用保存点

    1. 创建保存点
    bin/flink savepoint :jobId [:targetDirectory]  
    

    对于保存点的默认路径,在flink-conf.yaml文件中如下配置

    state.savepoints.dir: hdfs:///flink/savepoints 
    

    当然对于单独的作业,我们也可以在程序代码中通过执行环境来设置:

    env.setDefaultSavepointDir("hdfs:///flink/savepoints"); 
    

    除了对运行的作业创建保存点,我们也可以在停掉一个作业时直接创建保存点:

    bin/flink stop --savepointPath [:targetDirectory] :jobId 
    
    1. 从保存点重启应用
    bin/flink run -s :savepointPath [:runArgs] 
    

六、状态一致性

1. Flink 内部状态的一致性

一致性其实就是结果的正确性。对于分布式系统而言,强调的是从不同节点读取时总能得到相同的值;而对于事务而言,是要求提交更新操作后,能够读取到新的数据。

状态一致性有三种级别:最多一次(AT-MOST-ONCE)、至少一次(AT-LEAST-ONCE) 、精确一次(EXACTLY-ONCE) 。

Flink 内部状态的一致性: Flink通过检查点的保存来保证状态恢复后结果的正确,即状态的一致性,不重复也不遗漏。检查点机制而且可以做到精确一次。

2. 端到端(end-to-end)的状态一致性

完整的流处理应用,应该包括了数据源、流处理器和外部存储系统三个部分。三部分的一致性,就叫作“端到端的状态一致性”,它取决于三个组件中最弱的那一环。

3. 端到端精确一次(end-to-end exactly-once)

对于 Flink 内部来说,检查点机制可以保证故障恢复后数据不丢(在能够重放的前提下),并且只处理一次,所以已经可以做到 exactly-once 的一致性语义了。 端到端一致性的关键点,就在于输入的数据源端和输出的外部存储端。

  • 输入端保证:数据源可重放数据,或者说可重置读取数据偏移量,加上 Flink 的 Source 算子将偏移量作为状态保存进检查点,就可以保证数据不丢。这是达到 at-least-once 一致性语义的基本要求,当然也是实现端到端 exactly-once 的基本要求。
  • 输出端保证:
    • 幂等(idempotent)写入
      “幂等”操作,就是说一个操作可以重复执行很多次,但只导致一次结果更改。如ex 的求导下操作,Redis 中键值存储,HashMap 的插入操作。

      幂等写入,遇到故障进行恢复时,有可能会出现短暂的不一致。短时间内,结果会突然“跳回”到之前的某个值,然后“重播”一段之前的数据。重放逐渐超过发生故障的点的时候,最终的结果还是一致的。

    • 事务(transactional)写入
      用一个事务来进行数据向外部系统的写入,这个事务是与检查点绑定在一起的。当 Sink 任务遇到 barrier 时,开始保存状态的同时就开启一个事务,接下来所有数据的写入都在这个事务中;待到当前检查点保存完毕时,将事务提交,所有写入的数据就真正可用了。如果中间过程出现故障,状态会回退到上一个检查点,当前事务也会回滚,写入到外部的数据就被撤销了。
      具体来说,又有两种实现方式:预写日志(WAL)和两阶段提交(2PC)

      • (1)预写日志(write-ahead-log,WAL)
        对于不支持事务的存储系统,预写日志实现事务,模板类GenericWriteAheadSink。
        步骤:①先把结果数据作为日志(log)状态保存起来;②进行检查点保存时,也会将这些结果数据一并做持久化存储;③在收到检查点完成的通知时,将所有结果一次性写入外部系统。
        预写日志可能重复写入,因为有可能批写入失败,需要外部系统的返回确认保证成功,最终保存确认信息需要持久化保存,若出现了故障未保存,会重新计算写出。
      • (2)两阶段提交(two-phase-commit,2PC)
        先做“预提交”,等检查点完成之后再正式提交。真正基于事务的,需要外部系统提供事务支持,TwoPhaseCommitSinkFunction。
        步骤:①当第一条数据到来时,或者收到检查点的分界线时,Sink任务都会启动一个事务;②接下来接收到的所有数据,都通过这个事务写入外部系统;这时由于事务没有提交,所以数据尽管写入了外部系统,是“预提交”的状态,不可用;③当Sink任务收到JobManager发来检查点完成的通知时,正式提交事务,写入的结果就真正可用了。
        不过两阶段提交虽然精巧,却对外部系统有很高的要求。
        1. 外部系统必须提供事务支持,或者Sink 任务必须能够模拟外部系统上的事务。
        2. 在检查点的间隔期间里,必须能够开启一个事务并接受数据写入。
        3. 在收到检查点完成的通知之前,事务必须是“等待提交”的状态。在故障恢复的情况下,这可能需要一些时间。如果这个时候外部系统关闭事务,那么未提交的数据就会丢失。
        4. Sink 任务必须能够在进程失败后恢复事务。
        5. 提交事务必须是幂等操作。也就是说,事务的重复提交应该是无效的。

4. Flink 和 Kafka 连接时的精确一次保证

  • Flink 内部:通过检查点机制保证状态和处理结果的 exactly-once 语义。
  • 输入端:数据源端的Kafka可以对数据进行持久化保存,并可以重置偏移量。Source 任务将当前读取的偏移量保存为算子状态,写入到检查点中;当发生故障时,从检查点中读取恢复状态,并由连接器 FlinkKafkaConsumer 向 Kafka 重新提交偏移量,就可以重新消费数据、保证结果的一致性了。
  • 输出端:两阶段提交。FlinkKafkaProducer实现了TwoPhaseCommitSinkFunction接口:处理完毕得到结果,写入Kafka 时是基于事务的“预提交”;等到检查点保存完毕,才会提交事务进行“正式提交”。如果中间出现故障,事务进行回滚,预提交就会被放弃;恢复状态之后,也只能恢复所有已经确认提交的操作。
  • 需要的配置:
    1. 必须启用检查点;
    2. 在FlinkKafkaProducer 的构造函数中传入参数 Semantic.EXACTLY_ONCE;
    3. 配置写入的Kafka读取数据的消费者的隔离级别。预提交阶段数据已写入,只是被标记为“未提交”uncommitted,而Kafka中默认的isolation.level是read_uncommitted,可以读取未提交的数据。这样一来,外部应用就可以直接消费未提交的数据,对于事务性的保证就失效了。所以应该将隔离级别配置为read_committed,外部应用消费数据就会有显著的延迟。
    4. 事务超时配置。Flink的Kafka连接器超时时间transaction.timeout.ms默认是1小时,而Kafka 集群配置的事务最大超时时间transaction.max.timeout.ms 默认是15分钟。所以在检查点保存时间很长时,有可能出现Kafka已经认为事务超时了,丢弃了预提交的数据;而 Sink 任务认为还可以继续等待。如果接下来检查点保存成功,发生故障后回滚到这个检查点的状态,这部分数据就被真正丢掉了。所以这两个超时时间,前者应该小于等于后者。

下一章:Flink 1.13 的 Flink CEP 复杂事件处理

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值