Spark与Flink
Flink是可扩展的批处理和流式数据处理的数据处理平台,支持增量迭代计算。Flink在进行集合的迭代转换时可以是循环或是迭代计算处理,这使得Join算法、对分区的链接和重用以及排序可以选择最优算法。Flink的流式处理是真正的流处理,流式数据一旦进入就实时进行处理,允许流数据灵活地在操作窗口。它甚至可以在使用水印的流数中处理数据。此外,Flink的代码执行引擎还对现有使用Storm,MapReduce等有很强的兼容性。
Spark是一种快速、通用的计算集群系统,Spark提出的最主要抽象概念是弹性分布式数据集(RDD),它是一个元素集合,划分到集群的各个节点上,可以被并行操作。用户也可以让Spark保留一个RDD在内存中,使其能在并行操作中被有效的重复使用。
流式计算比较
它们都支持流式计算,Flink是一行一行处理,而Spark是基于数据片集合(RDD)进行小批量处理,所以Spark在流式处理方面,不可避免增加一些延时。Flink的流式计算跟Storm性能差不多,支持毫秒级计算,而Spark则只能支持秒级计算。
与Hadoop兼容
- 计算的资源调度都支持YARN的方式
- 数据存取都支持HDFS、HBase等数据源
- Flink对Hadoop有着更好的兼容,如可以支持原生HBase的TableMapper和TableReducer,唯一不足是现在只支持老版本的MapReduce方法,新版本的MapReduce方法无法得到支持,Spark则不支持TableMapper和TableReducer这些方法
SQL支持
都支持,Spark对SQL的支持比Flink支持的范围要大一些,另外Spark支持对SQL的优化,而Flink支持主要是对API级的优化。
计算迭代
delta-iterations,这是Flink特有的,在迭代中可以显著减少计算
Spark Streaming与Flink
本文从应用场景、编程模型、任务调度、时间机制、Kafka 动态分区的感知、容错及处理语义、背压等几个方面对比 Spark Streaming与 Flink。
应用场景
Spark Streaming以微批的形式开创了流处理,使用场景:允许一点延迟,批量处理,吞吐量优先的场景。Flink很适合实时性高的场景了。以一些具体场景而言,适用于Flink:
1.全局去重,全局聚合操作,比如distinct ,uv等业务场景。
Flink适合,Spark Streaming做起来比较麻烦,后者要借助状态算子或者第三方存储,比如redis,alluxio等。
2.开窗操作且要求同一个窗口多次输出。这个可以用Flink的trigger,Spark Streaming比较麻烦。
3.仅一次处理。Spark Streaming实现仅一次处理大部分都是依赖于输出端的幂等性。
而Flink,可以通过其分布式checkpoint的性质结合sink的事物来实现,也即分布式两段提交协议。
当然,Flink也可以利用sink的幂等性来实现仅一次处理。
4.更容易实现ddl,dml等完整的sql支持,进而实现完全sql实现业务开发,类似blink。
Spark Streaming需要微批rdd转化为表,也是一个临时小表,不是全局的。
运行角色
Spark Streaming 运行时的角色(standalone 模式)主要有:
- Master:主要负责整体集群资源的管理和应用程序调度
- Worker:负责单个节点的资源管理,driver 和 executor 的启动等
- Driver:用户入口程序执行的地方,即 SparkContext 执行的地方,主要是 DAG 生成、stage 划分、task 生成及调度
- Executor:负责执行 task,反馈执行状态和执行结果
Flink 运行时的角色(standalone 模式)主要有:
- Jobmanager: 协调分布式执行,他们调度任务、协调 checkpoints、协调故障恢复等。至少有一个 JobManager。高可用情况下可以启动多个 JobManager,其中一个选举为 leader,其余为 standby
- Taskmanager: 负责执行具体的 tasks、缓存、交换数据流,至少有一个 TaskManager
- Slot: 每个 task slot 代表 TaskManager 的一个固定部分资源,Slot 的个数代表着 taskmanager 可并行执行的 task 数
运行模型
Spark Streaming 是微批处理,运行的时候需要指定批处理的时间,每次运行 job 时处理一个批次的数据。Flink 是基于事件驱动的,事件可以理解为消息。事件驱动的应用程序是一种状态应用程序,它会从一个或者多个流中注入事件,通过触发计算更新状态,或外部动作对注入的事件作出反应。
任务调度原理
Spark Streaming 任务是基于微批处理的,实际上每个批次都是一个 Spark Core 的任务,对于 job 的调度执行有 fifo 和 fair 两种模式。对于编码完成的 Spark Core 任务在生成到最终执行结束主要包括以下几个部分:
- 构建 DAG 图
- 划分 stage
- 生成 taskset
- 调度 task

