程序员必知必会之开源流计算框架:ApacheSamza

Apache Samza

Apache Samza(简称Samza)最初是由LinkedIn开源的一款分布式流计算框架,之后贡献给Apache并最终孵化成一个顶级项目。在众多的流计算框架中,Apache Samza算得上是一个非常独特的分布式流计算框架,因此我们有必要对其进行一番研究。

系统架构

相比其他流计算框架的复杂实现,Samza的设计和实现可以说是简单到了极致。Samza与本书第3章讨论的单节点实时流计算框架有相似的设计观念,认为流计算就是从Kafka等消息中间件中取出消息,然后对消息进行处理,最后将处理结果重新发回消息中间件的过程。

Samza将流数据的管理委托给Kafka等消息中间件,再将资源管理、任务调度和分布式执行等功能借助于诸如YARN这样的分布式资源管理系统完成,其自身的主要逻辑则是专注于对流计算过程的抽象及对用户编程接口的实现。因此,Samza实现的流计算框架非常简洁,其早期版本的代码甚至不超过一万行。

以运行在YARN上的Samza为例,它就是一个典型的YARN应用。Samza的系统架构如图6-3所示。Samza的YARN客户端向YARN提交Samza作业,并从YARN集群中申请资源(主要是CPU和内存)用于执行Samza应用中的作业。Samza作业在运行时,表现为多个副本的任务。Samza任务正是流计算应用的处理逻辑所在,它们从Kafka中读取消息,然后进行处理,并最终将处理结果重新发回Kafka。

图6-3 Samza的系统架构

流的描述

Samza对流(Stream)的描述涉及作业(job)、分区(partition)、任务(task)、数据流图(dataflo.graph)、容器(container)和流应用程序(StreamApplication)等概念。

·流。流是Samza处理的对象,由具有相同格式和业务含义的消息组成。每个流可以有任意多的消费者,从流中读取消息并不会删除这个消息。我们可以选择性地将消息与一个关键字关联,用于流的分区。

Samza使用插件系统实现不同的流。例如,在Kafka中,流对应一个主题中的消息;在数据库中,流对应一个表的更新操作;在Hadoop中,流对应目录下文件的追写换行操作。在本节后面的讨论中,我们主要基于Kafka流对Samza进行讨论。

·作业。一个Samza作业代表一段对输入流进行转化并将结果写入输出流的程序。考虑到运行时的并行和水平扩展问题,Samza又对流和作业进行了切分,将流切分为一个或多个分区,并相应地将作业切分为一个或多个任务。

·分区。Samza的流和分区很明显继承自Kafka的概念。当然Sazma也对这两个概念进行了抽象和泛化。Samza的流被切分为一个或多个分区,每个分区都是一个有序的消息序列。

·任务。Samza作业又被切分为一个或多个任务。任务是作业并行化执行的单元,就像分区是流的并行化单元一样。每个任务负责处理流的一个分区。因此,任务的数量和分区的数量是完全相同的。通过YARN等资源调度器,任务被分布到YARN集群的多个节点上运行,并且所有的任务彼此之间都是完全独立运行的。如果某个任务在运行时发生故障退出了,则它会被YARN在其他地方重启,并继续处理与之前相同的那个分区。

·数据流图。将多个作业组合起来可以创建一个数据流图。数据流图描述了Samza流计算应用构成的整个系统的拓扑结构,它的边代表数据流向,而节点代表执行流转化操作的作业。与Storm中Topology不同的是,数据流图包含的各个作业并不要求一定在同一个Samza应用程序中,数据流图可以由多个不同的Samza应用程序共同构成,并且不同的Samza应用程序不会相互影响。在后面我们还会介绍流应用程序,需要注意流应用程序和数据流图的不同之处。图6-5就展示了一个同样的数据流图使用不同流应用程序组合来实现的例子。

图6-4 一个描述join操作的Samza作业

图6-5 同样的数据流图可以使用多种方法实现

·容器。前面所讲的分区和任务都是逻辑上的并行单元,它们不是对计算资源的真实划分。那什么才是对计算资源的真实划分呢?容器。

容器是物理上的并行单元,每一个容器都代表着一定配额的计算资源。每个容器可以运行一个或多个任务。任务的数量由输入流的分区数确定,而容器的数量则可以由用户在运行时任意指定。

·流应用程序。流应用程序是Samza上层API用于描述Samza流计算应用的概念。一个流应用程序对应着一个Samza应用程序,它相当于Storm中Topology的角色。如果我们将整个流计算系统各个子系统的实现都放在一个流应用程序中,那么这个流应用程序实际上就是数据流图的实现。如果我们将整个流计算系统各个子系统的实现放在多个流应用程序中,那么所有这些流应用程序共同构成完整的数据流图。

