【Flink】Flink 中的 ProcessFunction API 和 状态一致性保证

1、ProcessFunction API(底层 API)

我们之前学习的 转换算子是无法访问事件的时间戳信息和水位线信息的。而这在一些应用场景下,极为重要。例如 MapFunction 这样的 map 转换算子就无法访问时间戳或者当前事件的事件时间。
基于此,DataStream API 提供了一系列的 Low-Level 转换算子。可以 访问时间戳、watermark 以及注册定时事件。还可以输出 特定的一些事件,例如超时事件等。
Process Function 用来构建事件驱动的应用以及实现自定义的业务逻辑(使用之前的 window 函数和转换算子无法实现)例如,Flink SQL 就是使用 Process Function 实现的。
Flink 提供了 8 个 Process Function:

  • ProcessFunction
  • KeyedProcessFunction
  • CoProcessFunction
  • ProcessJoinFunction
  • BroadcastProcessFunction
  • KeyedBroadcastProcessFunction
  • ProcessWindowFunction
  • ProcessAllWindowFunction
KeyedProcessFunction

这里我们重点介绍 KeyedProcessFunction。
KeyedProcessFunction 用来操作 KeyedStream。KeyedProcessFunction 会处理流的每一个元素,输出为 0 个、1 个或者多个元素。所有的 Process Function 都继承自 RichFunction 接口,所以都有 open()、close()和 getRuntimeContext()等方法。而 KeyedProcessFunction[KEY, IN, OUT]还额外提供了两个方法:

  • processElement(v: IN, ctx: Context, out: Collector[OUT]),流中的每一个元素都会调用这个方法,调用结果将会放在 Collector 数据类型中输出。Context 可以访问元素的时间戳,元素的 key,以及 TimerService 时间服务。Context 还可以将结果输出到别的流(side outputs)。
  • onTimer(timestamp: Long, ctx: OnTimerContext, out: Collector[OUT])是一个回调函数。当之前注册的定时器触发时调用。参数 timestamp 为定时器所设定的触发的时间戳。Collector 为输出结果的集合。OnTimerContext 和 processElement 的 Context 参数一样,提供了上下文的一些信息,例如定时器触发的时间信息(事件时间或者处理时间)。
TimerService 和 定时器(Timers)

Context 和 OnTimerContext 所持有的 TimerService 对象拥有以下方法:

  • currentProcessingTime(): Long 返回当前处理时间
  • currentWatermark(): Long 返回当前 watermark 的时间戳
  • registerProcessingTimeTimer(timestamp: Long): Unit 会注册当前 key 的 processing time 的定时器。当 processing time 到达定时时间时,触发 timer
  • registerEventTimeTimer(timestamp: Long): Unit 会注册当前 key 的 event time定时器。当水位线大于等于定时器注册的时间时,触发定时器执行回调函数
  • deleteProcessingTimeTimer(timestamp: Long): Unit 删除之前注册处理时间定时器。如果没有这个时间戳的定时器,则不执行
  • deleteEventTimeTimer(timestamp: Long): Unit 删除之前注册的事件时间定时器,如果没有此时间戳的定时器,则不执行。

当定时器 timer 触发时,会执行回调函数 onTimer()。注意定时器 timer 只能在 keyed streams 上面使用。
下面举个例子说明 KeyedProcessFunction 如何操作 KeyedStream。
需求:监控温度传感器的温度值,如果温度值在一秒钟之内(processing time)连续上升,则报警。

val warnings = readings
	.keyBy(_.id)
	.process(new TempIncreaseAlertFunction)

看一下 TempIncreaseAlertFunction 如何实现,程序中使用了 ValueState 这样一个状态变量。

class TempIncreaseAlertFunction extends KeyedProcessFunction[String, SensorReading, String] {
	//  保存上一个传感器温度值
	lazy val lastTemp: ValueState[Double] = getRuntimeContext.getState(new ValueStateDescriptor[Double]("lastTemp", Types.of[Double]))
	//  保存注册的定时器的时间戳
	lazy val currentTimer: ValueState[Long] = getRuntimeContext.getState(new ValueStateDescriptor[Long]("timer", Types.of[Long]))
	
	override def processElement(r: SensorReading, ctx: KeyedProcessFunction[String, SensorReading, String]#Context, out: Collector[String]): Unit = {
		//  取出上一次的温度
		val prevTemp = lastTemp.value()
		//  将当前温度更新到上一次的温度这个变量中
		lastTemp.update(r.temperature)
		val curTimerTimestamp = currentTimer.value()
		if (prevTemp == 0.0 || r.temperature < prevTemp) {
			// 温度下降或者是第一个温度值,删除定时器
			ctx.timerService().deleteProcessingTimeTimer(curTimerTimestamp)
			// 清空状态变量
			currentTimer.clear()
		} else if (r.temperature > prevTemp && curTimerTimestamp == 0) {
			//  温度上升且我们并没有设置定时器
			val timerTs = ctx.timerService().currentProcessingTime() + 1000
			ctx.timerService().registerProcessingTimeTimer(timerTs)
			currentTimer.update(timerTs)
		}
	}
	
	override def onTimer(ts: Long, ctx: KeyedProcessFunction[String, SensorReading, String]#OnTimerContext, out: Collector[String]): Unit = {
		out.collect("传感器 id 为: " + ctx.getCurrentKey + "的传感器温度值已经连续 1s 上升了。")
		currentTimer.clear()
	}
}
侧输出流(SideOutput)

大部分的 DataStream API 的算子的输出是单一输出,也就是某种数据类型的流。除了 split 算子,可以将一条流分成多条流,这些流的数据类型也都相同。process function 的 side outputs 功能可以产生多条流,并且这些流的数据类型可以不一样。一个 side output 可以定义为 OutputTag[X]对象,X 是输出流的数据类型。process function 可以通过 Context 对象发射一个事件到一个或者多个 side outputs。
下面是一个示例程序:

val monitoredReadings: DataStream[SensorReading] = readings
	.process(new FreezingMonitor)
monitoredReadings
	.getSideOutput(new OutputTag[String]("freezing-alarms"))
	.print()
readings.print()

接下来我们实现 FreezingMonitor 函数,用来监控传感器温度值,将温度值低于 32F 的温度输出到 side output。