对于 Flink的流任务客户端首先会生成 StreamGraph,接着生成 JobGraph,然后将 jobGraph 提交给 Jobmanager 由它完成 jobGraph 到 ExecutionGraph 的转变,最后由 jobManager 调度执行。
Flink 的拓扑生成提交执行之后,除非故障,否则拓扑部件执行位置不变,并行度由每一个算子并行度决定,类似于 storm。而 spark Streaming 是每个批次都会根据数据本地性和资源情况进行调度,无固定的执行拓扑结构。 flink 是数据在拓扑结构里流动执行,而 Spark Streaming 则是对数据缓存批次并行处理。
时间机制对比
流处理的时间
流处理程序在时间概念上总共有三个时间概念:
- 处理时间
处理时间是指每台机器的系统时间,当流程序采用处理时间时将使用运行各个运算符实例的机器时间。处理时间是最简单的时间概念,不需要流和机器之间的协调,它能提供最好的性能和最低延迟。然而在分布式和异步环境中,处理时间不能提供消息事件的时序性保证,因为它受到消息传输延迟,消息在算子之间流动的速度等方面制约。 - 事件时间
事件时间是指事件在其设备上发生的时间,这个时间在事件进入 flink 之前已经嵌入事件,然后 flink 可以提取该时间。基于事件时间进行处理的流程序可以保证事件在处理的时候的顺序性,但是基于事件时间的应用程序必须要结合 watermark 机制。基于事件时间的处理往往有一定的滞后性,因为它需要等待后续事件和处理无序事件,对于时间敏感的应用使用的时候要慎重考虑。 - 注入时间
注入时间是事件注入到 flink 的时间。事件在 source 算子处获取 source 的当前时间作为事件注入时间,后续的基于时间的处理算子会使用该时间处理数据。相比于事件时间,注入时间不能够处理无序事件或者滞后事件,但是应用程序无序指定如何生成 watermark。在内部注入时间程序的处理和事件时间类似,但是时间戳分配和 watermark 生成都是自动的。
下图可以清晰地看出三种时间的区别:

Spark 时间机制
Spark Streaming 只支持处理时间,Structured streaming 支持处理时间和事件时间,同时支持 watermark 机制处理滞后数据。
Flink 时间机制
Flink 支持三种时间机制:事件时间,注入时间,处理时间,同时支持 watermark 机制处理滞后数据。
kafka 动态分区检测
Spark Streaming
对于有实时处理业务需求的企业,随着业务增长数据量也会同步增长,将导致原有的 kafka 分区数不满足数据写入所需的并发度,需要扩展 kafka 的分区或者增加 kafka 的 topic,这时就要求实时处理程序,如 SparkStreaming、flink 能检测到 kafka 新增的 topic 、分区及消费新增分区的数据。
接下来结合源码分析,Spark Streaming 和 flink 在 kafka 新增 topic 或 partition 时能否动态发现新增分区并消费处理新增分区的数据。 Spark Streaming 与 kafka 结合有两个区别比较大的版本,如图 9 所示是官网给出的对比数据:

其中确认的是 Spark Streaming 与 kafka 0.8 版本结合不支持动态分区检测,与 0.10 版本结合支持,接着通过源码分析。
Spark Streaming 与 kafka 0.8 版本结合
*源码分析只针对分区检测,入口是 DirectKafkaInputDStream 的 compute:
// 改行代码会计算这个job,要消费的每个kafka分区的最大偏移
override def compute(validTime: Time): Option[KafkaRDD[K, V, U, T, R]] = {
// 构建KafkaRDD,用指定的分区数和要消费的offset范围
val untilOffsets = clamp(latestLeaderOffsets(maxRetries))
val rdd = KafkaRDD[K, V, U, T, R](
// Report the record number and metadata of this batch interval to InputInfoTracker.
context.sparkContext, kafkaParams, currentOffsets, untilOffsets, messageHandler)
val offsetRanges = currentOffsets.map { case (tp, fo) =>
val uo = untilOffsets(tp)
OffsetRange(tp.topic, tp.partition, fo, uo.offset)
}
val description = offsetRanges.filter { offsetRange =>
// Don't display empty ranges.
offsetRange.fromOffset != offsetRange.untilOffset
}.map { offsetRange =>
// Copy offsetRanges to immutable.List to prevent from being modified by the user
s"topic: ${offsetRange.topic}\tpartition: ${offsetRange.partition}\t" +
s"offsets: ${offsetRange.fromOffset} to ${offsetRange.untilOffset}"
}.mkString("\n")
val metadata = Map("offsets" -> offsetRanges.toList,
StreamInputInfo.METADATA_KEY_DESCRIPTION -> description)
val inputInfo = StreamInputInfo(id, rdd.count, metadata)
ssc.scheduler.inputInfoTracker.reportInfo(validTime, inputInfo)
currentOffsets = untilOffsets.map(kv => kv._1 -> kv._2.offset)
Some(rdd)
}
第一行就是计算得到该批次生成 KafkaRDD 每个分区要消费的最大 offset。 接着看 latestLeaderOffsets(maxRetries)
// 可以看到的是用来指定获取最大偏移分区的列表还是只有currentOffsets,没有发现关于新增的分区的内容。
@tailrec protected final def latestLeaderOffsets(retries: Int): Map[TopicAndPartition, LeaderOffset] = {
val o = kc.getLatestLeaderOffsets(currentOffsets.keySet)
// Either.fold would confuse @tailrec, do it manually
if (o.isLeft) {
val err = o.left.get.toString
if (retries <= 0) {
throw new SparkException(err)
} else {
logError(err)
Thread.sleep(kc.config.refreshLeaderBackoffMs)
latestLeaderOffsets(retries - 1)
}
} else {
o.right.get
}
}
其中 protected var currentOffsets = fromOffsets,这个仅仅是在构建 DirectKafkaInputDStream 的时候初始化,并在 compute 里面更新:
currentOffsets = untilOffsets.map(kv => kv._1 -> kv._2.offset)
中间没有检测 kafka 新增 topic 或者分区的代码,所以可以确认 Spark Streaming 与 kafka 0.8 的版本结合不支持动态分区检测。
Spark Streaming 与 kafka 0.10 版本结合
入口同样是 DirectKafkaInputDStream 的 compute 方法,捡主要的部分说,Compute 里第一行也是计算当前 job 生成 kafkardd 要消费的每个分区的最大 offset:
// 获取当前生成job,要用到的KafkaRDD每个分区最大消费偏移值
val untilOffsets = clamp(latestOffsets())
具体检测 kafka 新增 topic 或者分区的代码在 latestOffsets()
/**
* Returns the latest (highest) available offsets, taking new partitions into account.
*/
protected def latestOffsets(): Map[TopicPartition, Long] = {
val c = consumer
paranoidPoll(c) // 获取所有的分区信息
// make sure new partitions are reflected in currentOffsets
val parts = c.assignment().asScala
// 做差获取新增的分区信息
val newPartitions = parts.diff(currentOffsets.keySet)
// position for new partitions determined by auto.offset.reset if no commit
// 新分区消费位置,没有记录的化是由auto.offset.reset决定
currentOffsets = currentOffsets ++ newPartitions.map(tp => tp -> c.position(tp)).toMap
// don't want to consume messages, so pause
c.pause(newPartitions.asJava) // find latest available offsets
c.seekToEnd(currentOffsets.keySet.asJava)
parts.map(tp => tp -> c.position(tp)).toMap
}
该方法内有获取 kafka 新增分区,并将其更新到 currentOffsets 的过程,所以可以验证 Spark Streaming 与 kafka 0.10 版本结合支持动态分区检测。
Flink
入口类是 FlinkKafkaConsumerBase,该类是所有 flink 的 kafka 消费者的父类。

在 FlinkKafkaConsumerBase 的 run 方法中,创建了 kafkaFetcher,实际上就是消费者。接下来是创建了一个线程,该线程会定期检测 kafka 新增分区,然后将其添加到 kafkaFetcher 里。上面,就是 flink 动态发现 kafka 新增分区的过程。不过与 Spark 无需做任何配置不同的是,flink 动态发现 kafka 新增分区,这个功能需要被使能的。也很简单,需要将 flink.partition-discovery.interval-millis 该属性设置为大于 0 即可。
容错机制及处理语义
对比两者在故障恢复及如何保证仅一次的处理语义。
Spark Streaming 保证仅一次处理
对于 Spark Streaming 任务,可以设置 checkpoint,然后假如发生故障并重启,可以从上次 checkpoint 之处恢复,但是这个行为只能使得数据不丢失,可能会重复处理,不能做到恰一次处理语义。
对于 Spark Streaming 与 kafka 结合的 direct Stream 可以自己维护 offset 到 zookeeper、kafka 或任何其它外部系统,每次提交完结果之后再提交 offset,这样故障恢复重启可以利用上次提交的 offset 恢复,保证数据不丢失。但是假如故障发生在提交结果之后、提交 offset 之前会导致数据多次处理,这个时候需要保证处理结果多次输出不影响正常的业务。
假设要保证数据恰一次处理语义,那么结果输出和 offset 提交必须在一个事务内完成。在这里有以下两种做法:
- repartition(1) Spark Streaming 输出的 action 变成仅一个 partition,这样可以利用事务去做:
Dstream.foreachRDD(rdd=>{
rdd.repartition(1).foreachPartition(partition=>{
// 开启事务
partition.foreach(each=>{//提交数据
}) // 提交事务
})
})
- 将结果和 offset 一起提交
结果数据包含 offset。这样提交结果和提交 offset 就是一个操作完成,不会数据丢失,也不会重复处理。故障恢复的时候可以利用上次提交结果带的 offset。
Flink 与 kafka 0.11 保证仅一次处理
若要 sink 支持仅一次语义,必须以事务的方式写数据到 Kafka,这样当提交事务时两次 checkpoint 间的所有写入操作作为一个事务被提交。这确保了出现故障或崩溃时这些写入操作能够被回滚。
在一个分布式且含有多个并发执行 sink 的应用中,仅仅执行单次提交或回滚是不够的,因为所有组件都必须对这些提交或回滚达成共识,这样才能保证得到一致性的结果。Flink 使用两阶段提交协议以及预提交(pre-commit)阶段来解决这个问题。本例中的 Flink 应用包含以下组件:
- 一个source,从Kafka中读取数据(即KafkaConsumer)
- 一个时间窗口化的聚会操作
- 一个sink,将结果写回到Kafka(即KafkaProducer)