以上介绍了Samza的核心概念。这里还是需要强调下,Samza中关于作业和任务的定义与Hadoop MapReduce框架中关于作业和任务的定义完全不同。在MapReduce中,一个MaprReduce程序就是一个作业,而一个作业可以有一个或多个任务。这些任务由作业解析而来,它们又分为Map任务和Reduce任务,分别执行着不同的任务。但是在Samza中,任务相当于作业的多个运行时副本,所有任务均执行着完全相同的程序逻辑,它们仅仅是输入/输出的流分区不同而已。所以,从这种意义上来讲,Samza的作业和任务之间的关系就相当于程序和进程之间的关系。

一个程序可以起多个进程,所有这些进程都执行着相同的程序代码。

流的执行

与Storm的发展非常相似,Samza用于构建流计算应用的编程接口也经历了从底层API到上层API演进的过程,这其实也代表了流计算领域和Samza框架自身的发展历史。如果读者想更加清楚地理解框架背后的工作原理,可以详细研究底层API。上层API更加“现代”,而且更加有助于我们理解流计算这种编程模式,所以我们在本节直接使用Samza的上层API来讲解Samza流的执行过程。我们同样从流的输入、流的处理、流的输出和反向压力4个方面来讨论Samza中流的执行过程。

1.流的输入

Samza使用各种描述符来定义Samza应用的各个组成部分。以Kafka为例,Samza提供了KafkaSystemDescriptor用于描述管理数据流的Kafka集群。对每一个Kafka输入流,我们创建一个KafkaInputDescriptor用于描述该输入流的信息,然后通过Samza流应用描述符
StreamApplicationDescriptor的getInputStream方法创建消息流MessageStream。

// Create a KafkaSystemDescriptor providing properties of the cluster

KafkaSystemDescriptor kafkaSystemDescriptor = new

KafkaSystemDescriptor(KAFKA_SYSTEM_NAME)

.withConsumerZkConnect(KAFKA_CONSUMER_ZK_CONNECT)

.withProducerBootstrapServers(KAFKA_PRODUCER_BOOTSTRAP_SERVERS)

.withDefaultStreamConfigs(KAFKA_DEFAULT_STREAM_CONFIGS);

// For each input or output stream, create a KafkaInput/Output descriptor

KafkaInputDescriptor<KV<String, String>> inputDescriptor =

kafkaSystemDescriptor.getInputDescriptor(INPUT_STREAM_ID,

KVSerde.of(new StringSerde(), new StringSerde()));

// Obtain a handle to a MessageStream that you can chain operations on

MessageStream<KV<String, String>> lines =

streamApplicationDescriptor.getInputStream(inputDescriptor);

在上面的代码中,我们创建了一个从Kafka读取消息的输入流lines。Samza用键值对表示消息,其中,键代表消息的主键,通常带有业务含义,如用户ID、事件类型、产品编号等,而值代表消息的具体内容。在很多场景下,带有业务含义的键非常有用,如实现类似于Flink中KeyedStream的功能。

2.流的处理

Samza对流的处理是通过建立在MessageStream上的各种算子(Operator)完成的。MessageStream上定义的算子主要包括两类,即流数据处理类算子和流数据管理类算子。流数据处理类算子包括map、flatMap、asyncFlatMap、filter等。流数据管理类算子包括partitionBy、merge、broadcast、join和window等。

下面是对MessageStream进行处理的示例。

lines

.map(kv -> kv.value)

.flatMap(s -> Arrays.asList(s.split("\\W+")))

.window(Windows.keyedSessionWindow(

w -> w, Duration.ofSeconds(5), () -> 0, (m, prevCount) -> prevCount + 1,

new StringSerde(), new IntegerSerde()), "count")

.map(windowPane ->

KV.of(windowPane.getKey().getKey(),

windowPane.getKey().getKey() + ": " +

windowPane.getMessage().toString()))

.sendTo(counts);

在上面的例子中,先将Kafka读出的键值对消息流lines转化为由其值组成的消息流,再用flatMap将每行的文本字符串转化为单词流;然后用
Windows.keyedSessionWindow定义了一个以5秒钟为窗口进行聚合的窗口操作,这样原来的单词流会转化为以<word,count>为键值的数据流;之后,再用map将数据流转化为其输出的格式,并最终发送到Kafka,至此就完成了单词计数的功能。

3.流的输出

与输入流对应,Samza提供了KafkaOutputDescriptor用于描述将消息发送到Kafka的输出流。通过Samza流应用描述符
StreamApplicationDescriptor的getOutputStream方法,就可以创建输出消息流OutputStream。

KafkaOutputDescriptor<KV<String, String>> outputDescriptor =

kafkaSystemDescriptor.getOutputDescriptor(OUTPUT_STREAM_ID,

KVSerde.of(new StringSerde(), new StringSerde()));

OutputStream<KV<String, String>> counts = streamApplicationDescriptor.

getOutputStream(outputDescriptor);

上面代码定义了将消息发送到Kafka的输出流counts。

最后,将输入、处理和输出各部分的代码片段整合起来,我们就能得到实现单词计数功能的Samza流计算应用。

4.反向压力

