Flink:状态管理和容错机制

Table of Contents

Flink中的状态

flink状态是什么?

Keyed State 和 Operator State

原始状态和托管状态

如何使用Managed Keyed State

状态的生命周期(TTL)

如何使用Managed Operator  State

容错机制

什么是checkpoint

checkpoint算法

如何使用checkpoint

启用checkpoint



Flink中的状态

flink状态是什么?

flink的状态,简单来说,就是有状态函数或者算子在处理数据时,保存在本地的一个变量,这个变量可以是自定义结构的数据,用于记录计算时产生的结果,或者其他的数据。有状态的操作在对每条数据进行处理时,会基于状态计算或更新状态信息,如下图:

 

基于状态,flink可以使用更加精细的操作,如:

  • 可以在状态中保存所有元素,使应用可以在状态中查找数据。
  • 可以在状态中保存聚合操作的结果,如reduce算子计算的结果、窗口聚合函数的结果等。
  • 在数据流上训练机器学习模型时,使用状态保存模型参数的当前版本。
  • 可以在状态中保存历史数据,提高管理历史数据的效率。

flink的状态首先运行在内存中,定期被保存到checkpoint中(checkpoints保存在本地文件系统),防止意外中断导致的数据丢失。同时也可以使用savepoints手动将状态保存稳定的文件系统中,如hdfs、S3等。

Keyed State 和 Operator State

首先,flink中的state分为两种:Keyed State Operator State。

Keyed State:keyed state始终与key相关,所以只能在KeyedStream的函数和算子中使用keyed state。可以理解为,KeyedStream的算子或者函数按照key将数据流进行分区,每个key就是一个分区,而每个分区都保存着一个的keyed state。

以后版本中,可能会将keyed state改为Key Groups,Key Groups就是一个flink实例被分配到的所有key的组合。所以Key Groups的数量等于设置的并行度。

Operator State:Operator State即non-keyed state。算子操作或者非键控函数的每个并行任务都会绑定一个Operator State。如kafka连接器就是一个很好的例子:kafka消费者的每个分区都会维护一个map类型的数据,作为状态保存topic、分区和offset。当并行度发生变化时,Operator State支持重新分配状态。

原始状态和托管状态

keyed state和Operator State可以有两种形式存在:managed (托管)raw(原始)。

Managed State:Managed State在运行时由flink控制,保存在哈希表、RocksDB等结构化数据中。如ValueState、ListState。flink会对Managed State编码,并写入checkpoint。

Raw State:Raw State是以自定义的数据类型保存的状态信息。在写入checkpoint时,作为二进制序列写入checkpoint中。所以flink不知道Operator State的数据结构,只能获得原始的二进制序列。通常情况下,使用managed state居多。

所有的flink函数都可以使用Managed State,但是如果需要使用Raw State,则需要在函数内实现相应的接口。相较于Raw State,官方更推荐使用Managed State,使用Managed State时,支持修改并行度后自动重新分配状态,且具备更完善的内存管理。

注意:如果使用Managed State时需要自定义序列化器。参考:https://ci.apache.org/projects/flink/flink-docs-release-1.10/dev/stream/state/custom_serialization.html

如何使用Managed Keyed State

上面说到,flink的状态可以保存所有元素、聚合结果、历史数据等,flink提供了相应的接口以实现这些功能。另外,keyed state顾名思义,必须在stream.keyBy(…)之后使用,否则会报错。

下面是flink提供的状态数据类型:

ValueState<T>: 仅保存一个可更新、可检索的值。作用域为输入元素的键,即每个key保存一个<T>类型的状态。可以使用update(T)方法更新状态,或使用value()方法获取状态。

ListState<T>: 保存一个列表的状态。可以对这个列表进行追加写或者检索。使用add(T)或者addAll(List<T>)方法添加数据。使用get()方法可获得一个可迭代对象,可以在这个对象中检索数据。也可以使用update(List<T>)覆盖所有的数据。

ReducingState<T>: 保存一个唯一值,这个值是当前所有元素的预聚合结果。这个接口与ListState 相似,区别在于ReducingState的add()方法是调用ReduceFunction方法,将当前元素与之前的预聚合结果进行计算,再保存新的预聚合结果。