class FreezingMonitor extends ProcessFunction[SensorReading, SensorReading] {
	//  定义一个侧输出标签
	lazy val freezingAlarmOutput: OutputTag[String] = new OutputTag[String]("freezing-alarms")
	override def processElement(r: SensorReading, ctx: ProcessFunction[SensorReading, SensorReading]#Context, out: Collector[SensorReading]): Unit = {
		//  温度在 32F 以下时,输出警告信息
		if (r.temperature < 32.0) {
			ctx.output(freezingAlarmOutput, s"Freezing Alarm for ${r.id}")
		}
		//  所有数据直接常规输出到主流
		out.collect(r)
	}
}
CoProcessFunction

对于两条输入流,DataStream API 提供了 CoProcessFunction 这样的 low-level 操作。CoProcessFunction 提供了操作每一个输入流的方法:processElement1() 和 processElement2()。
类似于 ProcessFunction,这两种方法都通过 Context 对象来调用。这个 Context 对象可以访问事件数据,定时器时间戳,TimerService,以及 side outputs。
CoProcessFunction 也提供了 onTimer()回调函数。

2、状态编程和容错机制

流式计算分为无状态和有状态两种情况。无状态的计算观察每个独立事件,并根据最后一个事件输出结果。例如,流处理应用程序从传感器接收温度读数,并在温度超过 90 度时发出警告。有状态的计算则会基于多个事件输出结果。以下是一些例子。

  • 所有类型的窗口。例如,计算过去一小时的平均温度,就是有状态的计算。
  • 所有用于复杂事件处理的状态机。例如,若在一分钟内收到两个相差 20 度以上的温度读数,则发出警告,这是有状态的计算。
  • 流与流之间的所有关联操作,以及流与静态表或动态表之间的关联操作,都是有状态的计算。

下图展示了无状态流处理和有状态流处理的主要区别。无状态流处理分别接收每条数据记录(图中的黑条),然后根据最新输入的数据生成输出数据(白条)。有状态流处理会维护状态(根据每条输入记录进行更新),并基于最新输入的记录和当前的状态值生成输出记录(灰条)。
【Flink】Flink 中的 ProcessFunction API 和 状态一致性保证_第1张图片
上图中输入数据由黑条表示。无状态流处理每次只转换一条输入记录,并且仅根据最新的输入记录输出结果(白条)。有状态 流处理维护所有已处理记录的状态值,并根据每条新输入的记录更新状态,因此输出记录(灰条)反映的是综合考虑多个事件之后的结果。
尽管无状态的计算很重要,但是流处理对有状态的计算更感兴趣。事实上,正确地实现有状态的计算比实现无状态的计算难得多。旧的流处理系统并不支持有状态的计算,而新一代的流处理系统则将状态及其正确性视为重中之重。

有状态的算子和应用程序

Flink 内置的很多算子,数据源 source,数据存储 sink 都是有状态的,流中的数据都是 buffer records,会保存一定的元素或者元数据。例如:ProcessWindowFunction 会缓存输入流的数据,ProcessFunction 会保存设置的定时器信息等等。
在 Flink 中,状态始终与特定算子相关联。总的来说,有两种类型的状态:

  • 算子状态(operator state)
  • 键控状态(keyed state)
算子状态(operator state )

算子状态的作用范围限定为算子任务。这意味着由同一并行任务所处理的所有数据都可以访问到相同的状态,状态对于同一任务而言是共享的。算子状态不能由相同或不同算子的另一个任务访问。
【Flink】Flink 中的 ProcessFunction API 和 状态一致性保证_第2张图片
Flink 为算子状态提供三种基本数据结构:

  • 列表状态(List state)
    将状态表示为一组数据的列表。
  • 联合列表状态(Union list state)
    也将状态表示为数据的列表。它与常规列表状态的区别在于,在发生故障时,或者从保存点(savepoint)启动应用程序时如何恢复。
  • 广播状态(Broadcast state)
    如果一个算子有多项任务,而它的每项任务状态又都相同,那么这种特殊情况最适合应用广播状态。
键控状态(keyed state)

键控状态是根据输入数据流中定义的键(key)来维护和访问的。Flink 为每个键值维护一个状态实例,并将具有相同键的所有数据,都分区到同一个算子任务中,这个任务会维护和处理这个 key 对应的状态。当任务处理一条数据时,它会自动将状态的访问范围限定为当前数据的 key。因此,具有相同 key 的所有数据都会访问相同的状态。Keyed State 很类似于一个分布式的 key-value map 数据结构,只能用于 KeyedStream(keyBy 算子处理之后)。
【Flink】Flink 中的 ProcessFunction API 和 状态一致性保证_第3张图片
Flink 的 Keyed State 支持以下数据类型:

  • ValueState[T]保存单个的值,值的类型为 T。
    get 操作: ValueState.value()
    set 操作: ValueState.update(value: T)
  • ListState[T]保存一个列表,列表里的元素的数据类型为 T。基本操作如下:
    ListState.add(value: T)
    ListState.addAll(values: java.util.List[T])
    ListState.get()返回 Iterable[T]
    ListState.update(values: java.util.List[T])
  • MapState[K, V]保存 Key-Value 对。
    MapState.get(key: K)
    MapState.put(key: K, value: V)
    MapState.contains(key: K)
    MapState.remove(key: K)
  • ReducingState[T]
  • AggregatingState[I, O]

State.clear()是清空操作。

val sensorData: DataStream[SensorReading] = ...
val keyedData: KeyedStream[SensorReading, String] = sensorData.keyBy(_.id)
val alerts: DataStream[(String, Double, Double)] = keyedData
	.flatMap(new TemperatureAlertFunction(1.7))
	
class TemperatureAlertFunction(val threshold: Double) extends RichFlatMapFunction[SensorReading, (String, Double, Double)] {
	private var lastTempState: ValueState[Double] = _
	override def open(parameters: Configuration): Unit = {
		val lastTempDescriptor = new ValueStateDescriptor[Double]("lastTemp", classOf[Double])
		lastTempState = getRuntimeContext.getState[Double](lastTempDescriptor)
	}
	
	override def flatMap(reading: SensorReading, out: Collector[(String, Double, Double)]): Unit = {
		val lastTemp = lastTempState.value()
		val tempDiff = (reading.temperature - lastTemp).abs
		if (tempDiff > threshold) {
			out.collect((reading.id, reading.temperature, tempDiff))
		}
		this.lastTempState.update(reading.temperature)
	}
}