下面详细讲解 flink 的两段提交思路:

如图所示,Flink checkpointing 开始时便进入到 pre-commit 阶段。具体来说,一旦 checkpoint 开始,Flink 的 JobManager 向输入流中写入一个 checkpoint barrier ,将流中所有消息分割成属于本次 checkpoint 的消息以及属于下次 checkpoint 的,barrier 也会在操作算子间流转。对于每个 operator 来说,该 barrier 会触发 operator 状态后端为该 operator 状态打快照。data source 保存了 Kafka 的 offset,之后把 checkpoint barrier 传递到后续的 operator。
这种方式仅适用于 operator 仅有它的内部状态。内部状态是指 Flink state backends 保存和管理的内容(如第二个 operator 中 window 聚合算出来的 sum)。
当一个进程仅有它的内部状态的时候,除了在 checkpoint 之前将需要将数据更改写入到 state backend,不需要在预提交阶段做其他的动作。在 checkpoint 成功的时候,Flink 会正确的提交这些写入,在 checkpoint 失败的时候会终止提交,过程如图所示。

当结合外部系统的时候,外部系统必须要支持可与两阶段提交协议捆绑使用的事务。显然本例中的 sink 由于引入了 kafka sink,因此在预提交阶段 data sink 必须预提交外部事务。如下图:
当 barrier 在所有的算子中传递一遍,并且触发的快照写入完成,预提交阶段完成。所有的触发状态快照都被视为 checkpoint 的一部分,也可以说 checkpoint 是整个应用程序的状态快照,包括预提交外部状态。出现故障可以从 checkpoint 恢复。下一步就是通知所有的操作算子 checkpoint 成功。该阶段 jobmanager 会为每个 operator 发起 checkpoint 已完成的回调逻辑。
本例中 data source 和窗口操作无外部状态,因此该阶段,这两个算子无需执行任何逻辑,但是 data sink 是有外部状态的,因此,此时我们必须提交外部事务,如下图:

以上就是 flink 实现恰一次处理的基本逻辑。
Back pressure
消费者消费的速度低于生产者生产的速度,为了使应用正常,消费者会反馈给生产者来调节生产者生产的速度,以使得消费者需要多少,生产者生产多少。
Spark Streaming 的背压
Spark Streaming 跟 kafka 结合是存在背压机制的,目标是根据当前 job 的处理情况来调节后续批次的获取 kafka 消息的条数。为了达到这个目的,Spark Streaming 在原有的架构上加入了一个 RateController,利用的算法是 PID,需要的反馈数据是任务处理的结束时间、调度时间、处理时间、消息条数,这些数据是通过 SparkListener 体系获得,然后通过 PIDRateEsimator 的 compute 计算得到一个速率,进而可以计算得到一个 offset,然后跟限速设置最大消费条数比较得到一个最终要消费的消息最大 offset。
Flink 的背压
与 Spark Streaming 的背压不同的是,Flink 背压是 jobmanager 针对每一个 task 每 50ms 触发 100 次 Thread.getStackTrace() 调用,求出阻塞的占比。过程如图所示:

阻塞占比在 web 上划分了三个等级:
1、OK: 0 <= Ratio <= 0.10,表示状态良好
2、LOW: 0.10 < Ratio <= 0.5,表示有待观察
3、HIGH: 0.5 < Ratio <= 1,表示要处理了
本文对比分析了Spark与Flink在数据处理、流式计算、与Hadoop的兼容性、SQL支持和计算迭代等方面的差异。Flink提供实时流处理,支持毫秒级计算,而Spark基于微批处理,存在一定的延时。在Hadoop兼容性上,Flink表现出色,而Spark在SQL支持和迭代计算上有优势。

1888

被折叠的 条评论
为什么被折叠?