AggregatingState<IN, OUT>:保存一个唯一值,这个值是当前所有元素的预聚合结果。与ReducingState的区别在于AggregatingState的输入类型和输出类型可以不一致,AggregatingState分别定义输入、输出两个参数的数据类型。add(IN)内部调用的是AggregateFunction方法。

FoldingState<T, ACC>:保存一个唯一值,这个值是当前所有元素的预聚合结果。与ReducingState类似,区别在于add(T) 方法内部调用的是FoldFunction方法,FoldFunction与ReduceFunction不同之处在于FoldFunction可以设置一个初始值,FoldingState的ACC参数就是这个初始值。这个方法过时了。

MapState<UK, UV>: 保存一个列表的map类型的状态。可以使用put方法向其添加k-v类型的键值对,也可以用于检索。使用 put(UK, UV) 或者putAll(Map<UK, UV>) 方法添加数据;使用entries()keys() 和values() 来检索key和value。使用 isEmpty()判断是否存在数据。

所有的状态类型都有一个clear()方法,用于清空当前key的状态中所有的数据。

重点提示一:以上的状态类型对象仅仅只是作为一个状态的接口而已,状态不一定是存储在以上对象里面的,还可以存在本地磁盘或者其他地方。

重点提示二:你从状态中获取的value取决于当前输入元素的key,所以,你调用的同一个函数会根据不同的key返回不同的value。

获取状态时,必须创建一个StateDescriptor对象用于描绘状态的名称和数据类型,还可能包含自定义的函数,如ReduceFunction。状态通过RuntimeContext调用getState方法来获取,所以必须是富函数才能获取状态。

获取不同的状态对应方式如下:

  • ValueState<T> getState(ValueStateDescriptor<T>)
  • ReducingState<T> getReducingState(ReducingStateDescriptor<T>)
  • ListState<T> getListState(ListStateDescriptor<T>)
  • AggregatingState<IN, OUT> getAggregatingState(AggregatingStateDescriptor<IN, ACC, OUT>)
  • FoldingState<T, ACC> getFoldingState(FoldingStateDescriptor<T, ACC>)
  • MapState<UK, UV> getMapState(MapStateDescriptor<UK, UV>)

以FlatMapFunction为例,使用状态代码如下:

class CountWindowAverage extends RichFlatMapFunction[(Long, Long), (Long, Long)] {

  private var sum: ValueState[(Long, Long)] = _

  override def flatMap(input: (Long, Long), out: Collector[(Long, Long)]): Unit = {

    // 获取状态的值
    val tmpCurrentSum = sum.value

    // 如果状态不为空,则将其值赋给currentSum;否则初始化currentSum为(0L,0L)
    val currentSum = if (tmpCurrentSum != null) {
      tmpCurrentSum
    } else {
      (0L, 0L)
    }

    // 计算sum值
    val newSum = (currentSum._1 + 1, currentSum._2 + input._2)

    // 更新状态
    sum.update(newSum)

    // 当元素个数达到2, 发出平均值并清空状态。
    if (newSum._1 >= 2) {
      out.collect((input._1, newSum._2 / newSum._1))
      sum.clear()
    }
  }

  override def open(parameters: Configuration): Unit = {
    //在open函数中初始化状态,以免过早地获取状态导致数据错误。也可以在外部使用lazy修饰,效果与在open中初始化一样。
    sum = getRuntimeContext.getState(
      new ValueStateDescriptor[(Long, Long)]("average", createTypeInformation[(Long, Long)])
    )
  }
}


object ExampleCountWindowAverage extends App {
  val env = StreamExecutionEnvironment.getExecutionEnvironment

  env.fromCollection(List(
    (1L, 3L),
    (1L, 5L),
    (1L, 7L),
    (1L, 4L),
    (1L, 2L)
  )).keyBy(_._1)
    .flatMap(new CountWindowAverage())
    .print()
  // the printed output will be (1,4) and (1,5)

  env.execute("ExampleManagedState")
}

这个例子中,以输入元组的第一个元素为key(例子中所有key都是1),函数将元素个数和value的sum值保存在状态中。当元素个数达到2时,返回value的平均值并清除状态。