通过 RuntimeContext 注册 StateDescriptor。StateDescriptor 以状态 state 的名字和存储的数据类型为参数。
在 open()方法中创建 state 变量。注意复习之前的 RichFunction 相关知识。
接下来我们使用了 FlatMap with keyed ValueState 的快捷方式 flatMapWithState实现以上需求。

val alerts: DataStream[(String, Double, Double)] = keyedSensorData
	.flatMapWithState[(String, Double, Double), Double] {
		case (in: SensorReading, None) => (List.empty, Some(in.temperature))
		case (r: SensorReading, lastTemp: Some[Double]) => val tempDiff = (r.temperature - lastTemp.get).abs
		if (tempDiff > 1.7) {
			(List((r.id, r.temperature, tempDiff)), Some(r.temperature))
		} else {
			(List.empty, Some(r.temperature))
		}
	}
状态一致性

当在分布式系统中引入状态时,自然也引入了一致性问题。一致性实际上是"正确性级别"的另一种说法,也就是说在成功处理故障并恢复之后得到的结果,与没有发生任何故障时得到的结果相比,前者到底有多正确?举例来说,假设要对最近一小时登录的用户计数。在系统经历故障之后,计数结果是多少?如果有偏差,是有漏掉的计数还是重复计数?

一致性级别

在流处理中,一致性可以分为 3 个级别:

  • at-most-once: 这其实是没有正确性保障的委婉说法——故障发生之后,计数结果可能丢失。同样的还有 udp。
  • at-least-once: 这表示计数结果可能大于正确值,但绝不会小于正确值。也就是说,计数程序在发生故障后可能多算,但是绝不会少算。
  • exactly-once: 这指的是系统保证在发生故障后得到的计数结果与正确值一致。

曾经,at-least-once 非常流行。第一代流处理器(如 Storm 和 Samza)刚问世时只保证 at-least-once,原因有二。

  • 保证 exactly-once 的系统实现起来更复杂。这在基础架构层(决定什么代表正确,以及 exactly-once 的范围是什么)和实现层都很有挑战性。
  • 流处理系统的早期用户愿意接受框架的局限性,并在应用层想办法弥补(例如使应用程序具有幂等性,或者用批量计算层再做一遍计算)。

最先保证 exactly-once 的系统(Storm Trident 和 Spark Streaming)在性能和表现力这两个方面付出了很大的代价。为了保证 exactly-once,这些系统无法单独地对每条记录运用应用逻辑,而是同时处理多条(一批)记录,保证对每一批的处理要么全部成功,要么全部失败。这就导致在得到结果前,必须等待一批记录处理结束。因此,用户经常不得不使用两个流处理框架(一个用来保证 exactly-once,另一个用来对每个元素做低延迟处理),结果使基础设施更加复杂。曾经,用户不得不在保证 exactly-once 与获得低延迟和效率之间权衡利弊。Flink 避免了这种权衡。
Flink 的一个重大价值在于, 它既保证了 exactly-once ,也具有低延迟和高吞吐力 的处理能力。
从根本上说,Flink 通过使自身满足所有需求来避免权衡,它是业界的一次意义重大的技术飞跃。尽管这在外行看来很神奇,但是一旦了解,就会恍然大悟。

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

目前我们看到的一致性保证都是由流处理器实现的,也就是说都是在 Flink 流处理器内部保证的;而在真实应用中,流处理应用除了流处理器以外还包含了数据源(例如 Kafka)和输出到持久化系统。
端到端的一致性保证,意味着结果的正确性贯穿了整个流处理应用的始终;每一个组件都保证了它自己的一致性,整个端到端的一致性级别取决于所有组件中一致性最弱的组件。具体可以划分如下:

  • 内部保证 —— 依赖 checkpoint
  • source 端 —— 需要外部源可重设数据的读取位置
  • sink 端 —— 需要保证从故障恢复时,数据不会重复写入外部系统

而对于 sink 端,又有两种具体的实现方式:幂等(Idempotent)写入和事务性(Transactional)写入。

  • 幂等写入
    所谓幂等操作,是说一个操作,可以重复执行很多次,但只导致一次结果更改,也就是说,后面再重复执行就不起作用了。
  • 事务写入
    需要构建事务来写入外部系统,构建的事务对应着 checkpoint,等到 checkpoint 真正完成的时候,才把所有对应的结果写入 sink 系统中。

对于事务性写入,具体又有两种实现方式:预写日志(WAL)和两阶段提交(2PC)。DataStream API 提供了 GenericWriteAheadSink 模板类和
TwoPhaseCommitSinkFunction 接口,可以方便地实现这两种方式的事务性写入。
不同 Source 和 Sink 的一致性保证可以用下表说明:

Sink\source 不可重置 可重置
任意(Any) At-most-once At-least-once
幂等 At-most-once Exactly-once(故障恢复时会出现暂时不一致)
预写日志(WAL) At-most-once At-least-once
两阶段提交(2PC) At-most-once Exactly-once
检查点(checkpoint)

Flink 具体如何保证 exactly-once 呢? 它使用一种被称为"检查点"(checkpoint)的特性,在出现故障时将系统重置回正确状态。下面通过简单的类比来解释检查点的作用。
假设你和两位朋友正在数项链上有多少颗珠子。你捏住珠子,边数边拨,每拨过一颗珠子就给总数加一。你的朋友也这样数他们手中的珠子。当你分神忘记数到哪里时,怎么办呢? 如果项链上有很多珠子,你显然不想从头再数一遍,尤其是当三人的速度不一样却又试图合作的时候,更是如此(比如想记录前一分钟三人一共数了多少颗珠子,回想一下一分钟滚动窗口)。
于是,你想了一个更好的办法: 在项链上每隔一段就松松地系上一根有色皮筋,将珠子分隔开; 当珠子被拨动的时候,皮筋也可以被拨动; 然后,你安排一个助手,让他在你和朋友拨到皮筋时记录总数。用这种方法,当有人数错时,就不必从头开始数。相反,你向其他人发出错误警示,然后你们都从上一根皮筋处开始重数,助手则会告诉每个人重数时的起始数值,例如在粉色皮筋处的数值是多少。
Flink 检查点的作用就类似于皮筋标记。数珠子这个类比的关键点是: 对于指定的皮筋而言,珠子的相对位置是确定的; 这让皮筋成为重新计数的参考点。总状态(珠子的总数)在每颗珠子被拨动之后更新一次,助手则会保存与每根皮筋对应的检查点状态,如当遇到粉色皮筋时一共数了多少珠子,当遇到橙色皮筋时又是多少。当问题出现时,这种方法使得重新计数变得简单。