Samza不支持反向压力,但是它用其他方法避免OOM,这就是Kafka的消息缓冲功能。由于Samza是直接借用Kafka来保存处理过程中的流数据的,所以即便没有反向压力功能,Samza也不会存在内存不足的问题。但我们要明白,就算躲得过初一,也躲不过十五,磁盘容量再大,时间长了,磁盘也会被不断积压的消息占满。所以,在使用Samza时,我们还是需要对Kafka的消息消费情况和积压情况进行监控。当发现消息积压时,我们应立即采取措施来处理消息积压的问题。例如,可以给下游任务分配更多的计算资源。

需要注意的是,Samza目前不支持在应用程序已经运行后修改流的分区数。在讨论完Samza的状态管理后,我们就能清楚地明白这是由Samza目前的流状态恢复机制限制造成的。在后面我们会看到更多的原因,Samza在未来非常有可能会转而采用类似于Flink那样的分布式快照方式管理状态。但到目前为止,我们还是只能通过分配更多计算资源的方式来提升Samza作业的处理性能,但同时又不能改变分区数量。一种比较好的方式是一开始就给流设置更多的分区数,如设置24个分区,这样会起24个任务,然后在程序运行的初期设置较少的容器数。当业务流量增大,或发现某个作业处理能力偏低时,就给该作业分配更多的容器资源。

另外,除了显式的多级流水线外,Samza还可能存在隐式的多级流水线。图6-6说明了这种问题。当使用reparition算子时,Samza会在内部创建一个中间流用于暂存再分区后的数据。这个中间流也使用Kafka进行传输,所以如果reparition后的操作比较慢,则还是有可能出现消息不断积压的问题。因此,在进行Kafka的监控时,务必监控这些中间流的消息积压情况。

图6-6 Samza的隐式多级流水线

流的状态

Samza支持无状态的处理和有状态的处理。无状态的处理是指在处理过程中不涉及任何状态处理相关的操作,如map、filter等操作。有状态的处理则是指在消息处理过程中需要保存一些与消息有关的状态,如计算网站每5分钟的UV(Unique Visitor)等。Samza提供了错误容忍的、可扩展的状态存储机制。

在流数据状态方面,由于Samza使用Kafka来管理其处理各个环节的数据流,所以Samza的大部分流数据状态直接保存在Kafka中。Kafka帮助Samza完成了消息的可靠性存储、流的分区、消息顺序的保证等功能。

除了保存在Kafka中的消息外,在Samza的任务节点进行诸如window、join等操作会依赖于在缓冲区中暂存一段时间窗口内的消息,我们将这类API归于流数据状态管理。在Samza的MessageStream类中,与流数据状态管理相关的API包括window、join、partitionBy。

其中partitionBy又比较特殊,它不像window、join那样主要使用内存或诸如RocksDB的本地数据库来存储状态,而是将消息流按照主键重新分区后输出到以Kafka为载体的中间流。另外,Samza还包括将两个流合并的merge操作(类似于SQL中的UNION ALL操作),这种对流的合并操作实现起来相对简单,并不涉及状态操作。

在流信息状态方面,在Samza中,流信息状态可以通过对任务状态(task state)的管理完成。虽然Samza并不阻止我们在Samz.任务中使用远程数据库来进行状态管理,但它还是极力推荐我们使用本地数据库的方式存储状态。这样做是出于对性能优化、资源隔离、故障恢复和失败重处理等多方面因素的考虑。

Samza提供了KeyValueStore接口用于状态的存储。在KeyValueStore接口背后,Samza实现了基于RocksDB的本地状态存储系统。当Samza进行状态操作时,所有的操作均直接访问本地RocksDB数据库,所以性能比跨网络远程访问的数据库高出很多,有时甚至能达到2到3个数量级的区别。另外,为了保障任务节点在其他节点重启时访问的是相同的状态数据,还会将每次写入RocksDB的操作复制一份到Kafka作为变更日志。这样,当任务在其他节点上重启时,能够从Kafka中读取并重放变更日志,恢复任务转移物理节点前RocksDB中的数据。

消息传达可靠性保证

Samza目前只提供了at-least-once级别的消息传达可靠性保证机制,但是有计划支持exactly-once级别的消息传达可靠性保证机制。

所以,到目前为止,如果我们需要实现消息的不重复处理,就应该尽量让状态的更新是幂等操作。在缺乏像Storm中消息处理追踪机制或像Spark和Flink中用到的分布式快照机制的情况下,Samza要达到诸如计数、求和不能重复的要求还是比较困难的。而像计数、求和这样的聚合计算在流计算系统中还是比较常见的需求。所以,目前Samza还不是非常适合这种对计算准确度要求非常严格的场景。笔者认为,Samza在以后实现exactly-once级别消息传达可靠性保证机制时,也会采取类似于Flink的方案,即实现状态的checkpoint机制,在此基础之上实现分布式快照管理,最终实现exactly-once级别的消息传达可靠性保证机制。

  • 8
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值