注意,如果元组列表中的元组的第一个元素不相同(即key不同),则这为每个不同的key保留不同的状态。

状态的生命周期(TTL)

任何类型的keyed state都可以分配一个生命周期时间(TTL)。如果配置了TTL,且一个状态过期了,那么就清空这个状态。每个key都有其对应的状态,状态收集器对每个状态独立判断TTL,这意味着如果某个key的状态过期,那么只会情况该key的状态,而不会影响其他key的状态。

判断过期的逻辑为:上一个时间戳+TTL<=当前时间,则视为过期。以下是判断是否过期的源码:

public class TtlUtils {
	static <V> boolean expired(@Nullable TtlValue<V> ttlValue, long ttl, TtlTimeProvider timeProvider) {
		return expired(ttlValue, ttl, timeProvider.currentTimestamp());
	}

	static <V> boolean expired(@Nullable TtlValue<V> ttlValue, long ttl, long currentTimestamp) {
		return ttlValue != null && expired(ttlValue.getLastAccessTimestamp(), ttl, currentTimestamp);
	}

	static boolean expired(long ts, long ttl, TtlTimeProvider timeProvider) {
		return expired(ts, ttl, timeProvider.currentTimestamp());
	}
    //上一个时间戳+TTL<=当前时间,则视为过期
	public static boolean expired(long ts, long ttl, long currentTimestamp) {
		return getExpirationTimestamp(ts, ttl) <= currentTimestamp;
	}

	private static long getExpirationTimestamp(long ts, long ttl) {
		long ttlWithoutOverflow = ts > 0 ? Math.min(Long.MAX_VALUE - ts, ttl) : ttl;
		return ts + ttlWithoutOverflow;
	}

	static <V> TtlValue<V> wrapWithTs(V value, long ts) {
		return new TtlValue<>(value, ts);
	}
}

配置TTL

配置TTL首先需要创建一个StateTtlConfig 的对象,用于配置TTL相关信息。然后调用状态描述器的enableTimeToLive方法开启TTL,之后再通过描述器在RumTimeContext中获取状态。示例如下:

import org.apache.flink.api.common.state.StateTtlConfig;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.common.time.Time;

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

其中,setUpdateType方法用于设置TTL刷新方式,有两种刷新机制:

  • StateTtlConfig.UpdateType.OnCreateAndWrite - 仅在创建和写入时。
  • StateTtlConfig.UpdateType.OnReadAndWrite - 创建、写入、读取时。

setStateVisibility方法用于设置对已过期但还未被清理掉的状态如何处理,也有两种机制:

  • StateTtlConfig.StateVisibility.NeverReturnExpired - 过期数据不可见,即使未被清除,也不可见。
  • StateTtlConfig.StateVisibility.ReturnExpiredIfNotCleanedUp - 过期但未被清除的可见。

清除过期状态

默认情况下,过期数据会在读取时自动删除,然后后台会定期进行垃圾回收。也可以选择关闭后台垃圾回收,代码如下:

import org.apache.flink.api.common.state.StateTtlConfig;
StateTtlConfig ttlConfig = StateTtlConfig
    .newBuilder(Time.seconds(1))
    .disableCleanupInBackground()
    .build();

也可以设置成在创建全状态镜像时清除过期状态,这样可以减小快照大小。在这种模式下,本地状态不会被清理,但是如果从快照中恢复状态时,也不会包含过期数据。注意:此选项不适用于使用RocksDB做增量checkpoint。设置方式如下:

import org.apache.flink.api.common.state.StateTtlConfig
import org.apache.flink.api.common.time.Time

val ttlConfig = StateTtlConfig
    .newBuilder(Time.seconds(1))
    .cleanupFullSnapshot
    .build

也可以在访问状态或者处理数据时触发状态删除操作。如果使用这种策略,则状态存储后端会保存一个懒加载的全局迭代器用于存储所有的state。只有在触发清理操作时,才会激活这个迭代器,遍历所有状态并清理过期状态。配置代码如下:

import org.apache.flink.api.common.state.StateTtlConfig
val ttlConfig = StateTtlConfig
    .newBuilder(Time.seconds(1))
    .cleanupIncrementally(10, true)
    .build