Flink 的检查点算法

Flink 检查点的核心作用是确保状态正确,即使遇到程序中断,也要正确。记住这一基本点之后,我们用一个例子来看检查点是如何运行的。Flink 为用户提供了用来定义状态的工具。例如,以下这个 Scala 程序按照输入记录的第一个字段(一个字符串)进行分组并维护第二个字段的计数状态。

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

该程序有两个算子: keyBy 算子用来将记录按照第一个元素(一个字符串)进行分组,根据该 key 将数据进行重新分区,然后将记录再发送给下一个算子:有状态的map 算子(mapWithState)。map 算子在接收到每个元素后,将输入记录的第二个字段的数据加到现有总数中,再将更新过的元素发射出去。下图表示程序的初始状态: 输入流中的 6 条记录被检查点分割线(checkpoint barrier)隔开,所有的 map 算子状态均为 0(计数还未开始)。所有 key 为 a 的记录将被顶层的 map 算子处理,所有 key 为 b
的记录将被中间层的 map 算子处理,所有 key 为 c 的记录则将被底层的 map 算子处理。
【Flink】Flink 中的 ProcessFunction API 和 状态一致性保证_第4张图片
上图是程序的初始状态。注意,a、b、c 三组的初始计数状态都是 0,即三个圆柱上的值。ckpt 表示检查点分割线(checkpoint barriers)。每条记录在处理顺序上严格地遵守在检查点之前或之后的规定,例如[“b”,2]在检查点之前被处理,[“a”,2]则在检查点之后被处理。
当该程序处理输入流中的 6 条记录时,涉及的操作遍布 3 个并行实例(节点、CPU内核等)。那么,检查点该如何保证 exactly-once 呢?
检查点分割线和普通数据记录类似。它们由算子处理,但并不参与计算,而是会触发与检查点相关的行为。当读取输入流的数据源(在本例中与 keyBy 算子内联)遇到检查点屏障时,它将其在输入流中的位置保存到持久化存储中。如果输入流来自消息传输系统(Kafka),这个位置就是偏移量。Flink 的存储机制是插件化的,持久化存储可以是分布式文件系统,如 HDFS。下图展示了这个过程。
【Flink】Flink 中的 ProcessFunction API 和 状态一致性保证_第5张图片
当 Flink 数据源(在本例中与 keyBy 算子内联)遇到检查点分界线(barrier)时,它会将其在输入流中的位置保存到持久化存储中。这让 Flink 可以根据该位置重启。
检查点像普通数据记录一样在算子之间流动。当 map 算子处理完前 3 条数据并收到检查点分界线时,它们会将状态以异步的方式写入持久化存储,如下图所示。
【Flink】Flink 中的 ProcessFunction API 和 状态一致性保证_第6张图片
位于检查点之前的所有记录([“b”,2]、[“b”,3]和[“c”,1])被 map 算子处理之后的情况。此时,持久化存储已经备份了检查点分界线在输入流中的位置(备份操作发生在 barrier 被输入算子处理的时候)。map 算子接着开始处理检查点分界线,并触发将状态异步备份到稳定存储中这个动作。
当 map 算子的状态备份和检查点分界线的位置备份被确认之后,该检查点操作就可以被标记为完成,如下图所示。我们在无须停止或者阻断计算的条件下,在一个逻辑时间点(对应检查点屏障在输入流中的位置)为计算状态拍了快照。通过确保备份的状态和位置指向同一个逻辑时间点,后文将解释如何基于备份恢复计算,从而保证 exactly-once。值得注意的是,当没有出现故障时,Flink 检查点的开销极小,检查点操作的速度由持久化存储的可用带宽决定。回顾数珠子的例子: 除了因为数错而需要用到皮筋之外,皮筋会被很快地拨过。
【Flink】Flink 中的 ProcessFunction API 和 状态一致性保证_第7张图片
检查点操作完成,状态和位置均已备份到稳定存储中。输入流中的所有数据记录都已处理完成。值得注意的是,备份的状态值与实际的状态值是不同的。备份反映的是检查点的状态。
如果检查点操作失败,Flink 可以丢弃该检查点并继续正常执行,因为之后的某一个检查点可能会成功。虽然恢复时间可能更长,但是对于状态的保证依旧很有力。
只有在一系列连续的检查点操作失败之后,Flink 才会抛出错误,因为这通常预示着发生了严重且持久的错误。
现在来看看下图所示的情况: 检查点操作已经完成,但故障紧随其后。
【Flink】Flink 中的 ProcessFunction API 和 状态一致性保证_第8张图片
在这种情况下,Flink 会重新拓扑(可能会获取新的执行资源),将输入流倒回到上一个检查点,然后恢复状态值并从该处开始继续计算。在本例中,[“a”,2]、[“a”,2] 和 [“c”,2]这几条记录将被重播。
下图展示了这一重新处理过程。从上一个检查点开始重新计算,可以保证在剩下的记录被处理之后,得到的 map 算子的状态值与没有发生故障时的状态值一致。
【Flink】Flink 中的 ProcessFunction API 和 状态一致性保证_第9张图片
Flink 将输入流倒回到上一个检查点屏障的位置,同时恢复 map 算子的状态值。然后,Flink 从此处开始重新处理。这样做保证了在记录被处理之后,map 算子的状态值与没有发生故障时的一致。
Flink 检查点算法的正式名称是异步分界线快照(asynchronous barrier
snapshotting)。该算法大致基于 Chandy-Lamport 分布式快照算法。
检查点是 Flink 最有价值的创新之一,因为 它使 Flink 可以保证 exactly-once,并且不需要牺牲性能。

Flink+Kafka 如何实现端到端的 exactly-once 语义

我们知道,端到端的状态一致性的实现,需要每一个组件都实现,对于 Flink + Kafka 的数据管道系统(Kafka 进、Kafka 出)而言,各组件怎样保证 exactly-once语义呢?

  • 内部 —— 利用 checkpoint 机制,把状态存盘,发生故障的时候可以恢复,保证内部的状态一致性
  • source —— kafka consumer 作为 source,可以将偏移量保存下来,如果后续任务出现了故障,恢复的时候可以由连接器重置偏移量,重新消费数据,保证一致性
  • sink —— kafka producer 作为 sink,采用两阶段提交 sink,需要实现一个 TwoPhaseCommitSinkFunction

内部的 checkpoint 机制我们已经有了了解,那 source 和 sink 具体又是怎样运行的呢?接下来我们逐步做一个分析。
我们知道 Flink 由 JobManager 协调各个 TaskManager 进行 checkpoint 存储,checkpoint 保存在 StateBackend 中,默认 StateBackend 是内存级的,也可以改为文件级的进行持久化保存。
【Flink】Flink 中的 ProcessFunction API 和 状态一致性保证_第10张图片
当 checkpoint 启动时,JobManager 会将检查点分界线(barrier)注入数据流;barrier 会在算子间传递下去。
【Flink】Flink 中的 ProcessFunction API 和 状态一致性保证_第11张图片
每个算子会对当前的状态做个快照,保存到状态后端。对于 source 任务而言,就会把当前的 offset 作为状态保存起来。下次从 checkpoint 恢复时,source 任务可以重新提交偏移量,从上次保存的位置开始重新消费数据。
【Flink】Flink 中的 ProcessFunction API 和 状态一致性保证_第12张图片
每个内部的 transform 任务遇到 barrier 时,都会把状态存到 checkpoint 里。sink 任务首先把数据写入外部 kafka,这些数据都属于预提交的事务(还不能被消费);当遇到 barrier 时,把状态保存到状态后端,并开启新的预提交事务。
【Flink】Flink 中的 ProcessFunction API 和 状态一致性保证_第13张图片
当所有算子任务的快照完成,也就是这次的 checkpoint 完成时,JobManager 会向所有任务发通知,确认这次 checkpoint 完成。
当 sink 任务收到确认通知,就会正式提交之前的事务,kafka 中未确认的数据就改为“已确认”,数据就真正可以被消费了。
【Flink】Flink 中的 ProcessFunction API 和 状态一致性保证_第14张图片
所以我们看到,执行过程实际上是一个两段式提交,每个算子执行完成,会进行“预提交”,直到执行完 sink 操作,会发起“确认提交”,如果执行失败,预提交会放弃掉。
具体的两阶段提交步骤总结如下:

  • 第一条数据来了之后,开启一个 kafka 的事务(transaction),正常写入 kafka 分区日志但标记为未提交,这就是“预提交”
  • jobmanager 触发 checkpoint 操作,barrier 从 source 开始向下传递,遇到 barrier 的算子将状态存入状态后端,并通知 jobmanager
  • sink 连接器收到 barrier,保存当前状态,存入 checkpoint,通知 jobmanager,并开启下一阶段的事务,用于提交下个检查点的数据
  • jobmanager 收到所有任务的通知,发出确认信息,表示 checkpoint 完成
  • sink 任务收到 jobmanager 的确认信息,正式提交这段时间的数据
  • 外部 kafka 关闭事务,提交的数据可以正常消费了

所以我们也可以看到,如果宕机需要通过 StateBackend 进行恢复,只能恢复所有确认提交的操作。

选择一个状态后端(state backend)
  • MemoryStateBackend
    内存级的状态后端,会将键控状态作为内存中的对象进行管理,将它们存储在 TaskManager 的 JVM 堆上;而将 checkpoint 存储在 JobManager 的内存中。
  • FsStateBackend
    将 checkpoint 存到远程的持久化文件系统(FileSystem)上。而对于本地状态,跟 MemoryStateBackend 一样,也会存在 TaskManager 的 JVM 堆上。
  • RocksDBStateBackend
    将所有状态序列化后,存入本地的 RocksDB 中存储。
    注意:RocksDB 的支持并不直接包含在 flink 中,需要引入依赖:

	org.apache.flink
	flink-statebackend-rocksdb_2.11
	1.7.2

设置状态后端为 FsStateBackend:

val env = StreamExecutionEnvironment.getExecutionEnvironment
val checkpointPath: String = ???
val backend = new RocksDBStateBackend(checkpointPath)
env.setStateBackend(backend)
env.setStateBackend(new FsStateBackend("file:///tmp/checkpoints"))
env.enableCheckpointing(1000)
// 配置重启策略
env.setRestartStrategy(RestartStrategies.fixedDelayRestart(60, Time.of(10, TimeUnit.SECONDS)))

3、Table API 与 SQL

Table API 是流处理和批处理通用的关系型 API,Table API 可以基于流输入或者批输入来运行而不需要进行任何修改。Table API 是 SQL 语言的超集并专门为 Apache Flink 设计的,Table API 是 Scala 和 Java 语言集成式的 API。与常规 SQL 语言中将查询指定为字符串不同,Table API 查询是以 Java 或 Scala 中的语言嵌入样式来定义的,具有 IDE 支持如:自动完成和语法检测。

需要引入的 pom 依赖

	org.apache.flink
	flink-table_2.11
	1.7.2

简单了解 TableAPI
def main(args: Array[String]): Unit = {
	val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
	val myKafkaConsumer: FlinkKafkaConsumer011[String] = MyKafkaUtil.getConsumer("ECOMMERCE")
	val dstream: DataStream[String] = env.addSource(myKafkaConsumer)
	val tableEnv: StreamTableEnvironment = TableEnvironment.getTableEnvironment(env)
	val ecommerceLogDstream: DataStream[EcommerceLog] = dstream
		.map{jsonString => JSON.parseObject(jsonString,classOf[EcommerceLog]) }
	val ecommerceLogTable: Table = tableEnv.fromDataStream(ecommerceLogDstream)
	val table: Table = ecommerceLogTable.select("mid,ch").filter("ch='appstore'")
	val midchDataStream: DataStream[(String, String)] = table.toAppendStream[(String,String)]
	midchDataStream.print()
	env.execute()
}
动态表

如果流中的数据类型是 case class 可以直接根据 case class 的结构生成 table:

tableEnv.fromDataStream(ecommerceLogDstream)

或者根据字段顺序单独命名:

tableEnv.fromDataStream(ecommerceLogDstream,’mid,’uid .......)

最后的动态表可以转换为流进行输出:

table.toAppendStream[(String,String)]
字段

用一个单引放到字段前面来标识字段名, 如 ‘name , ‘mid ,’amount 等。

TableAPI 的窗口聚合操作
// 每 10 秒中渠道为 appstore 的个数
def main(args: Array[String]): Unit = {
	//sparkcontext
	val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
	// 时间特性改为 eventTime
	env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
	val myKafkaConsumer: FlinkKafkaConsumer011[String] = MyKafkaUtil.getConsumer("ECOMMERCE")
	val dstream: DataStream[String] = env.addSource(myKafkaConsumer)
	val ecommerceLogDstream: DataStream[EcommerceLog] = dstream.map{ jsonString => JSON.parseObject(jsonString,classOf[EcommerceLog]) }
	// 告知 watermark  和 eventTime 如何提取
	val ecommerceLogWithEventTimeDStream: DataStream[EcommerceLog] = ecommerceLogDstream.assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor[EcommerceLog](Time.seconds(0L)) {
		override def extractTimestamp(element: EcommerceLog): Long = {
			element.ts
		}
	}).setParallelism(1)
	val tableEnv: StreamTableEnvironment = TableEnvironment.getTableEnvironment(env)
	// 把数据流转化成 Table
	val ecommerceTable: Table = tableEnv.fromDataStream(ecommerceLogWithEventTimeDStream , 'mid,'uid,'appid,'area,'os,'ch,'logType,'vs,'logDate,'logHour,'logHourMinute,'ts.rowtime)
	// 通过 table api  进行操作
	// 每 10 秒 统计一次各个渠道的个数 table api  解决
	// 1 groupby 2  要用 window 3  用 eventtime 来确定开窗时间
	val resultTable: Table = ecommerceTable.window(Tumble over 10000.millis on'ts as 'tt).groupBy('ch,'tt ).select( 'ch, 'ch.count)
	// 把 Table 转化成数据流
	val resultDstream: DataStream[(Boolean, (String, Long))] = resultSQLTable.toRetractStream[(String,Long)]
	resultDstream.filter(_._1).print()
	env.execute()
}
关于 group by

如果了使用 groupby,table 转换为流的时候只能用 toRetractDstream:

val rDstream: DataStream[(Boolean, (String, Long))] = table
	.toRetractStream[(String,Long)]

toRetractDstream 得到的第一个 boolean 型字段标识 true 就是最新的数据
(Insert),false 表示过期老数据(Delete)

val rDstream: DataStream[(Boolean, (String, Long))] = table
	.toRetractStream[(String,Long)]
rDstream.filter(_._1).print()

如果使用的 api 包括时间窗口,那么窗口的字段必须出现在 groupBy 中。

val table: Table = ecommerceLogTable
	.filter("ch ='appstore'")
	.window(Tumble over 10000.millis on 'ts as 'tt)
	.groupBy('ch ,'tt)
	.select("ch,ch.count ")
关于时间窗口

用到时间窗口,必须提前声明时间字段,如果是 processTime 直接在创建动态表时进行追加就可以。

val ecommerceLogTable: Table = tableEnv
	.fromDataStream( ecommerceLogWithEtDstream, 'mid,'uid,'appid,'area,'os,'ch,'logType,'vs,'logDate,'logHour,'logHourMinute,'ps.proctime)

如果是 EventTime 要在创建动态表时声明:

val ecommerceLogTable: Table = tableEnv
	.fromDataStream(ecommerceLogWithEtDstream, 'mid,'uid,'appid,'area,'os,'ch,'logType,'vs,'logDate,'logHour,'logHourMinute,'ts.rowtime)

滚动窗口可以使用 Tumble over 10000.millison 来表示:

val table: Table = ecommerceLogTable.filter("ch ='appstore'")
	.window(Tumble over 10000.millis on 'ts as 'tt)
	.groupBy('ch ,'tt)
	.select("ch,ch.count ")
SQL 如何编写
def main(args: Array[String]): Unit = {
	//sparkcontext
	val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
	// 时间特性改为 eventTime
	env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
	val myKafkaConsumer: FlinkKafkaConsumer011[String] = MyKafkaUtil.getConsumer("ECOMMERCE")
	val dstream: DataStream[String] = env.addSource(myKafkaConsumer)
	val ecommerceLogDstream: DataStream[EcommerceLog] = dstream.map{ jsonString => JSON.parseObject(jsonString,classOf[EcommerceLog]) }
	// 告知 watermark  和 eventTime 如何提取
	val ecommerceLogWithEventTimeDStream: DataStream[EcommerceLog] = ecommerceLogDstream.assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor[EcommerceLog](Time.seconds(0L)) {
		override def extractTimestamp(element: EcommerceLog): Long = {
			element.ts
		}
	}).setParallelism(1)
	
	// SparkSession
	val tableEnv: StreamTableEnvironment = TableEnvironment.getTableEnvironment(env)
	// 把数据流转化成 Table
	val ecommerceTable: Table = tableEnv.fromDataStream(ecommerceLogWithEventTimeDStream , 'mid,'uid,'appid,'area,'os,'ch,'logType,'vs,'logDate,'logHour,'logHourMinute,'ts.rowtime)
	// 通过 table api 进行操作
	// 每 10 秒 统计一次各个渠道的个数 table api  解决
	// 1 groupby 2  要用 window 3  用 eventtime 来确定开窗时间
	val resultTable: Table = ecommerceTable.window(Tumble over 10000.millis on 'ts as 'tt).groupBy('ch,'tt ).select( 'ch, 'ch.count)
	//  通过 sql  进行操作
	val resultSQLTable : Table = tableEnv.sqlQuery( "select ch ,count(ch) from "+ ecommerceTable +" group by ch ,Tumble(ts,interval '10' SECOND )")
	// 把 Table 转化成数据流
	val appstoreDStream: DataStream[(String, String, Long)] = appstoreTable.toAppendStream[(String,String,Long)]
	val resultDstream: DataStream[(Boolean, (String, Long))] = resultSQLTable.toRetractStream[(String,Long)]
	resultDstream.filter(_._1).print()
	env.execute()
}

4、Flink CEP

什么是复杂事件处理 CEP