配置这种方式需要传入两个参数,第一个参数是当访问状态时(访问状态必定触发清理),每次检查的状态的数目;第二个参数是是否在处理每条数据是触发清理操作。默认是每次检查5个状态数据,不基于处理数据触发清理。

Scala DataStream API中特有的状态接口

除了上述接口外,在scala API中,对map() 或者 flatMap()函数在操作KeyedStream时,还提供了一种快捷方式来访问一个ValueState 。如:

val stream: DataStream[(String, Int)] = ...

val counts: DataStream[(String, Int)] = stream
  .keyBy(_._1)
  .mapWithState((in: (String, Int), count: Option[Int]) =>
    count match {
      case Some(c) => ( (in._1, c), Some(c + in._2) )
      case None => ( (in._1, 0), Some(in._2) )
    })

如何使用Managed Operator  State

因为生产环境中大部分使用的都是keyed state,很少使用Operator  State,所以这里只展示几个简单的例子,不做赘述。

下面例子中使用了有状态的SinkFunction ,使用了CheckpointedFunction在输出元素前先进行缓冲,然后将事件切分并更新状态。

class BufferingSink(threshold: Int = 0)
  extends SinkFunction[(String, Int)]
    with CheckpointedFunction {

  @transient
  private var checkpointedState: ListState[(String, Int)] = _

  private val bufferedElements = ListBuffer[(String, Int)]()

  override def invoke(value: (String, Int), context: Context): Unit = {
    bufferedElements += value
    if (bufferedElements.size == threshold) {
      for (element <- bufferedElements) {
        // send it to the sink
      }
      bufferedElements.clear()
    }
  }

  override def snapshotState(context: FunctionSnapshotContext): Unit = {
    checkpointedState.clear()
    for (element <- bufferedElements) {
      checkpointedState.add(element)
    }
  }

  override def initializeState(context: FunctionInitializationContext): Unit = {
    val descriptor = new ListStateDescriptor[(String, Int)](
      "buffered-elements",
      TypeInformation.of(new TypeHint[(String, Int)]() {})
    )

    checkpointedState = context.getOperatorStateStore.getListState(descriptor)

    if(context.isRestored) {
      for(element <- checkpointedState.get()) {
        bufferedElements += element
      }
    }
  }

}

容错机制

flink的容错机制都是基于checkpoint(状态一致性检查)的,简单来说,就是flink在计算过程中,将状态保存至checkpoint,当遇到故障终止任务后,可以从checkpoint中恢复数据并继续任务,达到容错的目的。

什么是checkpoint

Checkpoint是flink故障恢复机制的核心,可以保证数据精准一次消费。所谓checkpoint,其实就是有状态流在某一时间点的状态的快照。这个时间点应该是所有任务都恰好处理完同一个输入数据的时候,即在整个flink程序中,最后一个操作也已经处理完这条数据的时候。未被处理完的其他数据的状态不会被保存。

当遇到故障,导致应用停止后。第一步将会重启应用,然后会从checkpoint中恢复状态,此时状态将会恢复到上一个checkpoint时的状态,然后继续正常运行应用。

flink的checkpoint与spark-streaming的checkpoint不同,spark-streaming是批处理,所以它的checkpoint比较简单,因为底层是rdd,所以只要把rdd保存起来就可以了,但是这就有一个缺点,那就是一旦发生故障,可能有一整个批次的数据都要重新计算,加入数据量很大的花,将会消耗更多的事件;而flink是流式处理,它的checkpoint针对的是每一条数据(可以设置每处理一条都保存一次,也可以设置一段事件保存一次),所以它的checkpoint更加复杂,但是发生故障后对整个应用的影响也更小。

chekpoint中有类似watermark的机制,称为checkpoint-barrier,用于检查点对齐,当收到checkpoint-barrier时保存快照。checkpoint-barrier有三个属性:ID,timestamp,checkpoint-options。每个有状态操作遇到checkpoint-barrier都会保存快照,而只有当最后一个操作保存了快照之后,这次checkpoint才算完成。

checkpoint算法

基于分布式快照Chandy-Lamport,具体可查看这篇博客https://www.cnblogs.com/yuanyifei1/p/10360465.html