一个或多个由简单事件构成的事件流通过一定的规则匹配,然后输出用户想得到的数据,满足规则的复杂事件。
特征:

  • 目标:从有序的简单事件流中发现一些高阶特征
  • 输入:一个或多个由简单事件构成的事件流
  • 处理:识别简单事件之间的内在联系,多个符合一定规则的简单事件构成
    复杂事件
  • 输出:满足规则的复杂事件
    【Flink】Flink 中的 ProcessFunction API 和 状态一致性保证_第15张图片

CEP 用于分析低延迟、频繁产生的不同来源的事件流。CEP 可以帮助在复杂的、不相关的事件流中找出有意义的模式和复杂的关系,以接近实时或准实时的获得通知并阻止一些行为。
CEP 支持在流上进行模式匹配,根据模式的条件不同,分为连续的条件或不连续的条件;模式的条件允许有时间的限制,当在条件范围内没有达到满足的条件时,会导致模式匹配超时。
看起来很简单,但是它有很多不同的功能:

  • 输入的流数据,尽快产生结果
  • 在 2 个 event 流上,基于时间进行聚合类的计算
  • 提供实时/准实时的警告和通知
  • 在多样的数据源中产生关联并分析模式
  • 高吞吐、低延迟的处理

市场上有多种 CEP 的解决方案,例如 Spark、Samza、Beam 等,但他们都没有提供专门的 library 支持。但是 Flink 提供了专门的 CEP library。

Flink CEP

Flink 为 CEP 提供了专门的 Flink CEP library,它包含如下组件:

  • Event Stream
  • pattern 定义
  • pattern 检测
  • 生成 Alert

【Flink】Flink 中的 ProcessFunction API 和 状态一致性保证_第16张图片
首先,开发人员要在 DataStream 流上定义出模式条件,之后 Flink CEP 引擎进行模式检测,必要时生成告警。
为了使用 Flink CEP,我们需要导入依赖:


	org.apache.flink
	flink-cep_${scala.binary.version}
	${flink.version}

Event Streams

以登陆事件流为例:

case class LoginEvent(userId: String, ip: String, eventType: String, eventTime: String)

val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
env.setParallelism(1)

val loginEventStream = env.fromCollection(List(
	LoginEvent("1", "192.168.0.1", "fail", "1558430842"),
	LoginEvent("1", "192.168.0.2", "fail", "1558430843"),
	LoginEvent("1", "192.168.0.3", "fail", "1558430844"),
	LoginEvent("2", "192.168.10.10", "success", "1558430845")
)).assignAscendingTimestamps(_.eventTime.toLong)
Pattern API

每个 Pattern 都应该包含几个步骤,或者叫做 state。从一个 state 到另一个 state,通常我们需要定义一些条件,例如下列的代码:

val loginFailPattern = Pattern.begin[LoginEvent]("begin")
	.where(_.eventType.equals("fail"))
	.next("next")
	.where(_.eventType.equals("fail"))
	.within(Time.seconds(10)

每个 state 都应该有一个标示:例如 .beginLoginEvent 中的
“begin”,每个 state 都需要有一个唯一的名字,而且需要一个 filter 来过滤条件,这个过滤条件定义事件需要符合的条件,例如:

.where(_.eventType.equals("fail"))

我们也可以通过 subtype 来限制 event 的子类型:

start.subtype(SubEvent.class).where(...);

事实上,你可以多次调用 subtype 和 where 方法;而且如果 where 条件是不相关的,你可以通过 or 来指定一个单独的 filter 函数:

pattern.where(...).or(...);

之后,我们可以在此条件基础上,通过 next 或者 followedBy 方法切换到下一个 state,next 的意思是说上一步符合条件的元素之后紧挨着的元素;而 followedBy 并不要求一定是挨着的元素。这两者分别称为严格近邻和非严格近邻。

val strictNext = start.next("middle")
val nonStrictNext = start.followedBy("middle")

最后,我们可以将所有的 Pattern 的条件限定在一定的时间范围内:

next.within(Time.seconds(10))

这个时间可以是 Processing Time,也可以是 Event Time。

Pattern 检测

通过一个 input DataStream 以及刚刚我们定义的 Pattern,我们可以创建一个PatternStream:

val input = ...
val pattern = ...
val patternStream = CEP.pattern(input, pattern)
val patternStream = CEP.pattern(loginEventStream.keyBy(_.userId), loginFailPattern)

一旦获得 PatternStream,我们就可以通过 select 或 flatSelect,从一个 Map 序列找到我们需要的警告信息。

select

select 方法需要实现一个 PatternSelectFunction,通过 select 方法来输出需要的警告。它接受一个 Map 对,包含 string/event,其中 key 为 state 的名字,event 则为真实的 Event。

val loginFailDataStream = patternStream
	.select((pattern: Map[String, Iterable[LoginEvent]]) => {
		val first = pattern.getOrElse("begin", null).iterator.next()
		val second = pattern.getOrElse("next", null).iterator.next()
		Warning(first.userId, first.eventTime, second.eventTime, "warning")
	})

其返回值仅为 1 条记录。

flatSelect

通过实现 PatternFlatSelectFunction,实现与 select 相似的功能。唯一的区别就是 flatSelect 方法可以返回多条记录,它通过一个 Collector[OUT]类型的参数来将要输出的数据传递到下游。

超时事件的处理

通过 within 方法,我们的 parttern 规则将匹配的事件限定在一定的窗口范围内。当有超过窗口时间之后到达的 event,我们可以通过在 select 或 flatSelect 中,实现 PatternTimeoutFunction 和 PatternFlatTimeoutFunction 来处理这种情况。

val patternStream: PatternStream[Event] = CEP.pattern(input, pattern)
val outputTag = OutputTag[String]("side-output")
val result: SingleOutputStreamOperator[ComplexEvent] = patternStream
	.select(outputTag){
		(pattern: Map[String, Iterable[Event]], timestamp: Long) => TimeoutEvent()
	} {
		pattern: Map[String, Iterable[Event]] => ComplexEvent()
	}
val timeoutResult: DataStream = result.getSideOutput(outputTag)

5、常见面试问题汇总

面试题一:应用架构

问题:公司怎么提交的实时任务,有多少 Job Manager?
解答:
1、我们使用 yarn session 模式提交任务。每次提交都会创建一个新的 Flink 集群,为每一个 job 提供一个 yarn-session,任务之间互相独立,互不影响,方便管理。任务执行完成之后创建的集群也会消失。线上命令脚本如下:

bin/yarn-session.sh -n 7 -s 8 -jm 3072 -tm 32768 -qu root.*.* -nm *-* -d

其中申请 7 个 taskManager,每个 8 核,每个 taskmanager 有 32768M 内存。
2、集群默认只有一个 Job Manager。但为了防止单点故障,我们配置了高可用。我们公司一般配置一个主 Job Manager,两个备用 Job Manager,然后结合 ZooKeeper 的使用,来达到高可用。

面试题二:压测和监控

问题:怎么做压力测试和监控?
解答:我们一般碰到的压力来自以下几个方面:
1、产生数据流的速度如果过快,而下游的算子消费不过来的话,会产生背压。背压的监控可以使用 Flink Web UI(localhost:8081) 来可视化监控,一旦报警就能知道。一般情况下背压问题的产生可能是由于 sink 这个 操作符没有优化好,做一下优化就可以了。比如如果是写入 ElasticSearch, 那么可以改成批量写入,可以调大 ElasticSearch 队列的大小等等策略。
2、设置 watermark 的最大延迟时间这个参数,如果设置的过大,可能会造成内存的压力。可以设置最大延迟时间小一些,然后把迟到元素发送到侧输出流中去。晚一点更新结果。或者使用类似于 RocksDB 这样的状态后端, RocksDB 会开辟堆外存储空间,但 IO 速度会变慢,需要权衡。
3、还有就是滑动窗口的长度如果过长,而滑动距离很短的话,Flink 的性能
会下降的很厉害。我们主要通过时间分片的方法,将每个元素只存入一个“重叠窗口”,这样就可以减少窗口处理中状态的写入。参见链接:
https://www.infoq.cn/article/sIhs_qY6HCpMQNblTI9M
4、状态后端使用 RocksDB,还没有碰到被撑爆的问题。

面试题三:为什么用 Flink

问题:为什么使用 Flink 替代 Spark?
解答:主要考虑的是 flink 的低延迟、高吞吐量和对流式数据应用场景更好的支持;另外,flink 可以很好地处理乱序数据,而且可以保证 exactly-once 的状态一致性。

面试题四:checkpoint 的存储

问题:Flink 的 checkpoint 存在哪里?
解答:可以是内存,文件系统,或者 RocksDB。

面试题五:exactly-once 的保证

问题:如果下级存储不支持事务,Flink 怎么保证 exactly-once?
解答:端到端的 exactly-once 对 sink 要求比较高,具体实现主要有幂等写入和事务性写入两种方式。幂等写入的场景依赖于业务逻辑,更常见的是用事务性写入。而事务性写入又有预写日志(WAL)和两阶段提交(2PC)两种方式。如果外部系统不支持事务,那么可以用预写日志的方式,把结果数据先当成状态保存,然后在收到 checkpoint 完成的通知时,一次性写入 sink 系统。

面试题六:状态机制

问题:说一下 Flink 状态机制?
解答:Flink 内置的很多算子,包括源 source,数据存储 sink 都是有状态的。在 Flink 中,状态始终与特定算子相关联。Flink 会以 checkpoint 的形式对各个任务的状态进行快照,用于保证故障恢复时的状态一致性。Flink 通过状态后端来管理状态和 checkpoint 的存储,状态后端可以有不同的配置选择。

面试题七:海量 key 去重

问题:怎么去重?考虑一个实时场景:双十一场景,滑动窗口长度为 1 小时,滑动距离为 10 秒钟,亿级用户,怎样计算 UV?
解答:使用类似于 scala 的 set 数据结构或者 redis 的 set 显然是不行的,
因为可能有上亿个 Key,内存放不下。所以可以考虑使用布隆过滤器(Bloom Filter)来去重。

面试题八:checkpoint 与 spark 比较

问题:Flink 的 checkpoint 机制对比 spark 有什么不同和优势?
解答:spark streaming 的 checkpoint 仅仅是针对 driver 的故障恢复做了数据和元数据的 checkpoint。而 flink 的 checkpoint 机制 要复杂了很多,它采用的是轻量级的分布式快照,实现了每个算子的快照,及流动中的数据的快照。

面试题九:watermark 机制

问题:请详细解释一下 Flink 的 Watermark 机制。
解答:Watermark 本质是 Flink 中衡量 EventTime 进展的一个机制,主要用来处理乱序数据。

面试题十:exactly-once 如何实现

问题:Flink 中 exactly-once 语义是如何实现的,状态是如何存储的?
解答:Flink 依靠 checkpoint 机制来实现 exactly-once 语义,如果要实现端到端的 exactly-once,还需要外部 source 和 sink 满足一定的条件。状态的存储通过状态后端来管理,Flink 中可以配置不同的状态后端。

面试题十一:CEP

问题:Flink CEP 编程中当状态没有到达的时候会将数据保存在哪里?
解答:在流式处理中,CEP 当然是要支持 EventTime 的,那么相对应的也要支持数据的迟到现象,也就是 watermark 的处理逻辑。CEP 对未匹配成功的事件序列的处理,和迟到数据是类似的。在 Flink CEP 的处理逻辑中,状态没有满足的和迟到的数据,都会存储在一个 Map 数据结构中,也就是说,如果我们限定判断事件序列的时长为 5 分钟,那么内存中就会存储 5 分钟的数据,这在我看来,也是对内存的极大损伤之一。

面试题十二:三种时间语义

问题:Flink 三种时间语义是什么,分别说出应用场景?
解答:
1、Event Time:这是实际应用最常见的时间语义。
2、Processing Time:没有事件时间的情况下,或者对实时性要求超高的情况下。
3、Ingestion Time:存在多个 Source Operator 的情况下,每个 Source Operator 可以使用自己本地系统时钟指派 Ingestion Time。后续基于时间相关的各种操作,都会使用数据记录中的 Ingestion Time。

面试题十三:数据高峰的处理

问题:Flink 程序在面对数据高峰期时如何处理?
解答:使用大容量的 Kafka 把数据先放到消息队列里面作为数据源,再使用 Flink 进行消费,不过这样会影响到一点实时性。

你可能感兴趣的:(BigData,BigData,Components)