如何使用checkpoint

StreamExecutionEnvironment中有一个CheckpointConfig对象,当调用环境对象的env.enableCheckpointing(1000)方法时,实际上是调用CheckpointConfig对象的各种set方法。如:

通过env对象开启checkpoint,实际上是调用checkpointconfig的setCheckpointInterval方法:

//env的enableCheckpointing方法
	public StreamExecutionEnvironment enableCheckpointing(long interval) {
		checkpointCfg.setCheckpointInterval(interval);
		return this;
	}

checkpointconfig中的setCheckpointInterval方法

//checkpointconfig
	public void setCheckpointInterval(long checkpointInterval) {
		if (checkpointInterval <= 0) {
			throw new IllegalArgumentException("Checkpoint interval must be larger than zero");
		}
		this.checkpointInterval = checkpointInterval;
	}

启用checkpoint

默认情况下,checkpoint是关闭的,需要调用环境对象StreamExecutionEnvironment的enableCheckpointing(n) 方法以启用checkpoint,参数n代表每隔n毫秒发出一个checkpointbarrier。

以下是一个设置checkpoint的样例:

//创建环境对象
val env = StreamExecutionEnvironment.getExecutionEnvironment()

// 每1000ms做一次快照
env.enableCheckpointing(1000)

// 以上就开启了checkpoint了,以下是一些其他可选设置:

// 设置 exactly-once 模式(默认就是exactly-once)
env.getCheckpointConfig.setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE)

// 确保检查点之间有最小间隔为500 ms,假设每10s做一次checkpoint,某次耗时9s,那么正常在本次checkpoint完成后的1s又该做checkpoint了,以下配置可以确保每次checkpoint的最小间隔。
env.getCheckpointConfig.setMinPauseBetweenCheckpoints(500)

// 设置checkpoint超时时间,超过一分钟则丢弃
env.getCheckpointConfig.setCheckpointTimeout(60000)

// 保存checkpoint时发生故障,是否停止任务。如果配置false,那么checkpointing时如果发生故障,则不停止任务,仅丢弃该次checkpoint。
env.getCheckpointConfig.setFailTasksOnCheckpointingErrors(false)

// 设置checkpoint并行度
env.getCheckpointConfig.setMaxConcurrentCheckpoints(1)
key 默认 类型 描述

state.backend

null String 用于存储和检查点状态的状态后端。决定了checkpoint的存储位置(一般放在远程存储空间如fs或rocksdb)

state.backend.async

true Boolean 选择state.backend是否使用异步快照方法。某些state.backend可能不支持异步快照,或者仅支持异步快照,因此会忽略此选项。

state.backend.fs.memory-threshold

1024 Int 状态数据文件的最小大小。当所有状态数据小于次值时,保存在内存,超过此值时落盘。

state.backend.fs.write-buffer-size

4096 Int write buffer的默认大小。实际的写缓冲区大小为该选项和选项“ state.backend.fs.memory-threshold”的最大值。

state.backend.incremental

false Boolean 是否使用增量检查点(如果可能)。增量检查点仅存储与前一个检查点的差异,而不存储完整的检查点状态。某些状态后端可能不支持增量检查点,因此会忽略此选项。

state.backend.local-recovery

false Boolean 是否配置从本地恢复状态。默认情况下,本地恢复处于禁用状态。当前版本中(1.10),本地恢复仅支持键控状态后端。MemoryStateBackend不支持本地恢复,请忽略此选项。

state.checkpoints.dir

null String 存储检查点的数据文件和元数据的默认目录。必须是所有TaskManager和JobManager都能访问的存储路径。

state.checkpoints.num-retained

1 Int 要保留的最大已完成checkpoint数。

state.savepoints.dir

null String savepoint的默认目录。用于将savepoint写入文件系统(MemoryStateBackend,FsStateBackend,RocksDBStateBackend)。

taskmanager.state.local.root-dirs

null String config参数定义用于存储基于文件的状态以进行本地恢复的根目录。当前版本中(1.10),本地恢复仅支持键控状态后端。MemoryStateBackend不支持本地恢复,请忽略此选项。

猜你喜欢

转载自blog.csdn.net/x950913/article/details/106599275