Stateful Functions (StateFun) 的出现简化了分布式有状态应用的构建,它将有状态流处理(有状态的强一致性保证)与事件驱动的 FAAS 平台(基于云原生架构带来的弹性和 Serverless 体验)结合起来。一个典型的 StateFun 应用包括两个部分:使用现代平台(kubernetes 等)部署 FAAS 服务以及一个 StateFun 集群,StateFun 集群扮演着事件驱动数据库的角色,来为 Functions 的状态和 Event 提供一致性和容错性保证。
那么,StateFun 内部是如何实现的呢?一个 StateFun 集群是如何与这些 Functions 通信呢?本篇文章就带大家深入了解一下 StateFun Runtime 的内部实现原理(文中的示例是完全部署在 AWS 上运行的)。本篇文章的主要目标就是让读者能够比较清楚地理解 StateFun Runtime 与 Functions 之间的交互,以及如何开发一个 Stateful Serverless 应用,并且能够将应用部署到类似于 GCP 或 Microsoft Azure 之类的云平台上。
这里先来看下一个示例 —— a shopping cart application(购物车应用),下图展示了这个示例中涉及到的两个 Functions、Functions 中维护的 state 以及两个 Function 之间传递的 msg 类型:
本文的示例代码见 shopping_cart,这里使用的 Python SDK 开发。
这个应用包含了两个 Function:
ItemsInCart
);NumInStock
)以及每件商品在所有用户购物车中的数量(NumReserved
);应用中的所有 Msg 都是通过逻辑地址发往相应的 Function 实例,这个逻辑地址会包含 Function Type 及 Intance ID 信息(如:cart:Kim
、inventory:socks
)。本应用中发送到 Ingress 的数据类型是 AddToCart
,它表示是一个将相应的商品加到用户的购物车中的操作,发送给 Egress 的类型是 AddToCartResult
,它表示的是这个将商品添加到用户购物车中操作的结果(可能会因为库存情况加入失败)。
这几种数据类型定义如下:
1 | syntax = "proto3"; |
Cart
Function 是用来处理 AddToCart
类型数据的,它会在应用逻辑中再触发其他的 Function,为了简化这个示例,这里在两个 Function 之间传递的数据只抽象了两种简单的数据类型:
RequestItem
:从 Cart
Function 发送到 Inventory
Function 的请求类型(用来查询商品库存);ItemReserved
:Inventory
Function 返回的结果(表示可以加到购物车的商品数量)。上面已经详细介绍了购物车应用示例的处理逻辑,这部分注重看一下 StateFun Cluster 是如何保证 Functions 状态及 msg 发送的一致性和容错的。
StateFun Runtime 是构建在 Apache Flink 之上,并且基于 Flink 的底层机制 —— co-location of state and messaging 来保证一致性和容错性。在一个 StateFun 应用中,所有 messages 的路由转发都是经过 StateFun Cluster 的,包括从 Ingress 中发送的数据、Functions 之间传输的数据以及 Function 发往 Egress 的数据。而且,Function 的 state 都是在 StateFun Cluster 中维护的,如同 Flink 应用一样,StateFun Cluster 中 messages 与 Function State 是 co-partitioned
的,所以计算都是本地 state 访问,而且都是没有任何负作用的原子操作。
这里举个例子,假设一条 target 逻辑地址为 (cart, "Kim")
的 message 经过 StateFun Cluster 路由转发,这个逻辑地址将被用做数据传输和 state 的 partition key
(对应的 Flink 作业中就是 keyby
操作中的 key
值),这样的话,StateFun Cluster 接收到的数据都具有本地 state 可用性。与 Flink 相比,StateFun 的区别在于实际的计算逻辑不会发生在 StateFun Cluster Partitions 中,而是由远程 Function Service 来触发。那么 StateFun 是如何做到将 message 路由转发到远程 Function Service、并且提供【如同 state 和计算都在一起的一致性保证的】 state 访问的呢?
StateFun Cluster Partition 与 Function 的交互使用的是一个简洁、定义优雅的 request-reply 协议,如下图所示。一旦 Cluster Partition 接收到相应的 message,就会通过 HTTP 请求根据 target 逻辑地址将其发送到相应的 target Function Service 中。请求的 body 中会包含 input events 和这个 Function 计算需要的状态信息(从本地获取),在 Function 处理完请求后,会将需要返回的结果集合及所有变化的 state 作为 Service Response 都发送回 StateFun Cluster。当 StateFun Cluster Partition 接收到 Response 后,所有的 state 变化都会被写会到本地 State 中,message 会根据 target 逻辑地址路由转发到其他 Cluster Partition 中,触发其他的 Function 调用。
在这个框架下,StateFun SDKs 如 Python SDK 以及其他语言的 SDK 都可以基于这个协议来实现;从用户的角度来看,他们部署的 Function 操作的状态都像是本地状态一样,而实际上,这些都是由 StateFun 来维护和保证的,并且通过 HTTP/gRPC 协议来交互。
StateFun Runtime 端会保证在任何时刻,每条 event(如 (cart, "Kim")
)只会进行一次触发调用,并且每个实体的触发都是串行进行的(可以理解为一个 StateFun Cluster Partition 上一个 Function 的触发操作都是串行的),如果对于一个实体来说,一个 Function
正在触发,那么新到的数据将会被缓存在 state 中,只有正在进行的触发结束后才能处理后面的请求。另外,因为请求是串行发送,它保证了每个请求都是完全隔离的,并且由于一个请求会将需要的所有信息都放在请求中,所以 Function 的触发是完全幂等的操作(这可以原生地避免 Function 在调用故障时可能会出现的一致性问题)。
关于容错机制,所有由 StateFun Cluster 管理的 Function state 会利用 Flink 原生的分布式快照机制周期性、异步地产生 Checkpoint,并且存储到 HDFS/GCS 这类的远程文件系统。这些 Checkpoint 会包含这个应用所有 Function 的全局一致性状态快照,并且包括 Ingress 中的 offset 信息和 Egress 中正在进行的事务状态信息。如果应用因为某些异常而挂掉,系统会从最新一次成功的 Checkpoint 中恢复,所有 Function 的状态信息都会被恢复、在 Checkpoint 与系统 Crash 之间的 event 也都会按照之前同样的逻辑进行处理,就好像失败从未发生一样。
在这一小节,通过上面那个购物车的示例,来看下一条真实的 event 是如何在 StateFun Cluster 与 Function 之间传递的。顾客 Kim
想将 2 双袜子(sock
)添加到其购物车中,这条 event 触发的一系列操作如下图所示:
结合上图,下面一步步来看下这条 event 的处理过程:
AddToCart("Kim", "socks", 2)
从 Ingress Partition 中发送出来 (1)
,在这个应用中,Ingress event router 配置的 Function Type 是 Cart Function
,并且使用 user ID Kim
作为 Instance ID。Function Type 和 Instance ID 它们会确定这个 event 的 target 逻辑地址((cart:Kim)
);partition B
读取到的,但是 (cart:Kim)
的地址实际上应该路由到 partition A
,因此,这条 event 会先被路由到 partition A
中 (2)
;partition A
接收到这条 event 后开始做相应的处理:(cart:Kim)
的状态信息 —— Kim 购物车中已经存在商品列表 (3)
;(cart:Kim)
为 busy
的状态,除非当前的 event 处理完,否则不会再处理其他的 event 信息(先将后面的请求其缓存起来),这样可以避免状态一致性的问题;Cart
Function Service 发送请求 (4)
,这个请求会包含 AddToCart("Kim", "socks", 2)
数据及当前 (cart:Kim)
的状态信息(这里要注意的是,每个请求的路由转发,都会将这个状态信息作为请求的一部分发送到 Function Service 中,这是一个比较有意思的设计);Cart
Function Service 在接收到数据后,会尝试查询一下库存状态(通过 Inventory
Function Service 来查询),因此,它会返回一个 target 逻辑地址为 (inventory:socks)
的 RequestItem("socks", 2)
请求。在这里,经过 Cart
Function Service 处理后的任何状态变化都会随着请求返回给 StateFun Cluster 中 (5)
;RequestItem
信息路由到其他的 Partition 上,并且将 (cart:Kim)
标记为可用状态;(inventory:socks)
的地址应该路由到 partition B
上,这里,会将对应的 event 再路由转发到 partition B
上 (6)
;partition B
接收到 RequestItem
msg 后,Runtime 将会再次按照上面类似的逻辑进行相应触发 (7)
。通过这个示例,我们可以清晰地看到一条 event 在 StateFun Cluster 中的处理流程,对于理解其内部机制很有帮助。
这里比较有意思的点是流程 2 和 6,本质上 StateFun Cluster Partition 代表的是 Flink Job 中具体执行的 Task,Stateful Function 在实现时增加了一个 Feedback Loop 支持,来使得数据流的传输不受限于 DAG 的限制,在 StateFun 中,真实的数据流还可以是有环的,这个将会在下一篇文章中给大家揭秘其内部机制。
Stateful Functions Internals: Behind the scenes of Stateful Serverless 这篇文章的最后是关于在公有云平台部署的介绍,我们就不再详述了,本文通过一个应用示例把 StateFun 内部的实现机制给大家做了一个简单的介绍,比较核心的内容都有所涉及,对于想了解 StateFun 内部原理的同学,本文应该就足以让我们有个清晰的认识。因为之前对 Flink StateFun 做过一些调研,把 StateFun 源码的核心流程简单看了一遍,在下篇文章中将会针对 StateFun 的具体实现做一个梳理,更深入地介绍一下 StateFun 的实现。
]]>StateFun 2.0 的架构与 1.0 做了非常大的变化,Function 部分已经完全与 JVM 部分解耦,Function 部分可以单独进行部署,直接部署在 FAAS 上或者直接使用 Kubernetes 启动相应的 HTTP/RPC 服务都是可以的,如下图所示:
Flink TaskManagers 从 Ingress 系统(如:kafka、kinesis 等)中接收数据,并且将它们发送给对应的 StateFul Functions 中,经过 Function 计算完后,再发送回 TM,TM 再根据 target address 信息将其发送给其他的 Function 或 Egress 系统(如:kafka、kinesis 等)。
这里先看下 StateFun 框架的几个概念:
在这套架构中,Flink Cluster 主要是做 state 一致性保证及 event 路由转发的功能,FAAS 专注于其计算(无需 care 状态存储及一致性的问题)。实际上,在这套系统下,Flink 相当于去掉了传统数据库的角色,因为 Flink 更适合用于 event 驱动的函数和服务,通过集成状态存储,保证了函数或服务间传递消息的有状态性。
在传统的数据库或者 Key/Value 存储(这里称之为 Request/Response Database)中,应用需主动发送一个查询到数据库(如 SQL via JDBC、GET/PUT via HTTP)。然而,在 StateFun 这类事件驱动数据库中,这个关系被反转了:数据库根据到达的消息来调用函数或服务。这个特性非常适合 FaaS 或者事件驱动架构的应用。
基于请求/响应数据库的应用中,数据库只负责保存状态。函数或服务间的通讯通常一个独立的服务层进行处理。相反,事件驱动数据库以紧密集成的方式既保存了状态的存储,又承担了消息的传输。
另外 StateFun 的架构还有两个优势:
StateFun 2.0 中,一个 StateFun 应用所涉及的核心组件如下图所示:
在上图中,也可以看到 Flink TaskManagers 中它的主要作用就是接收消息、管理状态、将 event 转发到不同的 Function 以及将数据通过 Egress 发送出去。
在这里,要说明的是,Function 之间并不是直接交流的,数据路由发送都有是由 TM 来操作,TM 将一条 Event 发送给一个 Function,它处理后,会将结果及 target adress 发送回 TM,再由 TM 根据 target address 发送到下游。这些 Function 所使用到的持久化状态都是在 TM 中维护,本身依赖了 Flink 的 StateBackend 及 Checkpoint 机制。
上图中的 Function Dispatcher 表示的是 Function 的部署方式,图中使用的是 Remote Function。
在前面 StateFun 所涉及的核心组件图中,Function Dispatcher 在调用函数时,函数是可以有多种部署选择的。
2.0 架构中,一个比较大的 Feature 就是支持了 Remote Function,它完全与底层 Flink 集群解耦,通过 HTTP/gRPC 与 Flink TaskManager 进行交互,如下图所示:
简单来说,Remote Functions 的意思就是函数是独立部署的,从物理上和 Flink Cluster 是分开的。Flink Task Managers 和函数之间的沟通是通过 HTTP/gRPC 请求来完成的。
其架构如下图所示:
这种部署方式就是将函数和 TaskManager 的进程部署在一个实例(Pod 或者机器)上,用不同的容器或者进程隔离开来,例如 K8S 中的 sidecar 这种模式。TaskManager 就可以和函数直接在本地通信,但也失去了 FAAS 独立扩缩的能力。
这种部署模式更加直接,函数和 TaskManagers 直接在同一个容器内,像 Stateful Functions 1.0 就是这种模式,用高的耦合度换取了高的性能,但损失了灵活性和扩展性,它本质上就完全类似于一个 Flink Streaming Job。
在介绍 StateFun 示例之前,还有两个概念,需要简单看下,那就是 Router
和 Module
,它 StateFun API 中比较核心的抽象(针对 Java SDK 而言,Python SDK 抽象得更简单)。
Router 的含义,这里可以从两个方面来理解:
keyby
操作,这里会在后面的文章详细介绍)。举一个 Java 的示例:
1 | final class AddToCartRouter implements Router<ProtobufMessages.AddToCart> { |
这里的 Address
就是前面说的 target address,它唯一表示了一个 Function,表示要发送的 Function,由两部分组成:FunctionType
指明了具体的 Function,id
表示在 Flink keyby shuffle 时的 key
值。而如果这里要发送的是 Egress 的话,直接使用 EgressIdentifier
来区分而不需要再设置 id
。
在上面的示例中,这个 Router 就指明了 Ingress 数据要发送的下游 Function 信息。
在 StateFun 中,Module 是一个用于添加核心模块的一个入口,它把 Ingress、Egress、Routers 及 Stateful Function bind 在一起。一个简单 Java 示例如下:
1 | (StatefulFunctionModule.class) |
对于一个 Module 实现,首先需要实现 StatefulFunctionModule
相关的接口,并且用 @AutoService(StatefulFunctionModule.class)
来修饰,这里使用了 Java SPI 的技术(不展开讨论),在 configure()
方法中,将这个 StateFun 应用的需要绑定的组件定义出来,组件的顺序是没有要求的(与 DataStream API 不同),内部在解析时是通过 Target Address 来确定下游的。
在一个 StateFun 应用中可以有多个 Module,用于绑定不同的组件,可以方便团队协同开发(举个例子:一个 Module 绑定一个组件模块,由不同的同学开发不同的组件模块),不过在一个 StateFun 中,只会有一个 Binder,也就是说,多个 module 最终都会被一个 Binder 连接起来。
在官方仓库中有一个 Java 的示例 —— The Greeter Example,这个示例比较简单,从 kafka 中接收 event 数据(这里可以认为是 user name),在 Function 中会记录每个 event(user)出现的次数,根据出现的次数返回相应的结果,最后将结果写出到一个 Kafka Topic 中,先来看下其 Module 的实现:
1 | (StatefulFunctionModule.class) |
StateFun 中比较核心的地方是 State 的使用,下面来看下这个示例中 Function 的实现:
1 | final class GreetStatefulFunction implements StatefulFunction { |
StateFun API 是非常简洁的,在使用 State 时,只需要通过 Persisted
注解修饰即可,否则不会保存到 Flink State 中,也就不会进行容错,在底层的实现上,它通过反射来找到一个 Function 中声明的变量信息,并将其注册到 Flink State 中,如果不通过注解修饰,就无法获取这个 State 变量。
StateFun 2.0 发布之后,其生产性可用提高很多,它已经可以完全与 JVM 解耦,并且可以很好地利用 FAAS 的扩展能力,但是底层的 state 及数据转发依然受限于 Flink Job 的限制,无法完全做到自动伸缩,在大规模数据量的场景下,其可用性及可靠性有待验证,不过 StateFun 现在还在发展中,未来也不是没有机会。
参考
]]>Kubernetes(因为首尾字母中间有 8 个字符,所以被简写成 K8s),它是一个是用于自动部署、扩展和管理容器化应用程序的工业级容器编排平台,尽管公开面世不过短短数年,但 Kubernetes 已经成为容器编排领域事实上的标准。
Kubernetes(来自希腊语,意为 “舵手” 或 “飞行员”)是由 Joe Beda、Brendan Burns 和 Craig McLuckie 创立,而后 Google 的其他几位工程师,包括 Brian Grant 和 Tim Hockin 等加盟共同研发,并由 Google 在 2014 年首次对外宣布。Kubernetes 的开发和设计都深受 Google 内部系统 Borg 的影响,事实上,它的许多顶级贡献者之前也是 Borg 系统的开发者。
2015年4月,Borg 论文《Large-scale cluster management at Google with Borg》 首次公开,有兴趣的同学可以看一下。
Kubernetes 的发展历程如下图所示(图片来自 Kubernetes Introduction),
Kubernetes 本质上是底层资源与容器间的一个抽象层,如果和单机架构类比,有点类似于分布式时代的 Linux,它旨在提供一个可预测性、可扩展性与高可用性的方法来完全管理容器化应用程序和服务的生命周期的平。简单总结起来,它具有以下几个重要特性:
iptables
或 ipvs
内建了负载均衡机制;这里我们先来看下 Kubernetes 的架构图,如下图所示(图片来自 Kubernetes Introduction):
可以看出,Kubernetes 架构是一个比较典型的二层架构和 server-client 架构:
下面分别来看下 Master 和 Node 组件内部的一些核心服务。
Master 节点主要由 API Server、Controller Manager 和 Scheduler 三个组件,以及一个用于集群状态存储的 etcd 存储服务组成:
Node 负责提供运行容器的各种依赖环境,并接受 Master 的管理。每个 Node 主要由以下几个组件构成:
cAdvisor
监控容器和节点的资源占用状况;Kubernetes 集群还依赖于一组称为 ”附件”(add-ons)的组件以提供完整的功能,它们通常是由第三方提供的特定应用程序,且托管运行于 Kubernetes 集群之上,如下图所示(图片来自 《Kubernetes 进阶实战》):
下面列出的几个附件各自为集群从不同角度引用了所需的核心功能:
前面已经了解了 Kubernetes 的架构及组件信息,这里我们来总结一下 Kubernetes 生态下一些核心概念,只有了解并理解这些概念,才能更好地使用 Kubernetes。
Kubernetes 并不直接运行容器,而是使用一个抽象的资源对象来封装一个或者多个容器,这个抽象即为 Pod,它也是 Kubernetes 的最小调度单元(可以参考 Kubernetes 指南之 POD)。用户可以通过 Kubernetes 的 Pod API 生产一个 Pod,让 Kubernetes 对这个 Pod 进行调度,也就是把它放在某一个 Kubernetes 管理的节点上运行起来。一个 Pod 简单来说是对一组容器的抽象,它里面会包含一个或多个容器。
特点:
我们知道容器的数据都是非持久化的,在容器消亡以后数据也跟着丢失,所以 Docker 提供了 Volume 机制以便将数据持久化存储。Volume 本身就是卷的概念,它是用来管理 Kubernetes 存储的,是用来声明在 Pod 中的容器可以访问的文件目录的,一个卷可以被挂载在 Pod 中一个或者多个容器的指定路径下面。
而 Volume 本身是一个抽象的概念,一个 Volume 可以去支持多种的后端的存储。比如说 Kubernetes 的 Volume 就支持了很多存储插件,它可以支持本地的存储,可以支持分布式的存储,比如说像 ceph,GlusterFS ;它也可以支持云存储,比如说阿里云上的云盘、AWS 上的云盘、Google 上的云盘等等(在资源描述文件的配置方式参考 Kubernetes 指南之 Volume)。
ReplicaSet(也简称为 RS,K8s 之前的版本这个功能叫做 Replication Controller)用来确保容器应用的副本数始终保持在用户定义的副本数,即如果有容器异常退出,会自动创建新的 Pod 来替代;而异常多出来的容器也会自动回收(这些都是由 Master 端的 Controller Manager 来做的)。ReplicaSet 的典型应用场景包括确保健康 Pod 的数量、弹性伸缩、滚动升级以及应用多版本发布跟踪等。
资源配置文件的使用示例,参考 Kubernetes 指南之 ReplicaSet 示例。
Deployment 为 Pod 和 ReplicaSet 提供了一个声明式定义 (declarative) 方法,用来替代以前的 ReplicationController 或 ReplicaSet 来更方便的管理应用。
Deployment 是在 Pod 这个抽象上更为上层的一个抽象,它可以定义一组 Pod 的副本数目、以及这个 Pod 的版本,一般大家用 Deployment 这个抽象来做应用的真正的管理,而 Pod 是组成 Deployment 最小的单元。
比如说我可以定义一个 Deployment,这个 Deployment 里面需要两个 Pod,当一个 Pod 失败的时候,控制器就会监测到,它重新把 Deployment 中的 Pod 数目从一个恢复到两个,通过再去新生成一个 Pod。通过控制器,我们也会帮助完成发布的策略。比如说进行滚动升级,进行重新生成的升级,或者进行版本的回滚。
Deployment 的资源配置声明及相关的操作命令参考:Kubernetes 指南之 Deployment。
一个简单的、3 副本的 nginx 应用的资源配置文件可以定义为:
1 | apiVersion: apps/v1 |
Service 是对一组提供相同功能的 Pods 的抽象,并为它们提供一个统一的入口,借助 Service,应用可以方便的实现服务发现与负载均衡,并实现应用的零宕机升级,Service 通过标签来选取服务后端,一般配合 ReplicaSet(简称 RS)或者 Deployment 来保证后端容器的正常运行。这些匹配标签的 Pod IP 和端口列表组成 endpoints,由 kube-proxy 负责将服务 IP 负载均衡到这些 endpoints 上,如下图所示(图片来自 Overview of a Service):
关于 Service,个人的理解是,它只是一种抽象,通过 label(资源标签)绑定到对应的 RC 和 Deployment 上,它是不会创建 Pod 的,Pod 还是由 RS 或 Deployment 创建的。下面是一个示例,这个 Service 将服务的 80 端口转发到 default namespace 中带有标签 run=nginx
的 Pod 的 80 端口上。
1 | apiVersion: v1 |
Namespace 是对一组资源和对象的抽象集合(Kubernetes 指南之 Namespace),比如可以用来将系统内部的对象划分为不同的项目组或用户组。常见的 Pod, service, Replication Controller 和 Deployment 等都是属于某一个 namespace 的(默认是 default),而 node, persistent volume,namespace 等资源则不属于任何 namespace。
Namespace 常用来隔离不同的用户,比如 Kubernetes 自带的服务一般运行在 kube-system namespace 中。
常用的命令:
1 | # 查询 K8s 的 namespace 信息 |
其他还是有一些比较重要的资源对象,只不过这些没有上面这些常用,大家可以参考 Kubernetes 指南之资源对象。
这篇文章主要是对 Kubernetes 的架构、组件及核心的概念做了一下梳理,并没有涉及特别深入的内容,正如文章标题所述,算是一篇入门的文章介绍,以后如果有机会、有时间个人计划是好好研究一下 Kubernetes,更新一些稍微深入的内容。
参考:
]]>在介绍 CPU 分支预测机制之前,先来看下 CPU 的流水线机制(Wikipedia: CPU Instruction pipelining)。
关于流水线(pipeline),这里举一个生活中的例子,比如在洗车时,当前面一辆车清洗完成进入擦洗阶段后,下一辆车就可以进入喷水阶段了,这就是一个典型的流水线场景(如下如所示),它不是说非要前面一辆车把清洗、擦洗全部完成后,下一辆车才能开始。
从这里也可以看出,流水线机制一个重要的特性就是 提高了系统的吞吐量,也就是单位时间内服务的总数,不过它会有一个轻微的延迟,对于上面的例子就是,一辆汽车在洗完之后需要开到擦洗的地方擦洗。在 CPU 的设计中,也有类似流水线化的机制,这个汽车就是指令,每个阶段完成执行执行的一部分。
下面举一个示例,这里将系统执行分为三个阶段(A、B 和 C),如下图所示,每个阶段需要 100ps(picosecond,皮秒,也就是微微秒,即 $10^{-12}$),中间加载寄存器(也可以叫做流水线寄存器,pipeline register)需要 20ps。对于图 b,时间从左往右流动,对于指令 I1,三个方框分别代表三个阶段(图片来自 《深入理解计算机系统 第三版》 中插图)。
这样,每条指令都会按照三步经过这个系统,从头到尾需要三个完整的时钟周期,如上图所示,只要 I1
从 A 进入 B,就可以让 I2
进入 A 阶段了,以此类推。在稳定状态下,三个阶段都应该是活动的,每个时钟周期,一条指令离开系统,一条新的指令进入。在这个系统中,时钟周期设为 100+20=120ps
,得到的吞吐量大约为 8.33GIPS
,但是因为处理一条指令需要 3 个时钟周期,所以这条流水线的延迟就是 3*120=360ps
,它相当于 一阶段 的系统,吞吐量提高了 2.67 倍,代价是增加了一些硬件以及延迟的增加(寄存器变多带来的延迟)。
在上面的三阶段系统中,它是一个比较理想的情况,在这个系统中,我们可以将计算分成三个独立的阶段,每个阶段需要的时间是原来逻辑需要时间的三分之一,但是在实际生产中,会出现一些其他的因素,降低流水线的效率。
在前面的例子划分的阶段中,每个阶段执行都是 100ps,但是实际中并不一定是这样的,假如 A 阶段是 50ps,B 阶段是 150ps,C 阶段是 100ps,在这种情况下,系统必须将时钟周期设置为 170ps(由最慢的来决定),这样的话,其吞吐量就变成了 5.88GIPS
,由于时钟减慢,也导致了延迟增加到了 510ps。
因此,在 CPU 硬件设计时,将系统计算设计分为一组具有相同延迟的阶段将是一个严峻的挑战。
如果流水线过深,中间使用到的寄存器将会变多,寄存器使用带来的延迟在指令运行总延迟中的比重将会增大。一方面,在设计时,为了提高时钟频率,现代处理器会采用很深的流水线,另一方面,由于流水线过深,指令运行延迟会变长。所以,在实际设计时,电路设计师如何设计流水线寄存器,使其延迟尽可能减少,是高速微处理器面临重大挑战之一。
在开始介绍 CPU 分支预测技术之前,可以先看下 StackOverflow 上一个非常有名的问题(现在有 3w+ 人认同第一个回答) —— Why is processing a sorted array faster than processing an unsorted array?,问题的大概是,对一个数组中的每个元素,先做判断,如果大于某个值,就做累加,就是这样一个简单的操作,发现一个有意思的现象,如果用 C++ 写这段代码,对于有序数组和无序数组分别做这个操作,性能大概相差五倍多,在 Java 中,差距小一点,大概是 1 倍。为什么会出现这个问题呢?
背后的原因就是 CPU 流水线下,CPU 采用分支预测技术,对于有序数组可以很好地 CPU 这一特性,而无序数组会使得分支预测手足无措。
在前面,我们了解到 CPU 为了提高吞吐量采用了流水线机制,比如下图中的 4 级流水线(图片来自 Wikipedia: CPU Instruction pipelining):
上图中的 CPU pipeline 有四个执行阶段:
假设有三条指令,在上面这个四级流水线构架下(每个阶段都会花费一个时钟周期),pipeline 执行流程如下图所示:
我们知道:如果没有流水线机制,一条指令大概会花费 4 个时钟周期,而如果采用流水线机制,当第一条指令完成Fetch
后,第二条指令就可以进行Fetch
了,极大提高了指令的执行效率。
上面是我们的期待的理想情况,而在现实环境中,如果遇到的指令是 条件跳转指令,只要当前面的指令运行到执行阶段,才能知道要选择的分支,显然这种 停顿 对于 CPU 的 pipeline 机制是非常不友好的。而 分支预测技术 正是为了解决上述问题而诞生的,CPU 会根据分支预测的结果,选择下一条指令进入流水线。待跳转指令执行完成,如果预测正确,则流水线继续执行,不会受到跳转指令的影响。如果分支预测失败,那么便需要清空流水线,重新加载正确的分支(实际上目前市面上所有处理器都采用了类似的技术)。
这里看下常见的分支预测技术,主要有:静态分支预测、动态分支预测 和 协同分支预测 三种,有兴趣的可以看下下面的几篇文章:
关于这三种技术,这里就不再展开了,简单总结一下。
Java 本身没有虚函数的概念,它在 C++ 中是最常见的。在 C++ 中,虚函数通过 virtual
关键字定义,实现在类的继承当中,编译器通过判断对象的类型,在调用函数时,执行对应的函数。Java 中并没有显式去定义虚函数的概念,Java 中实际上每个函数都默认是一个虚函数(声明 final
关键字的函数除外),比如下面示例中 eat()
方法。
1 | public class Animal { |
虚函数存在的意义就是为了实现多态,Java 通过 动态绑定,不仅实现了虚函数的功能,也使得代码逻辑更为简洁。
到这里,相信大家已经对 CPU 的流水线机制及 CPU 的分支预测技术有了一定的了解。回到 code
上,如果代码里充满着各种不可预知的条件跳转指令,将会极大影响 CPU 的执行效率,数据库中采用的 Volcano-style execution engine(火山执行引擎)在代码中充满着各种虚函数调用(详细机制在后面内容中再介绍),在编译器中,虚函数需要调用查找虚函数表,并且虚函数调用是一个非直接跳转逻辑,在这个逻辑中,最大的代价是可能导致错误的 CPU 分支预测,一次错误的分支预测会导致需要 10 几个周期的系统开销。
参考:
先来看下这个改造/改进最初的动机,在之前 Flink 的线程模型中,会有多个潜在的线程去并发访问其内部的状态,比如 event-processing 和 checkpoint triggering,它们都是通过一个全局锁(checkpoint lock)来保证线程安全,这种实现方案带来的问题是:
SourceFunction#getCheckpointLock()
);基于上面的这些问题,关于线程模型,提出了一个全新的解决方案 —— MailBox 模型,它可以让 StreamTask 中所有状态的改变都会像在单线程中实现得一样简单。方案借鉴了 Actor 模型的 MailbBox 设计理念,它会让这些 action 操作(需要获取 checkpoint lock 的操作)先加入到一个 阻塞队列,然后主线程再从队列取相应的 mail task 去执行。
这里先看下,之前的实现方案中,StreamTask 中 checkpoint lock 都主要用在什么地方:
Event-processing
: events、watermarks、barriers、latency markers 等的发送和处理;Checkpoints
: 通过 RPC 向 TaskExecutor 发送 Checkpoint trigger 和 completeness 的通知,以及 Checkpoint 的 trigger 和 cancel 在 event 处理期间也可以通过 barrier 接收到;Processing Time Timers
: 目前 SystemProcessingTimeService
是使用 ScheduledExecutor
异步地处理 processing time timer(而 event time timer 依赖于 Watermark 的处理,并且它同步触发的)。另外,设计方案不但要能达到排它锁的效果,还要对一些核心环节(比如:event processing)能够做到原子性处理。
下面来看下 MailBox 模型 最初设计文档中的设计(方案方案见:Change threading-model in StreamTask to a mailbox-based approach)。
这里会在 StreamTask 中引入一个 MailBox 变量,最初的一个想法是将 MailBox 设计为一个 ArrayBlockingQueue
(实际上在 1.9 的实现中,使用的是一个 ring buffer
,1.10 对这部分又做了重构,后面会介绍)。MailBox 将会取代 StreamTask#run()
方法的角色,而且它还可以处理 Checkpoint event 和 processing timer event,这些 event 都会被封装为一个 task 添加到 MailBox 的队列中,而 MailBox 的主线程(单线程)将会消费这个队列中的 task 进行顺序处理。StreamTask 实现的伪代码如下:
1 | BlockingQueue<Runnable> mailbox = ... |
上面的代码实现只是核心代码大概实现,在真正的实现中还可以做很多优化,队列的公平性也是我们考虑的一个点,之前的抢锁操作是完全没有任何公平性而言的。
之前的实现中,Checkpoint lock 通过 getter
暴露给相关的 actor(Checkpoint、processing timer、event processing),而在 MailBox 的实现中,将会把 mailbox 隐藏在 queue 接口后面,仅仅向上层暴露 queue 的 getter
接口。
MailBox 的实现将会极大简化代码的实现,MailBox 模型可以确保这些改变都是由单线程来操作,之前很多需要加锁的代码在新的实现中可以被移除。而为了实现MailBox 模型,需要将之前 run()
方法中 event processing 循环调用处理改为一个 event 有界流处理,举个例子:
One/TwoInputStreamTask
中的下面代码
1 | while (running && inputProcessor.processInput()) |
可以修改为
1 | inputProcessor.processInput() // 每次触发,都相当于处理一个有限流 |
在实现中,会先检查 MailBox 有没有 mail
(即加入到队列里的 task 任务)需要处理,有的话,就进行处理,如果没有的话,就执行上面的操作,进行 event processing。
这里有一个问题:就是 SourceStreamTask,会有一个兼容性的问题,因为在流的 source 端,它的 event prcessing
是来专门产生一个无限流数据,在这个处理中,并不能穿插 MailBox 中的 mail
检测,也就是说,如果只有一个 MailBox 线程处理的话,当这个线程去产生数据的话,它一直运行下去,就无法再去检测 MailBox 中是否有新的 mail 到来(在 Source 未来的版本中,可以完美兼容 MailBox 线程设计,见 FLIP-27,但现在的版本还不兼容)。
为了兼容 Source 端,目前的解决方案是:两个线程操作,一个专门用产生无限流,另一个是 MailBox 线程(处理 Checkpoint、timer 等),这两个线程为了保证线程安全,还是使用 Checkpoint Lock 做排它锁,如下图所示(图片来自设计文档):
对于 Checkpoint 和 timer 的 trigger,这里会发现,目前的这个设计是完全可以满足需求的,Checkpoint 和 Timer 的触发事件都会以一个 Runnable
的形式添加到 MailBox 的队列中,等待 MailBox 主线程去处理。
介绍完其设计方案,这里注重看下在 Apache Flink 1.10 的代码中,基于 MailBox 模型 的 StreamTask 是如何实现的。
在 Flink 中,当一个作业被调度起来后,对于流计算来说,作业中的 Task 最终会以 StreamTask 的形式去执行,在 1.10 的实现中,一个 StreamTask 的核心处理流程如下:
StreamTask 中 invoke()
和 runMailboxLoop()
方法的实现如下:
1 | // org.apache.flink.streaming.runtime.tasks.StreamTask |
最后真正执行的是 MailboxProcessor 中的 runMailboxLoop()
方法,也就是上面说的 MailBox 主线程,StreamTask 运行的核心流程也是在这个方法中,其实现如下:
1 | //org.apache.flink.streaming.runtime.tasks.mailbox.MailboxProcessor |
上面的方法中,最关键的有两个地方:
processMail()
: 它会检测 MailBox 中是否有 mail
需要处理,如果有的话,就做相应的处理,一直将全部的 mail 处理完才会返回,只要 loop 还在进行,这里就会返回 true,否则会返回 false;runDefaultAction()
: 这个最终调用的 StreamTask 的 processInput()
方法,event-processing 的处理就是在这个方法中进行的。对于 StreamTask 来说,event-processing 现在是在 processInput()
方法中实现的:
1 | //org.apache.flink.streaming.runtime.tasks.StreamTask |
再结合 MailboxProcessor 中的 runMailboxLoop()
实现一起看,其操作的流程是:
processMail()
方法处理 MailBox 中的 mail
:mail
要处理,这里直接返回;mail
全部处理完;isDefaultActionUnavailable()
做一个状态检查(目的是提供一个接口方便上层控制调用,这里把这个看作一个状态检查方便讲述),如果是 true 的话,会在这里一直处理 mail 事件,不会返回,除非状态改变;processInput()
方法来处理 event:processInput()
方法来处理 event;MORE_AVAILABLE
(表示还有可用的数据等待处理)并且 recordWriter
可用(之前的异步操作已经处理完成),就会立马返回;END_OF_INPUT
,它表示数据处理完成,这里就会告诉 MailBox 数据已经处理完成了;recordWriter
可用。接着来看下 Checkpoint Trigger 是怎么处理的,要先看下 Streamtask 的 triggerCheckpointAsync()
实现:
1 | //org.apache.flink.streaming.runtime.tasks.mailbox.MailboxProcessor |
这里可以看到,其实现跟方案设计中的是一致,Checkpoint trigger 这里的操作就是向 MailBox 提交一个 Task,等待 MailBox 去处理。
在设计文档中,有个重要的、特别要注意的点就是 SourceStreamTask 的兼容问题,开始的设计方案是在 SourceStreamTask 中专门启动两个线程来保持兼容性问题,而且虽然使用了 MailBox 模型,但还是会继续使用 checkpoint lock 来保证线程安全,这里看下其是如何实现的。
1 | //org.apache.flink.streaming.runtime.tasks.SourceStreamTask |
可以看到:
processMail()
中一直等待并且处理 mail
,不会返回(也就是 MailBox 主线程一直在处理 mail
事件);那么两个线程如何保证线程安全呢?如果仔细看上面的代码就会发现,在 SourceStreamTask 中还继续使用了 getCheckpointLock()
,虽然这个方法现在已经被标注了将要被废弃,但 Source 没有改造完成之前,Source 的实现还是会继续依赖 checkpoint lock。
这里,总结一下 Flink 1.10 中 MailBox 模型的核心设计,如下图所示:
MailboxExecutor
: 它负责向 MailBox 提交 task 任务;TaskMailbox
: 负责存储相应 task 任务(也就是 mail
),它支持多写单读,单线程读取并处理;MailboxProcessor
: MailBox 的核心处理线程,MailboxDefaultAction
是其默认的 action 实现,可以理解为 StreamTask 的 event 处理逻辑就是基于 MailboxDefaultAction
接口实现的。Flink MailBox 这块的设计还是非常不错的,无论是从代码的可读性上还是后续维护性上都是要比之前的设计好很多,也值得我们学习借鉴。
参考:
]]>对于 TaskManager 的内容,这里将会聚焦下面几个问题上,下面的文章将会逐个去分析这些问题(因为内容较多,会分为两篇文章讲述,本篇注重聚焦在前五个问题上):
与 JobManager 类似,TaskManager 的启动类是 TaskManagerRunner
,大概的流程如下图所示:
TaskManager 启动的入口方法是 runTaskManager()
,它会首先初始化 TaskManager 一些相关的服务,比如:初始化 RpcService、初始化 HighAvailabilityServices 等等,这些都是为 TaskManager 服务的启动做相应的准备工作。其实 TaskManager 初始化主要分为下面两大块:
TM 的服务真正 Run 起来之后,核心流程还是在 TaskExecutor
中。
这里,先从 TaskManager 的入口 runTaskManager()
来看 TaskManager 相关服务的初始化流程,总结来看流程如下:
1 | // 1. 入口方法 |
首先看下具体的代码实现:
1 | // TaskManagerRunner.java |
在上面的流程中,初始化了一些最基本的服务,比如:rpc 服务,在方法的最后调用了 startTaskManager()
启动 TaskManager,其代码实现如下:
1 | // TaskManagerRunner.java |
这里,来着重看一下 TaskManagerServices.fromConfiguration()
这个方法,在这个方法初始了很多 TM 的服务,从下面的具体实现中也可以看出:
1 | // TaskManagerServices.java |
看到这里,是否有点懵圈了,是不是感觉 TaskManager 实现还挺复杂的,但与 TaskManager 要做的功能相比,上面的实现还不够,真正在 TaskManager 中处理复杂繁琐工作的组件是 TaskExecutor,这个才是 TaskManager 的核心。
回顾一下文章最开始的流程图,TaskManagerRunner 调用 run()
方法之后,真正要启动的是 TaskExecutor 服务,其 onStart()
具体实现如下:
1 | //note: 启动服务 |
这里,主要分为两个部分:
startTaskExecutorServices()
: 启动 TaskManager 相关的服务,结合流程图主要是四大块:startRegistrationTimeout()
: 启动注册超时的检测,默认是5 min,如果超过这个时间还没注册完成,就会抛出异常退出进程,启动失败。TaskExecutor 启动的核心实现是在 startTaskExecutorServices()
中,其实现如下:
1 | private void startTaskExecutorServices() throws Exception { |
接下来,详细这块的实现。
TaskExecutor 启动的第一个服务就是 HeartbeatManager,这里会启动两个:
jobManagerHeartbeatManager
: 用于与 JobManager(如果 Job 有 task 在这个 TM 上,这个 Job 的 JobManager 就与 TaskManager 有心跳通信)之间的心跳通信管理,如果 timeout,这里会重连;resourceManagerHeartbeatManager
:用于与 ResourceManager 之间的通信管理,如果 timeout,这里也会重连。1 | // TaskExecutor.java |
TaskManger 向 ResourceManager 注册是通过 ResourceManagerLeaderListener
来完成的,它会监控 ResourceManager 的 leader 变化,如果有新的 leader 被选举出来,将会调用 notifyLeaderAddress()
方法去触发与 ResourceManager 的重连,其实现如下:
1 | // TaskExecutor.java |
在上面的最后一步,创建了 TaskExecutorToResourceManagerConnection
对象,它启动后,会向 ResourceManager 注册 TM,具体的方法实现如下:
1 | // TaskExecutorToResourceManagerConnection.java |
ResourceManager 在收到这个请求,会做相应的处理,主要要做的事情就是:先从缓存里移除旧的 TM 注册信息(如果之前存在的话),然后再更新缓存,并增加心跳监控,只有这些工作完成之后,TM 的注册才会被认为是成功的。
TaskSlotTable 从名字也可以看出,它主要是为 TaskSlot 服务的,它主要的功能有以下三点:
先看下 TaskSlotTable 是如何初始化的:
1 | // TaskManagerServices.java |
TaskSlotTable 的初始化,只需要两个变量:
resourceProfiles
: TM 上每个 Slot 的资源信息;timerService
: 超时检测服务,来保证操作超时时做相应的处理。TaskSlotTable 的启动流程如下:
1 | // TaskExecutor.java |
TaskExecutor 启动的最后一步是,启动 JobLeader 服务,这个服务通过 JobLeaderListenerImpl
监控 Job 的 JobManager leader 的变化,如果 leader 被选举出来之后,这里将会与新的 JobManager leader 建立通信连接。
1 | // TaskExecutor.java |
到这里,TaskManager 的启动流程就梳理完了,TaskManager 在实现上整体的复杂度还是比较高的,毕竟它要做的事情是非常多的,下面的几个问题,将会进一步分析 TaskManager 内部的实现机制。
要想知道 TaskManager 提供了哪些能力,个人认为有一个最简单有效的方法就是查看其对外提供的 API 接口,它向上层暴露哪些 API,这些 API 背后都是 TaskManager 能力的体现,TaskManager 对外的包括的 API 列表如下:
requestSlot()
: RM 向 TM 请求一个 slot 资源;requestStackTraceSample()
: 请求某个 task 在执行过程中的一个 stack trace 抽样;submitTask()
: JobManager 向 TM 提交 task;updatePartitions()
: 更新这个 task 对应的 Partition 信息;releasePartitions()
: 释放这个 job 的所有中间结果,比如 close 的时候触发;triggerCheckpoint()
: Checkpoint Coordinator 触发 task 的 checkpoint;confirmCheckpoint()
: Checkpoint Coordinator 通知 task 这个 checkpoint 完成;cancelTask()
: task 取消;heartbeatFromJobManager()
: 接收来自 JobManager 的心跳请求;heartbeatFromResourceManager()
: 接收来自 ResourceManager 的心跳请求;disconnectJobManager()
;disconnectResourceManager()
;freeSlot()
: JobManager 释放 Slot;requestFileUpload()
: 一些文件(log 等)的上传请求;requestMetricQueryServiceAddress()
: 请求 TM 的 metric query service 地址;canBeReleased()
: 检查 TM 是否可以被 realease;把上面的 API 列表分分类,大概有以下几块:
通常,可以任务 TaskManager 提供的功能主要是前三点,如下图所示:
这个是 Flink HA 内容,Flink HA 机制是有一套统一的框架,它跟这个问题(TM 如何维护 JobManager 的关系,如果 JobManager 挂掉,TM 会如何处理? )的原理是一样的,这里以 ResourceManager Leader 的发现为例简单介一下。
这里,我们以使用 Zookeeper 模式的情况来讲述,ZooKeeper 做 HA 是业内最常用的方案,Flink 在实现并没有使用 ZkClient
这个包,而是使用 curator
来做的(有兴趣可以看下这篇文章 跟着实例学习ZooKeeper的用法: 缓存)。
关于 Flink HA 的使用,可以参考官方文档——JobManager High Availability (HA)。这里 TaskExecutor 在注册完 ResourceManagerLeaderListener
后,如果 leader 被选举出来或者有节点有变化,就通过它的 notifyLeaderAddress()
方法来通知 TaskExecutor,核心还是利用了 ZK 的 watcher 机制。同理, JobManager leader 的处理也是一样。
TaskManager Slot 资源的管理主要是在 TaskSlotTable 中处理的,slot 资源的申请与释放都通过 它处理的,相关的流程如下图所示(图中只描述了主要逻辑,相关的异常处理没有展示在图中):
这里先看下 slot 资源请求的处理,其实现如下:
1 | // TaskExecutor.java |
相应的处理逻辑如下:
FREE
状态,就进行分配(调用 TaskSlotTable 的 allocateSlot()
方法),如果分配失败,就抛出相应的异常;而 TaskSlotTable 在处理 slot 的分配时,主要是根据内部缓存的信息做相应的检查,其 allocateSlot()
的方法的实现如下:
1 | // TaskSlotTable.java |
这里再看下 Slot 的资源是如何释放的,代码实现如下:
1 | // TaskExecutor.java |
总结一下,TaskExecutor 在处理 slot 释放请求的理逻辑如下:
freeSlot()
方法,尝试释放这个 slot:FREE
);RELEASING
,然后再遍历这个 slot 上的 task,逐个将其标记为 failed;FREE
),这里将会通知 RM 这个 slot 现在又可用了;本篇文章主要把 TaskManager 的启动流程及资源管理做了相应的讲述,正如文章中所述,TaskManager 主要有三大功能:slot 资源管理、task 的提交与运行以及 checkpoint 处理,在下篇文章中将会着重在 Task 的提交与运行上,checkpoint 处理部分将会 checkpoint 的文章中一起介绍。
最后,说一些个人的感想吧,我个人在看开源项目的源码时,慢慢开始感受到阅读优秀的开源代码对个人技术能力的提升是非常有帮助的,它不但会增加你对这个项目的熟悉程度,还会让你看到一些设计或方案在代码里是如何落地或实现的,如果换做是你,你会怎么设计或实现,经常看看这些优秀代码,多多思考(如果能把其中的设计或实现应用到自己的工作上那就更好不过了),这对自己工程能力的提升是有帮助的。
参考:
]]>JobMaster
)。每个作业在启动后,Dispatcher 都会为这个作业创建一个 JobManager 对象,用来做这个作业相关的协调工作,比如:调度这个作业的 task、触发 Checkpoint 以及作业的容错恢复等。另外,本篇文章也将会看下一个作业在生成 ExecutionGraph 之后是如何在集群中调度起来的。
从之前文章的介绍中,我们已经知道 JobManager 其实就是一个作业的 master 服务,主要负责自己作业相关的协调工作,包括:向 ResourceManager 申请 Slot 资源来调度相应的 task 任务、定时触发作业的 checkpoint 和手动 savepoint 的触发、以及作业的容错恢复,这些流程将会在后面的系列文章中介绍(这些流程涉及到的组件比较多,需要等待后面把 TaskManager 及 Flink 的调度模型讲述完再回头来看),本文会从 JobManager 是如何初始化的、JobManager 有哪些组件以及分别提供了哪些功能这两块来讲述。
当用户向 Flink 集群提交一个作业后,Dispatcher 在收到 Client 端提交的 JobGraph 后,会为这个作业创建一个 JobManager 对象(对应的是 JobMaster 类),如下图所示:
JobManager 在初始化时,会创建 LegacyScheduler
对象,而 LegacyScheduler
在初始化时会将这个作业的 JobGraph 转化为 ExecutionGraph。在JobManager 启动后,就会开始给这个作业的 task 申请相应的资源、开始调度执行这个作业。
JobMaster 在实现中,也依赖了很多的服务,其中最重要的是 SchedulerNG
和 SlotPool
,JobMaster 对外提供的接口实现中大都是使用前面这两个服务的方法。
1 | // JobMaster.java |
JobMaster 中涉及到重要组件如下图所示:
JobMaster 主要有两个服务:
LegacyScheduler
: ExecutionGraph 相关的调度都是在这里实现的,它类似更深层的抽象,封装了 ExecutionGraph 和 BackPressureStatsTracker,JobMaster 不直接去调用 ExecutionGraph 和 BackPressureStatsTracker 的相关方法,都是通过 LegacyScheduler
间接去调用;SlotPool
: 它是 JobMaster 管理其 slot 的服务,它负责向 RM 申请/释放 slot 资源,并维护其相应的 slot 信息。从前面的图中可以看出,如果 LegacyScheduler
想调用 CheckpointCoordinator
的方法,比如 LegacyScheduler
的 triggerSavepoint()
方法,它是需要先通过 executionGraph
的 getCheckpointCoordinator()
方法拿到 CheckpointCoordinator
,然后再调用 CheckpointCoordinator
的 triggerSavepoint()
方法来触发这个作业的 savepoint。
目前 JobMaster 对外提供的 API 列表如下(主要还是 JobMasterGateway
接口对应的实现):
cancel()
: 取消当前正在执行的作业,如果作业还在调度,会执行停止,如果作业正在运行的话,它会向对应的 TM 发送取消 task 的请求(cancelTask()
请求);updateTaskExecutionState()
: 更新某个 task 的状态信息,这个是 TM 主动向 JM 发送的更新请求;requestNextInputSplit()
: Source ExecutionJobVertex 请求 next InputSlipt,这个一般是针对批处理读取而言,有兴趣的可以看下 FLIP-27: Refactor Source Interface,这里是社区计划对 Source 做的改进,未来会将批和流统一到一起;requestPartitionState()
: 获取指定 Result Partition 对应生产者 JobVertex 的执行状态;scheduleOrUpdateConsumers()
: TM 通知 JM 对应的 Result Partition 的数据已经可用,每个 ExecutionVertex 的每个 ResultPartition 都会调用一次这个方法(可能是在第一次生产数据时调用或者所有数据已经就绪时调用);disconnectTaskManager()
: TM 心跳超时或者作业取消时,会调用这个方法,JM 会释放这个 TM 上的所有 slot 资源;acknowledgeCheckpoint()
: 当一个 Task 做完 snapshot 后,通过这个接口通知 JM,JM 再做相应的处理,如果这个 checkpoint 所有的 task 都已经 ack 了,那就意味着这个 checkpoint 完成了;declineCheckpoint()
: TM 向 JM 发送这个消息,告诉 JM 的 Checkpoint Coordinator 这个 checkpoint request 没有响应,比如:TM 触发 checkpoint 失败,然后 Checkpoint Coordinator 就会知道这个 checkpoint 处理失败了,再做相应的处理;requestKvStateLocation()
: 请求某个注册过 registrationName 对应的 KvState 的位置信息;notifyKvStateRegistered()
: 当注册一个 KvState 的时候,会调用这个方法,一些 operator 在初始化的时候会调用这个方法注册一个 KvState;notifyKvStateUnregistered()
: 取消一个 KVState 的注册,这里是在 operator 关闭 state backend 时调用的(比如:operator 的生命周期结束了,就会调用这个方法);offerSlots()
: TM 通知 JM 其上分配到的 slot 列表;failSlot()
: 如果 TM 分配 slot 失败(情况可能很多,比如:slot 分配时状态转移失败等),将会通过这个接口告知 JM;registerTaskManager()
: 向这个 JM 注册 TM,JM 会将 TM 注册到 SlotPool 中(只有注册过的 TM 的 Slot 才被认为是有效的,才可以做相应的分配),并且会通过心跳监控对应的 TM;disconnectResourceManager()
: 与 ResourceManager 断开连接,这个是有三种情况会触发,JM 与 ResourceManager 心跳超时、作业取消、重连 RM 时会断开连接(比如:RM leader 切换、RM 的心跳超时);heartbeatFromTaskManager()
: TM 向 JM 发送心跳信息;heartbeatFromResourceManager()
: JM 向 ResourceManager 发送一个心跳信息,ResourceManager 只会监听 JM 是否超时;requestJobDetails()
: 请求这个作业的 JobDetails
(作业的概况信息,比如:作业执行了多长时间、作业状态等);requestJobStatus()
: 请求这个作业的执行状态 JobStatus
;requestJob()
: 请求这个作业的 ArchivedExecutionGraph
(它是 ExecutionGraph
序列化之后的结果);triggerSavepoint()
: 对这个作业触发一次 savepoint;stopWithSavepoint()
: 停止作业前触发一次 savepoint(触发情况是:用户手动停止作业时指定一个 savepoint 路径,这样的话,会在停止前做一次 savepoint);requestOperatorBackPressureStats()
: 汇报某个 operator 反压的情况;notifyAllocationFailure()
: 如果 RM 分配 slot 失败的话,将会通过这个接口通知 JM;这里可以看到有部分接口的方法是在跟 RM 通信使用的,所以在 RM 的接口中也可以看到对应的方法。另外,JobMaster 上面这些方法在实现时基本都是在调用 LegacyScheduler
或 SlotPool
的具体实现方法来实现的。
SlotPool 是为当前作业的 slot 请求而服务的,它会向 ResourceManager 请求 slot 资源;SlotPool 会维护请求到的 slot 列表信息(即使 ResourceManager 挂掉了,SlotPool 也可以使用当前作业空闲的 slot 资源进行分配),而如果一个 slot 不再使用的话,即使作业在运行,也是可以释放掉的(所有的 slot 都是通过 AllocationID
来区分的)。
目前 SlotPool 提供的 API 列表如下:
connectToResourceManager()
: SlotPool 与 ResourceManager 建立连接,之后 SlotPool 就可以向 ResourceManager 请求 slot 资源了;disconnectResourceManage()
: SlotPool 与 ResourceManager 断开连接,这个方法被调用后,SlotPool 就不能从 ResourceManager 请求 slot 资源了,并且所有正在排队等待的 Slot Request 都被取消;allocateAvailableSlot()
: 将指定的 Slot Request 分配到指定的 slot 上,这里只是记录其对应关系(哪个 slot 对应哪个 slot 请求);releaseSlot()
: 释放一个 slot; requestNewAllocatedSlot()
: 从 RM 请求一个新的 slot 资源分配,申请到的 slot 之后也会添加到 SlotPool 中;requestNewAllocatedBatchSlot()
: 上面的方法是 Stream 类型,这里是 batch 类型,但向 RM 申请的时候,这里并没有区别,只是为了做相应的标识;getAvailableSlotsInformation()
: 获取当前可用的 slot 列表;failAllocation()
: 分配失败,并释放相应的 slot,可能是因为请求超时由 JM 触发或者 TM 分配失败;registerTaskManager()
: 注册 TM,这里会记录一下注册过来的 TM,只能向注册过来的 TM 分配 slot;releaseTaskManager()
: 注销 TM,这个 TM 相关的 slot 都会被释放,task 将会被取消,SlotPool 会通知相应的 TM 释放其 slot;createAllocatedSlotReport()
: 汇报指定 TM 上的 slot 分配情况;通过上面 SlotPool 对外提供的 API 列表,可以看到其相关方法都是跟 Slot 相关的,整体可以分为下面几部分:
SlotPool 这里,更多只是维护一个状态信息,以及与 ResourceManager(请求 slot 资源)和 TM(释放对应的 slot)做一些交互工作,它对这些功能做了相应的封装,方便 JobMaster 来调用。
如前面所述,LegacyScheduler 其实是对 ExecutionGraph
和 BackPressureStatsTracker
方法的一个抽象,它还负责为作业创建对应的 ExecutionGraph 以及对这个作业进行调度。关于 LegacyScheduler 提供的 API 这里就不再展开,有兴趣的可以直接看下源码,它提供的大部分 API 都是在 JobMaster 的 API 列表中,因为 JobMaster 的很多方法实现本身就是调用 LegacyScheduler 对应的方法。
有了前面的讲述,这里看下一个新提交的作业,JobMaster 是如何调度起来的。当 JobMaster 调用 LegacyScheduler 的 startScheduling()
方法后,就会开始对这个作业进行相应的调度,申请对应的 slot,并部署 task,其实现如下:
1 | // LegacyScheduler.java |
一个作业开始调度后详细流程如下图所示(其中比较核心方法已经标成黄颜色):
ExecutionGraph 通过 scheduleForExecution()
方法对这个作业调度执行,其方法实现如下:
1 | /note: 把 CREATED 状态转换为 RUNNING 状态,并做相应的调度,如果有异常这里会抛出 |
配合前面图中的流程,接下来,看下这个作业在 SchedulingUtils 中是如何调度的:
1 | // SchedulingUtils.java |
由于对于流作业来说,它默认的调度模式(ScheduleMode
)是 ScheduleMode.EAGER
,也就是说,所有 task 会同时调度起来,上面的代码里也可以看到调度的时候有两个主要方法:
allocateResourcesForExecution()
: 它的作用是给这个 Execution 分配资源,获取要分配的 slot(它还会向 ShuffleMaster 注册 produced partition,这个 shuffle 部分内容后面文章再讲述,这里就不展开了);deploy()
: 这个方法会直接向 TM 提交这个 task 任务;这里,主要展开一下 allocateResourcesForExecution()
方法的实现,deploy()
的实现将会在后面 TaskManager 这篇文章中讲述。
通过前面的代码,我们知道,allocateResourcesForExecution()
方法会给每一个 ExecutionVertex 分配一个 slot,而它具体是如何分配的,这个流程是在 Execution 的 allocateAndAssignSlotForExecution()
方法中实现的,代码如下如下:
1 |
|
这里,简单总结一下上面这个方法的流程:
ExecutionState
)从 CREATED
转为 SCHEDULED
状态;TaskManagerLocation
)列表;allocateSlot()
获取要分配的 slot。在 SchedulerImpl 去分配 slot 的时候,其实是会分两种情况的:
allocateSingleSlot()
: 如果对应的 task 节点没有设置 SlotSharingGroup,会直接走这个方法,就不会考虑 share group 的情况,直接给这个 task 分配对应的 slot;allocateSharedSlot()
: 如果对应的 task 节点有设置 SlotSharingGroup,就会走到这个方法,在分配 slot 的时候,考虑的因素就会多一些。这里,我们先来看下如何给这个 slot 选择一个最佳的 TM 列表,具体的方法实现是在 Execution
中的 calculatePreferredLocations()
方法中实现的,其具体的实现如下:
1 | // Execution.java |
从上面的实现可以看出,这里是先通过 ExecutionVertex
的 getPreferredLocations()
方法获取一个 TaskManagerLocation 列表,然后再根据 LocationPreferenceConstraint
的模式做过滤,如果是 ALL
,那么前面拿到的所有列表都会直接返回,而如果是 ANY
,只会把那些已经分配好的 input 节点的 TaskManagerLocation
返回。
这里,看下 ExecutionVertex
的 getPreferredLocations()
方法的实现逻辑:
1 | // ExecutionVertex.java |
这里简单介绍一下其处理逻辑:
inputEdges
,获取其上游 ExecutionVertex 的位置信息列表,但是如果这个列表的数目超过阈值(默认是 8),就会直接返回 null(上游过于分散,再根据 input 位置信息去分配就没有太大意义了)。可以看出,在选取最优的 TaskManagerLocation 列表时,主要是根据 state 和 input 的位置信息来判断,会优先选择 state,也就是上次 checkpoint 中记录的位置。
在上面选择了最优的 TaskManagerLocation 列表后,这里来看下如何给 task 选择具体的 slot,这个是在 SlotSelectionStrategy
中的 selectBestSlotForProfile()
方法中做的,目前 SlotSelectionStrategy
有两个实现类:PreviousAllocationSlotSelectionStrategy
和 LocationPreferenceSlotSelectionStrategy
,这个是在 state.backend.local-recovery
参数中配置的,默认是 false,选择的是 PreviousAllocationSlotSelectionStrategy
,如果配置为 true,那么就会选择 PreviousAllocationSlotSelectionStrategy
,这部分的逻辑如下:
1 | // DefaultSchedulerFactory.java |
这里分别看下这两个实现类的 selectBestSlotForProfile()
的实现逻辑:
PreviousAllocationSlotSelectionStrategy
: 它会根据上次的分配记录,如果这个位置刚好在 SlotPool 的可用列表里,这里就会直接选这个 slot,否则会走到 LocationPreferenceSlotSelectionStrategy
的处理逻辑;LocationPreferenceSlotSelectionStrategy
: 这个是对可用的 slot 列表做打分,选择分数最高的(分数相同的话,会选择第一个),如果 slot 在前面得到的最优 TaskManagerLocation
列表中,分数就会比较高。在分配 slot 时,这里分为两种情况:
allocateSingleSlot()
: 如果没有设置 SlotSharingGroup 将会走到这个方法,直接给这个 SlotRequestId 分配一个 slot,具体选择哪个 slot 就是上面的逻辑;allocateSharedSlot()
: 而如果设置了 SlotSharingGroup 就会走到这里,先根据 SlotSharingGroupId
获取或创建对应的 SlotSharingManager
,然后创建(或者根据 SlotSharingGroup
获取)一个的 MultiTaskSlot
(每个 SlotSharingGroup
会对应一个 MultiTaskSlot
对象),这里再将这个 task 分配到这个 MultiTaskSlot
上(这个只是简单介绍,后面在调度模型文章中,将会详细讲述)。到这里,Flink JobManager 的大部分内容已经讲述完了,还有一些小点会在后面的系列文章中再给大家讲述。这里总结一下,JobManager 主要是为一个具体的作业而服务的,它负责这个作业每个 task 的调度、checkpoint/savepoint(后面 checkpoint 的文章中会详述其流程)的触发以及容错恢复,它有两个非常重点的服务组件 —— LegacyScheduler
和 SlotPool
,其中:
LegacyScheduler
: 它封装了作业的 ExecutionGraph
以及 BackPressureStatsTracker
中的接口,它会负责这个作业具体调度、savepoint 触发等工作;SlotPool
: 它主要负责这个作业 slot 相关的内容,像与 ResourceManager 通信、分配或释放 slot 资源等工作。文章的后半部分,又总结了一个作业是如何调度起来的,首先是分配 slot,最后是通过 deploy()
接口向 TM 提交这个 task,本文着重关注了 slot 的分配,task 的部署将会在下节的 TaskManager 详解中给大家介绍。
参考
]]>这里要说明的一点是:通常我们认为 Flink 集群的 master 节点就是 JobManager,slave 节点就是 TaskManager 或者 TaskExecutor(见:Distributed Runtime Environment),这本身是没有什么问题的。但这里需要强调一下,在本文中集群的 Master 节点暂时就叫做 Master 节点,而负责每个作业调度的服务,这里叫做 JobManager/JobMaster(现在源码的实现中对应的类是 JobMaster)。集群的 Master 节点的工作范围与 JobManager 的工作范围还是有所不同的,而且 Master 节点的其中一项工作职责就是为每个提交的作业创建一个 JobManager 对象,用来处理这个作业相关协调工作,比如:task 的调度、Checkpoint 的触发及失败恢复等,JobManager 的内容将会在下篇文章单独讲述,本文主要聚焦 Master 节点除 JobManager 之外的工作。
Flink 的 Master 节点包含了三个组件: Dispatcher、ResourceManager 和 JobManager。其中:
根据上面的 Flink 的架构图(等把 runtime 的内容介绍完,届时会画一张更细的 Flink 的架构图,现在先以官方的图来看),当用户开始提交一个作业,首先会将用户编写的代码转化为一个 JobGraph(参考这个系列前面的文章),在这个过程中,它会进行一些检查或优化相关的工作(比如:检查配置,把可以 Chain 在一起算子 Chain 在一起)。然后,Client 再将生成的 JobGraph 提交到集群中执行。此时有两种情况(对于两种不同类型的集群):
当作业到 Dispatcher 后,Dispatcher 会首先启动一个 JobManager 服务,然后 JobManager 会向 ResourceManager 申请资源来启动作业中具体的任务。ResourceManager 选择到空闲的 Slot (Flink 架构-基本概念)之后,就会通知相应的 TM 将该 Slot 分配给指定的 JobManager。
Flink 集群 Master 节点在初始化时,会先调用 ClusterEntrypoint 的 runClusterEntrypoint()
方法启动集群,其整体流程如下图所示:
上图流程中 runCluster()
方法的实现如下:
1 | // ClusterEntrypoint.java |
这个方法主要分为下面两个步骤:
initializeServices()
: 初始化相关的服务,都是 Master 节点将会使用到的一些服务;create DispatcherResourceManagerComponent
: 这里会创建一个 DispatcherResourceManagerComponent
对象,这个对象在创建的时候会启动 Dispatcher
和 ResourceManager
服务。下面来详细看下具体实现。
initializeServices()
初始化一些基本的服务,具体的代码实现如下:
1 | // ClusterEntrypoint.java |
上述流程涉及到服务有:
MemoryArchivedExecutionGraphStore
主要是在内存中缓存,FileArchivedExecutionGraphStore
会持久化到文件系统,也会在内存中缓存。这些服务都会在前面第二步创建 DispatcherResourceManagerComponent
对象时使用到。
创建 DispatcherResourceManagerComponent
对象的实现如下:
1 | // AbstractDispatcherResourceManagerComponentFactory.java |
在上面的方法实现中,Master 中的两个重要服务就是在这里初始化并启动的:
Dispatcher
: 初始化并启动这个服务,如果 JM 启动了 HA 模式,这里会竞选 leader,只有是 leader 的 Dispatcher
才会真正对外提供服务(参考前面图中的流程);ResourceManager
: 这个跟 Dispatcher
有点类似。这里,我们来详细看下 Master 使用到各个服务组件,并做下详细的介绍。
Dispatcher 主要是用于作业的提交、并把它们持久化、为作业创建对应的 JobManager 等,Client 端提交的 JobGraph 就是提交给了 Dispatcher 服务,这里先看一下一个 Dispatcher 对象被选举为 leader 后是如何初始化的,如果当前的 Dispatcher 被选举为 leader,则会调用其 grantLeadership()
方法,该方法实现如下:
1 | // Dispatcher.java |
Dispatcher 被选举为 leader 后,它主要的操作步骤如下:
recoverJobs()
: 先从 job graph store 恢复所有作业的 JobGraph;tryAcceptLeadershipAndRunJobs()
: 启动前面恢复的每个作业,这里要说明的是,目前看到的 1.9 的实现,这里会将前面所有的作业都会重启,我在看的时候是有点懵逼的,这个 HA 有点伪 HA,相当于 leader 切换之后,作业就必须要得重启恢复,这个代价是有点大的,不过也看到社区有改进的计划(FLINK-10333 这个进度有点慢);我们这里再详细看下 Dispatcher 对外提供了哪些 API 实现(这些接口主要还是 DispatcherGateway
中必须要实现的接口),通过这些 API,其实就很容易看出它到底对外提供了哪些功能,提供的 API 有:
listJobs()
: 列出当前提交的作业列表;submitJob()
: 向集群提交作业;getBlobServerPort()
: 返回 blob server 的端口;requestJob()
: 根据 jobId 请求一个作业的 ArchivedExecutionGraph(它是这个作业 ExecutionGraph 序列化后的形式);disposeSavepoint()
: 清理指定路径的 savepoint 状态信息;cancelJob()
: 取消一个指定的作业;requestClusterOverview()
: 请求这个集群的全局信息,比如:集群有多少个 slot,有多少可用的 slot,有多少个作业等等;requestMultipleJobDetails()
: 返回当前集群正在执行的作业详情,返回对象是 JobDetails 列表;requestJobStatus()
: 请求一个作业的作业状态(返回的类型是 JobStatus
); requestOperatorBackPressureStats()
: 请求一个 Operator 的反压情况; requestJobResult()
: 请求一个 job 的 JobResult
;requestMetricQueryServiceAddresses()
: 请求 MetricQueryService 的地址;requestTaskManagerMetricQueryServiceAddresses()
: 请求 TaskManager 的 MetricQueryService 的地址;triggerSavepoint()
: 使用指定的目录触发一个 savepoint;stopWithSavepoint()
: 停止当前的作业,并在停止前做一次 savepoint;shutDownCluster()
: 关闭集群;通过 Dispatcher 提供的 API 可以看出,Dispatcher 服务主要有功能有:
Dispatcher 这里主要处理的还是 Job 相关的请求,对外提供了统一的接口。
ResourceManager 从名字就可以看出,它主要是资源管理相关的服务,如果其被选举为 leader,实现如下,它会清除缓存中的数据,然后启动 SlotManager 服务:
1 | // ResourceManager.java |
这里也来看下 ResourceManager 对外提供的 API(ResourceManagerGateway
相关方法的实现):
registerJobManager()
: 在 ResourceManager 中注册一个 JobManager
对象,一个作业启动后,JobManager 初始化后会调用这个方法;registerTaskExecutor()
: 在 ResourceManager 中注册一个 TaskExecutor
(TaskExecutor
实际上就是一个 TaskManager),当一个 TaskManager 启动后,会主动向 ResourceManager 注册;sendSlotReport()
: TM 向 ResourceManager 发送 SlotReport
(SlotReport
包含了这个 TaskExecutor 的所有 slot 状态信息,比如:哪些 slot 是可用的、哪些 slot 是已经被分配的、被分配的 slot 分配到哪些 Job 上了等);heartbeatFromTaskManager()
: 向 ResourceManager 发送来自 TM 的心跳信息;heartbeatFromJobManager()
: 向 ResourceManager 发送来自 JM 的心跳信息;disconnectTaskManager()
: TM 向 ResourceManager 发送一个断开连接的请求;disconnectJobManager()
: JM 向 ResourceManager 发送一个断开连接的请求;requestSlot()
: JM 向 ResourceManager 请求 slot 资源;cancelSlotRequest()
: JM 向 ResourceManager 发送一个取消 slot 申请的请求;notifySlotAvailable()
: TM 向 ResourceManager 发送一个请求,通知 ResourceManager 某个 slot 现在可用了(TM 端某个 slot 的资源被释放,可以再进行分配了);deregisterApplication()
: 向资源管理系统(比如:yarn、mesos)申请关闭当前的 Flink 集群,一般是在关闭集群的时候调用的;requestTaskManagerInfo()
: 请求当前注册到 ResourceManager 的 TM 的详细信息(返回的类型是 TaskManagerInfo
,可以请求的是全部的 TM 列表,也可以是根据某个 ResourceID
请求某个具体的 TM);requestResourceOverview()
: 向 ResourceManager 请求资源概况,返回的类型是 ResourceOverview
,它包括注册的 TM 数量、注册的 slot 数、可用的 slot 数等;requestTaskManagerMetricQueryServiceAddresses()
: 请求 TM MetricQueryService 的地址信息;requestTaskManagerFileUpload()
: 向 TM 发送一个文件上传的请求,这里上传的是 TM 的 LOG/STDOUT 类型的文件,文件会上传到 Blob Server,这里会拿到一个 BlobKey(Blobkey 实际上是文件名的一部分,通过 BlobKey 可以确定这个文件的物理位置信息);从上面的 API 列表中,可以看出 ResourceManager 的主要功能是:
ResourceManager 在启动的时候,也会启动一个 SlotManager 服务,TM 相关的 slot 资源都是在 SlotManager 中维护的。
SlotManager 会维护所有从 TaskManager 注册过来的 slot(包括它们的分配情况)以及所有 pending 的 SlotRequest(所有的 slot 请求都会先放到 pending 列表中,然后再去判断是否可以满足其资源需求)。只要有新的 slot 注册或者旧的 slot 资源释放,SlotManager 都会检测 pending SlotRequest 列表,检查是否有 SlotRequest 可以满足,如果可以满足,就会将资源分配给这个 SlotRequest;如果没有足够可用的 slot,SlotManager 会尝试着申请新的资源(比如:申请一个 worker 启动)。
当然,为了资源及时释放和避免资源浪费,空转的 task manager(它当前已经分配的 slot 并未使用)和 pending slot request 在 timeout 之后将会分别触发它们的释放和失败(对应的方法实现是 checkTaskManagerTimeouts()
和 checkSlotRequestTimeouts()
)。
SlotManager 对外的提供的 API 如下(SlotManager
中必须要实现的接口,实现类是 SlotManagerImpl
):
getNumberRegisteredSlots()
: 获取注册的 slot 的总数量;getNumberRegisteredSlotsOf()
: 获取某个 TM 注册的 slot 的数量;getNumberFreeSlots()
: 获取当前可用的(还未分配的 slot) slot 的数量;getNumberFreeSlotsOf()
: 获取某个 TM 当前可用的 slot 的数量;getNumberPendingTaskManagerSlots()
: 获取 pendingSlots
中 slot 的数量(pendingSlots
记录的是 SlotManager 主动去向资源管理系统申请的资源,该系统在一些情况下会新启动 worker 来创建资源,但这些slot 还没有主动汇报过来,就会暂时先放到 pendingSlots
中,如果 TM 过来注册的话,该 slot 就会从 pendingSlots 中移除,存储到其他对象中);getNumberPendingSlotRequests()
: 获取 pendingSlotRequests
列表的数量,这个集合中存储的是收到的、还没分配的 SlotRequest 列表,当一个 SlotRequest 发送过来之后,会先存储到这个集合中,当分配完成后,才会从这个集合中移除;registerSlotRequest()
: JM 发送一个 slot 请求(这里是 ResourceManager 通过 requestSlot()
接口调用的);unregisterSlotRequest()
: 取消或移除一个正在排队(可能已经在处理中)的 SlotRequest;registerTaskManager()
: 注册一个 TM,这里会将 TM 中所有的 slot 注册过来,等待后面分配;unregisterTaskManager()
: 取消一个 TM 的注册(比如:关闭的时候可能会调用),这里会将这个 TM 上所有的 slot 都移除,会先从缓存中移除,然后再通知 JM 这个 slot 分配失败;reportSlotStatus()
: TM 汇报当前 slot 分配的情况,SlotManager 会将其更新到自己的缓存中;freeSlot()
: 释放一个指定的 slot,如果这个 slot 之前已经被分配出去了,这里会更新其状态,将其状态改为 FREE
;setFailUnfulfillableRequest()
: 遍历 pendingSlotRequests
列表,如果这些 slot 请求现在还分配不到合适的资源,这里会将其设置为 fail,会通知 JM slot 分配失败。同样,从上面的 API 列表中,总结一下 SlotManager 的功能:
Master 除了上面的服务,还启动了其他的服务,这里简单列一下:
BlobServer
: 它是 Flink 用来管理二进制大文件的服务,Flink JobManager 中启动的 BlobServer 负责监听请求并派发线程去处理(这个将会在下篇文章中讲述);JobManager
: Dispatcher 会为每个作业创建一个 JobManager 对象,它用来处理这个作业相关的协调工作,比如:task 的调度、Checkpoint 的触发及失败恢复等(这个也会在下篇文章中讲述);HA service
: Flink HA 的实现目前是依赖了 ZK,使用 curator
这个包来实现的,有兴趣的可以看下 Curator leader 选举(一) 这篇文章。到这里,终于就把 Flink Master 相关内容的一部分梳理完了,这里简单总结一下:
Flink Master 这部分的抽象还是比较好的,三大组件各司其职。当然还有一些需要改善的地方,比如:为什么不抽象一个 Master 类,然后把这些子服务全都放到 Master 类里,这样代码看起来会清晰舒服很多,现在的代码对初学者其实并不友好。
参考
]]>当用户向一个 Flink 集群提交一个作业后,JobManager 会接收到 Client 相应的请求,JobManager 会先做一些初始化相关的操作(也就是 JobGraph 到 ExecutionGraph 的转化),当这个转换完成后,才会根据 ExecutionGraph 真正在分布式环境中调度当前这个作业,而 JobManager 端处理的整体流程如下:
上图是一个作业提交后,在 JobManager 端的处理流程,本篇文章主要聚焦于 ExecutionGraph 的生成过程,也就是图中的红色节点,即 ExecutionGraphBuilder 的 buildGraph()
方法,这个方法就是根据 JobGraph 及相关的配置来创建 ExecutionGraph 对象的核心方法。
这里将会详细来讲述 ExecutionGraphBuilder buildGraph()
方法的详细实现。
ExecutionGraph 引入了几个基本概念,先简单介绍一下这些概念,对于理解 ExecutionGraph 有较大帮助:
taskVertices
变量,它是 ExecutionVertex 类型的数组,数组的大小就是这个 JobVertex 的并发度,在创建 ExecutionJobVertex 对象时,会创建相同并发度梳理的 ExecutionVertex 对象,在真正调度时,一个 ExecutionVertex 实际就是一个 task,它是 ExecutionJobVertex 并行执行的一个子任务;从这些基本概念中,也可以看出以下几点:
ExecutionVertex
和 IntermediateResultPartition
连接起来,表示它们之间的连接关系。这里先放一张 ExecutionGraph 粗略图,它展示上面这些类之间的关系:
ExecutionGraph 的生成是在 ExecutionGraphBuilder 的 buildGraph()
方法中实现的:
1 | // ExecutionGraphBuilder.java |
在这个方法里,会先创建一个 ExecutionGraph 对象,然后对 JobGraph 中的 JobVertex 列表做一下排序(先把有 source 节点的 JobVertex 放在最前面,然后开始遍历,只有当前 JobVertex 的前置节点都已经添加到集合后才能把当前 JobVertex 节点添加到集合中),最后通过 attachJobGraph()
方法生成具体的 Execution Plan。
ExecutionGraph 的 attachJobGraph()
方法会将这个作业的 ExecutionGraph 构建出来,它会根据 JobGraph 创建相应的 ExecutionJobVertex、IntermediateResult、ExecutionVertex、ExecutionEdge、IntermediateResultPartition,其详细的执行逻辑如下图所示:
上面的图还是有些凌乱,要配合本文的第二张图来看,接下来看下具体的方法实现。
先来看下创建 ExecutionJobVertex 对象的实现:
1 | public ExecutionJobVertex( |
它主要做了一下工作:
results
(IntermediateDataSet
列表)来创建相应的 IntermediateResult
对象,每个 IntermediateDataSet
都会对应的一个 IntermediateResult
;ExecutionVertex
对象,每个 ExecutionVertex
对象在调度时实际上就是一个 task 任务;IntermediateResult
和 ExecutionVertex
对象时都会记录它们之间的关系,它们之间的关系可以参考本文的图二。创建 ExecutionVertex 对象的实现如下:
1 | public ExecutionVertex( |
ExecutionVertex 创建时,主要做了下面这三件事:
producedDataSets
(IntermediateResult 类型的数组),给每个 ExecutionVertex 创建相应的 IntermediateResultPartition 对象,它代表了一个 IntermediateResult 分区;setPartition()
方法,记录 IntermediateResult 与 IntermediateResultPartition 之间的关系;attemptNumber
将会自增加 1,这里初始化的时候其值为 0。根据前面的流程图,接下来,看下 ExecutionJobVertex 的 connectToPredecessors()
方法。在这个方法中,主要做的工作是创建对应的 ExecutionEdge 对象,并使用这个对象将 ExecutionVertex 与 IntermediateResultPartition 连接起来,ExecutionEdge 的成员变量比较简单,如下所示:
1 | // ExecutionEdge.java |
ExecutionEdge 的创建是在 ExecutionVertex 中 connectSource()
方法中实现的,代码实现如下:
1 | // ExecutionVertex.java |
在创建 ExecutionEdge 时,会根据这个 JobEdge 的 DistributionPattern
选择不同的实现,这里主要分两种情况,DistributionPattern
是跟 Partitioner 的配置有关(Partitioner 详解):
1 | // StreamingJobGraphGenerator.java |
如果 DistributionPattern 是 ALL_TO_ALL
模式,这个 ExecutionVertex 会与 IntermediateResult 对应的所有 IntermediateResultPartition 连接起来,而如果是 POINTWISE
模式,ExecutionVertex 只会与部分的 IntermediateResultPartition 连接起来。POINTWISE
模式下 IntermediateResultPartition 与 ExecutionVertex 之间的分配关系如下图所示,具体的分配机制是跟 IntermediateResultPartition 数与 ExecutionVertex 数有很大关系的,具体细节实现可以看下相应代码,这里只是举了几个示例。
到这里,这个作业的 ExecutionGraph 就创建完成了,有了 ExecutionGraph,JobManager 才能对这个作业做相应的调度。
本文详细介绍了 JobGraph 如何转换为 ExecutionGraph 的过程。到这里,StreamGraph、 JobGraph 和 ExecutionGraph 的生成过程,在最近的三篇文章中已经详细讲述完了,后面将会给大家逐步介绍 runtime 的其他内容。
简单总结一下:
参考
]]>这里我们先看下 FlinkPlan 的实现,它主要有两个实现类:StreamGraph 和 OptimizedPlan,分别对应 Streaming 和 Batch process,不管是哪种类型最后可以转换为 JobGraph:
OptimizedPlan 可以通过 JobGraphGenerator 的 compileJobGraph()
方法来转换为 JobGraph,而 StreamGraph 则可以通过 StreamingJobGraphGenerator 的 createJobGraph()
方法来转换为相应的 JobGraph。其中,StreamGraph 的整体转换流程如下图所示(下图主要展示了这个流程涉及到主要方法调用,比较核心的方法图中也加了颜色,也是本文会着重讲述的方法):
StreamingJobGraphGenerator 的 createJobGraph()
的方法实现如下:
1 | //note: 根据 StreamGraph 生成 JobGraph |
核心步骤如下:
setChaining()
方法将可以 Chain 到一起的 StreamNode Chain 在一起,这里会生成相应的 JobVertex 、JobEdge 、 IntermediateDataSet 对象,JobGraph 的 Graph 在这一步就已经完全构建出来了;setPhysicalEdges()
方法会将每个 JobVertex 的入边集合也序列化到该 JobVertex 的 StreamConfig 中 (出边集合已经在 setChaining 的时候写入了);setSlotSharingAndCoLocation()
方法主要是 JobVertex 的 SlotSharingGroup 和 CoLocationGroup 设置;configureCheckpointing()
方法主要是 checkpoint 相关的设置。JobGraph 又引入了几个概念,这里先简单介绍一下。
如果跟前面的 StreamGraph 做对比,JobGraph 这里不但会对算子做 Chain 操作,还多抽象了一个概念 —— IntermediateDataSet,IntermediateDataSet 的抽象主要是为了后面 ExecutionGraph 的生成。
这里,我们来介绍一下生成的 JobGraph 过程中最核心一步,算子如何 Chain 到一起,先看一下示例,示例与前面两篇文章的示例是一样的(这里因为图片大小限制,去掉了 filter 算子),StreamGraph 及转换后的 JobGraph 如何下图所示:
StreamGraph 转换为 JobGraph 的处理过程主要是在 setChaining()
中完成,先看下这个方法的实现:
1 | //org.apache.flink.streaming.api.graph.StreamingJobGraphGenerator |
这段代码处理完成后,整个 JobGraph 就构建完成了,它首先从会遍历这个 StreamGraph 的 source 节点,然后选择从 source 节点开始执行 createChain()
方法,在具体的实现里,主要逻辑如下(需要配合前面的代码去看,这里会把多个 StreamNode Chain 在一起的 Node 叫做 ChainNode,方便讲述):
createChain()
当前要处理的节点是 currentNodeId
,先从 StreamGraph 中拿到这个 StreamNode 的 outEdge(currentNode.getOutEdges()
),然后判断这个 outEdge 连接的两个 StreamNode 是否可以 Chain 在一起,判断方法是 isChainable()
;createChain()
方法,并且 createChain()
中的 startNodeId
还是最开始的 startNodeId
(这个标识了这个 ChainNode 的开始 NodeId),而 chainIndex
会自增加 1;createChain()
中的 startNodeId
变成了这个 StreamEdge 的 target StreamNode(相当于如果 Chain 在一起,ChainNode 中的 startNodeId 会赋值为下一个节点的 NodeId,然后再依次类推),chainIndex
又从 0 开始计;createChain()
中的 startNodeId
表示了当前可以 Chain 之后 Node 的 startId,这里,会一直递归调用,直到达到 Sink 节点。StreamConfig
对象时,判断当前的 currentNodeId
与 startNodeId
是否相等,如果相等的话,证明当前 Node 就是这个 ChainNode 的 StartNode,这里会调用 createJobVertex()
方法给这个 ChainNode 创建一个 JobVertex 对象,最后会返回一个 StreamConfig 对象,如果前面的 id 不相等的话,这里会直接返回一个 StreamConfig 对象(这个对象主要是记录当前 StreamNode 的一些配置,它会同步 StreamGraph 中相关的配置);connect()
方法创建 JobEdge 和 IntermediateDataSet 对象,把这个 Graph 连接起来;上面就是这个方法的主要实现逻辑,下面会详细把这个方法展开,重点介绍其中的一些方法实现。
两个 StreamNode 是否可以 Chain 到一起,是通过 isChainable()
方法来判断的,这里判断的粒度是 StreamEdge,实际上就是判断 StreamEdge 连接的两个 StreamNode 是否 Chain 在一起:
1 | //note: 是否可以 chain 在一起 |
这个方法判断的指标有很多,具体看上面代码就可以明白,这里着重介绍两个:slotSharingGroup
和 edge.getPartitioner()
。
先看下一个 StreamNode 的 slotSharingGroup
是如何生成的:
1 | // org.apache.flink.streaming.api.graph.StreamGraphGenerator |
一个 StreamNode 的 SlotSharingGroup 会按照下面这个逻辑来确定:
这个 StreamEdge 的属性,在创建 StreamEdge 对象会配置这个属性,先看 Flink 中提供的 Partitioner 有哪几种:
用户可以在自己的代码中调用 DataStream API (比如:broadcast()
、shuffle()
等)配置相应的 StreamPartitioner,如果这个没有指定 StreamPartitioner 的话,则会走下面的逻辑创建默认的 StreamPartitioner:
1 | //org.apache.flink.streaming.api.graph.StreamGraph |
JobVertex 对象的创建是在 createJobVertex()
方法中实现的,这个方法实现比较简单,创建相应的 JobVertex 对象,并把相关的配置信息设置到 JobVertex 对象中就完成了,这里就不再展开详细介绍了。
connect()
创建 JobEdge 和 IntermediateDataSet 对象connect()
方法在执行的时候,它会遍历 transitiveOutEdges
中的 StreamEdge,也就是这个 ChainNode 的 out StreamEdge(这些 StreamEdge 是不能与前面的 ChainNode Chain 在一起)
1 | // org.apache.flink.streaming.api.graph.StreamGraphGenerator |
真正创建 JobEdge 和 IntermediateDataSet 对象是在 JobVertex 中的 connectNewDataSetAsInput()
方法中,在这里也会把 JobVertex、JobEdge、IntermediateDataSet 三者连接起来(JobGraph 的 graph 就是这样构建的):
1 | //org.apache.flink.runtime.jobgraph.JobVertex |
到这里,createChain()
方法就执行完了,在 JobGraph 总共会涉及到三个对象:JobVertex、JobEdge 和 IntermediateDataSet,最后生成的 JobGraph 大概下面这个样子:
执行完 setChaining()
方法后,下面还有几步操作:
setPhysicalEdges()
: 将每个 JobVertex 的入边集合也序列化到该 JobVertex 的 StreamConfig 中 (出边集合已经在 setChaining 的时候写入了);setSlotSharingAndCoLocation()
: 为每个 JobVertex 指定所属的 SlotSharingGroup 以及设置 CoLocationGroup;configureCheckpointing()
: checkpoint相关的配置;JobGraphGenerator.addUserArtifactEntries()
: 用户依赖的第三方包就是在这里(cacheFile)传给 JobGraph;这几个方法的实现比较简单,这里简单看下 configureCheckpointing()
这个方法,其他三个就不再叙述了。
1 | // org.apache.flink.streaming.api.graph.StreamGraphGenerator |
到这里,StreamGraph 转换为 JobGraph 的流程已经梳理完成了,个人感觉这部分还有一些绕的,不过这种开源代码,只要看多几遍,多 debug 看看具体的执行流程,基本都可以搞明白。
参考
]]>关于分布式计算中的 Graph,对于很多人来说,最开始接触和理解这个概念应该还是在 Spark 中。Spark 中有个 DAG (Directed Acyclic Graph,有向无环图)的概念,它包括一些边和一些顶点,其中边代表了 RDD(Spark 中对数据的封装和抽象)、顶点代表了 RDD 上的 Operator,在一个作业中,一旦有 Action 被调用,创建的 DAG 就会被提交到 DAG Scheduler,它会将这个 graph 以 task 的形式调度到不同的节点上去执行计算。Spark 在 MapReduce 的基础上提出了 DAG 的概念,带来了很多的好处,比如:更方便对复杂作业(复杂的 DAG)做全局优化、通过 DAG 恢复丢失的 RDD 等等。Apache Flink 在设计实现中,也借鉴了这个设计,Flink 中的每个作业在调度时都是一个 Graph(Flink 一般叫 DataFlow Graph,Spark 中一般叫作 DAG)。另外,Google 的 Beam 也是类似的概念,Collection 和 Transformation 对数据和操作的最基本抽象,Graph 由 Collection 和 Transformation 构成。
一个 Flink 作业(Steaming 作业),从 Client 端提交到最后真正调度执行,其 Graph 的转换会经过下面三个阶段(第四个阶段是作业真正执行时的状态,都是以 task 的形式在 TM 中运行):
这整个转换的内容还是比较多的,也考虑到单篇文章的篇幅问题,这里会先给大家讲述第一部分的转换,也就是 StreamGraph 的转换,同时也会给大家把基本的概念理清楚,便于后面的讲解。
如果想对后面的内容理解更清楚,首先需要对 DataStream API 的基本概念有一定的理解,Apache Flink 自从 1.0 开始推出 DataStream API 后,经过最近几年的演化,这部分的代码已经变得比较复杂了,有些地方个人感觉还是有些冗余的,这里尽量给大家梳理清楚。
A DataStream represents a stream of elements of the same type. A DataStream can be transformed into another DataStream by applying a transformation.
上面是 DataStream 的定义,从这个叙述中,可以看出,DataStream 实际上就是对相同类型数据流做的封装,它的主要作用就是可以用通过 Transformation 操作将其转换为另一个 DataStream,DataStream 向用户提供非常简单的 API 操作,比如 map()
、filter()
、flatMap()
等,目前 Flink 1.9 的代码里提供的 DataStream 实现如下:
A Transformation represents the operation that creates a DataStream。Transformation 代表创建 DataStream 的一个 operation,这里举一个示例,看一下下面的代码:
1 | final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); |
这段代码首先会执行 addSource()
操作,它会创建一个 DataStreamSource 节点, 只有创建了 Source 的 DataStream 节点,后面才能对这个 DataStream 做相应的 Transformation 操作(实际上 DataStreamSource 节点也会有一个对应的 SourceTransformation 对象)。
1 | public <OUT> DataStreamSource<OUT> addSource(SourceFunction<OUT> function) { |
接下来再看 flatMap()
方法,这个实现其实跟前面的实现有一些类似之处,如下所示:
1 | public <R> SingleOutputStreamOperator<R> flatMap(FlatMapFunction<T, R> flatMapper) { |
分析到这里,那么 Transformation 到底是什么呢?这里之所以给大家举这个示例,也是为了让大家对 Transformation 有更深入的了解。这里看下下面这一张图,最开始是一个 SourceTransformation,然后又创建一个 OneInputTransformation 对象(这张图就是这里我们举的示例):
实际上,一个 Transformation ,它是对 StreamOperator 的一个封装(而 StreamOperator 又是对 Function 的一个封装,真正的处理逻辑是在 Function 实现的,当然并不一定所有的 Operator 都会有 Function,这里为了便于理解,就按照这个来讲述了),并且会记录它前面的 Transformation,只有这样才能把这个 Job 的完整 graph 构建出来。这里也可以看到,所有对 DataStream 的操作,最终都是以 Transformation 体现的,DataStream 仅仅是暴露给用户的一套操作 API,用于简化数据处理的实现。
Operator 最基本类的是 StreamOperator,从名字也能看出来,它表示的是对 Stream 的一个 operation,它主要的实现类如下:
open/close
方法也会调用 Function 的对应实现等;processElement()
方法需要自己去实现;processElement1()
和 processElement2()
方法。Function 是 Transformation 最底层的封装,用户真正的处理逻辑是在这个里面实现的,包括前面示例中实现的 FlatMapFunction 对象。
到这里,终于把最基本这些概念介绍完了,只有对这些概念有了相应的理解之后,阅读源码时才不至于被绕进去。
这里在讲述一个作业转换为 StreamGraph 的细节时,依然以上一篇文章中的示例 —— RandomWordCount 来讲述。在执行 env.getStreamGraph().getStreamingPlanAsJSON()
后,这个 StreamGraph 将会以 JSON 的格式输出出来,输出结果如下:
1 | {"nodes":[{"id":1,"type":"Source: Custom Source","pact":"Data Source","contents":"Source: Custom Source","parallelism":1},{"id":2,"type":"Source: Custom Source","pact":"Data Source","contents":"Source: Custom Source","parallelism":1},{"id":4,"type":"Flat Map","pact":"Operator","contents":"Flat Map","parallelism":8,"predecessors":[{"id":1,"ship_strategy":"REBALANCE","side":"second"},{"id":2,"ship_strategy":"REBALANCE","side":"second"}]},{"id":6,"type":"Filter","pact":"Operator","contents":"Filter","parallelism":8,"predecessors":[{"id":4,"ship_strategy":"SHUFFLE","side":"second"}]},{"id":8,"type":"Keyed Aggregation","pact":"Operator","contents":"Keyed Aggregation","parallelism":8,"predecessors":[{"id":6,"ship_strategy":"HASH","side":"second"}]},{"id":9,"type":"Sink: Print to Std. Out","pact":"Data Sink","contents":"Sink: Print to Std. Out","parallelism":2,"predecessors":[{"id":8,"ship_strategy":"REBALANCE","side":"second"}]}]} |
在 Flink Plan Visualizer中可以看到 StreamGraph 可视化之后 graph(用 Chrome 打开可能会显示不全,可以试下 Firefox),如下如所示:
接下来,详细介绍一下 StreamGraph 是如何转换的。
1 | // StreamExecutionEnvironment |
StreamGraph 最后是通过 StreamGraphGenerator 的 generate()
方法生成的,那这个方法到底做了什么事情呢?其实现如下:
1 | //note: 构建 stream graph |
最关键的还是 transform()
方法的实现,这里会根据 Transformation 的类型对其做相应的转换,其实现如下:
1 | /** |
这里以 transformOneInputTransform()
的实现来举个相应的例子,它会给这个 Transformation 创建相应的 StreamNode,并且创建 StreamEdge 来连接前后的 StreamNode:
1 | private <IN, OUT> Collection<Integer> transformOneInputTransform(OneInputTransformation<IN, OUT> transform) { |
经过上面的 transform()
操作,最后生成的 StreamGraph 样板如下图所示:
关于上面的 transform()
,还有一个需要注意的是:这三个实现方法 transformSelect()
、transformPartition()
、transformSideOutput()
在操作时,并不会创建真正的 StreamNode 节点,它们会创建一个虚拟节点,将相应的配置赋给对应的 StreamEdge 即可。另外对于 transformUnion()
方法,它连虚拟节点也不会创建,原因其实看源码也能明白,它们并不包含具体的处理操作。
到这里,StreamGraph 的创建过程就分析完了,如果理解了 Flink 基本对象的抽象后,再去看这部分代码,实际上并不复杂,这里是对用户的作业逻辑做了一个最简单的转换,并没做什么优化操作,相当于还是原生的用户作业逻辑。
参考
本篇的题目是 Apache Flink 初探,比较适合对 Flink 不是很了解,想进一步了解的同学,主要会讲述一下流计算的基本知识,以及对 Flink 做了一个简单的介绍,算是这个系列的开胃小菜。
关于流计算,业内有一本口碑神一般存在的书,那就是大名名鼎鼎的《Streaming Systems》,这本书对流计算领域的问题及技术做了很深的讨论,如果你看过相关的内容,你就会发现 Flink 实际上就是开源届里实现最接近 DataFlow 模型的框架,这里先给大家介绍一下流计算相关的背景知识,对于后面理解 Flink 的设计,特别是高阶 API 的设计(实际上 DataStream API 就是为 DataFlow 模型而开发的)。
计算的数据源可以有很多种类型,比如:电商的交易数据、用户行为日志、物联网数据等,这些数据集可以分为两类:
从另一个角度来看,无边界数据集更符合现实中数据的产生方式,这样的话,就可以认为有边界数据集是无边界数据集的一个特例或一个子集。
在分布式计算中,关于时间域有两种类型:
简单来说,事件时间是事件真实发生的时间,而处理时间是事件在计算引擎中被处理的时间,理想情况下,两者是相等的,但在实际情况下,它们之间差距的影响因素非常多,可能跟软件、硬件或数据有关,并且这个差距毫无规律可言,如下如图所示:
上面的问题给流计算带来了很多的问题,而且由于数据的无边界特性,业内通常的做法是将输入数据进行 window 操作(本质上还是按照时间切片),而对于一些关注 Event Time 的应用来说,按照 Processing Time 做 window 是完全无法满足需求的(流计算之前困扰大家最大的问题之一就是这个准确性的问题)。
目前常用的 window 类型有以下几种:
Window 在 Event time 和 Processing time 下都是有意义的,只是适用于不同的应用场景而已,而对于 Event time 场景,如何来保证一个窗口数据的完整性呢?而窗口数据的完整性又确定了数据的准确性。
Watermark 就是来就解决这个问题的,它用于界定什么时间(时间戳)认为一个时间窗口内的数据已经全部到齐,之后晚于该 watermark 到达的数据则为迟到数据。
计算任务可以分为有状态计算和无状态计算:
对于批处理,每次处理的都是全量数据,所以就不用考虑状态这个问题。而流处理,一般会借助外部存储系统实现状态保存(这个对应的 Flink 中 State 模块的内容)。
流计算另一个难点的是容错恢复,如何保证恢复之后作业状态的一致性,目前业内通用的解决方案采用的是 Chandy-Lamport 算法(有兴趣的可以看下 Paper 阅读: Distributed Snapshots: Determining Global States of Distributed Systems),包括 Structured Streaming 也采用的这个方案。
到这里,把流计算的基础知识简单过了一下,想了解更多的同学,建议阅读一下 Google DataFlow 那篇论文或者《Streaming Systems》这本书(Apache Flink 零基础入门(一):基础概念解析 这篇讲述得也不错)。
流计算领域的开源框架,不可谓不多,但到现在还能让大家记住的(或者对业内产生巨大影响的)其实并不多,通常大家对比的也就是:Storm/Spark Streaming/Flink。在 17 年之前,我们在面临流计算技术选型时还可能会徘徊一下,但如果放在现在,你会发现,几乎没有太大可比性,几个引擎的差距已经很大,简单对比一下(只列出了流计算中重点关注的特性,只是粗略的比较,勿喷):
一圈比较下来,你会发现 Flink 真的是流计算的最佳选择,当然选择 Flink 还有其他很多的原因,可以参考阿里官方给出这两篇文章:
Apache Flink 采用经典的分布式架构设计 —— Master/Slave 架构,Flink 集群的架构图如下图所示,这张图展示了其整体结构,但是很多内部细节并没有展示,我也翻了很多的博客,也没有找到一张特别满意的架构图。
一个 Flink 集群,主要包含了两个核心组件:
JobManager/TaskManager 都是进程级别,TaskManager 在启动时,会根据配置将其内部的资源分为多个 slot,每个 slot 只会启动一个 Task,Task 是线程级别,从这里可以看出 Flink 是多线程调度模型,一个 TM 中可能会有来自多个任务的 task,从资源利用的角度看,这样的设计是有一些收益的,但是从资源隔离的角度看,这种设计就不是那么好了,不过好在现在业内的使用方式基本都是 On Yarn 的单集群单作业模式,相当于把资源隔离这个问题避过去了,但不可否认,这种设计是有缺陷的。
关于 Flink 的部署,这里推荐一下这几篇文章,本文就没有必要再整理了:
如果你想自己编译 Flink 安装包的话,可以参考 Flink Readme - Building Apache Flink from Source,这里给了几个不同的编译命令,最终的结果是一样,都可以正常编译出安装包:
1 | # 删除已有的 build,编译 flink binary |
最后生成的安装包就在 flink-dist/target/flink-1.9-SNAPSHOT-bin
下,在 flink 目录下也会生成一个 build-target
的软连。
这里有一个示例,见 RandomWordCount(后面的文章也会以这个示例讲述),这个示例比较简单,就是先模拟两个数据源,再对流做 union, 再做过滤,最后再做 WorkCount,这个作业可以在 Flink 工程中直接运行。
1 | public class RandomWordCount { |
开胃小菜到这里就结束了,后面会逐步给大家剖析 Flink 的内部实现原理与机制,其实整个 Flink 的代码可以三大块:
The distributed snapshot algorithm described here came about when I visited Chandy, who was then at the University of Texas in Austin. He posed the problem to me over dinner, but we had both had too much wine to think about it right then. The next morning, in the shower, I came up with the solution. When I arrived at Chandy’s office, he was waiting for me with the same solution.
另外,如果你只是想要明白这个算法是怎么做的,可以直接看这篇文章 —— 分布式快照算法: Chandy-Lamport 算法,它讲得更通俗易懂,本文更多的是论文的角度来讲述,会详细介绍一下这个算法的数学证明。
分布式系统的很多问题都可以归结于获取 global states 的问题,比如:
但是获取一个系统的 global states 并不是一件容易的事情,对于一个分布式系统而言,我们需要在同一个时间点记录下这个系统的全局状态,它包括每个 process 的状态以及相关 channel 的状态(一个计算是由有限的 process 和 channel 组成的一个 graph)。这就好比:在一个满是候鸟的天空大场景下,这个场景大到一张照片无法全部覆盖,摄影师不得不拍摄多张照片,然后把它们合并成一张全景,因为多张照片不能同时拍摄、在拍摄过程中候鸟也不会静止不动,所以如何保证合成的全景照片是有意义的(它可能少拍了某些鸟或者多拍了某些鸟)?这个就是分布式快照算法要解决的问题,因为没有全局统一的一把锁,所以不可能保证所有 process 能在同一时刻记录他们的状态信息。
一个分布式系统包含一个有限的 process 集合和有限的 channel 集合,它可以通过一个有向的 graph(顶点代表process、边代表 channel)来描述,如下图所示:
Channel:这里为了便于解释,文章会假设一个 channel 有一个无限、零错误、有序传输的 buffer(否者还要考虑 buffer 是否 full 的情况),channel 中数据的延迟是任意的并且有限的。一个 channel 的 state 就是它从上游收到的 msg list 减去下游已经接收到的 msg list;
Process:它是由一组状态、一个初始状态和一组 event 来定义。process p
中的一个 event e
代表一个可能改变 p
本身状态和对应 channel c
状态(c
发送或接收数据都可能会改变其状态)的原子操作。
一个 event e
被定义为 $<p, s, s’, M, c>$,其中:
p
是 event 产生的地方;e
之前 p
的状态是 s
;e
之后 p
的状态是 $s’$;c
它的状态会被 e
所改变;c
的 msg;如果 event 没有改变任何 channel 的状态,那么 M 和 c
则为 null,可能只改变了 p
的状态(这个概念很重要,需要好好理解,是后面论证的基础)。
有了前面的模型抽象,这里我们可以认为一个分布式系统的 global state 就是这批 process state 和 channel state 的集合。初始的 global state 就是每个 process 都是其对应的初始状态以及每个 channel 都是 empty 集合。
一个 event e
可能会改变 global state(这里记为 S
),这里定义另外一个函数:$next(S, e)$,它指的是 event e
发生在 global state S
之后的 global state,根据前面介绍的,e
处理后的 global state 变化是:p
的状态由 s
变为 s'
,Channel c
的状态是在原来的基础上加上(数据是发向 Channel c
的)或删除(数据是发离 Channel c
的) msg M。
这里再定义一个 $seq = (e_i: 0 \leq i \leq n)$,它代表的是这个分布式系统将要处理的 event 序列,这个 $seq$ 实际上就是 a computation of the system
(这个 event 序列就代表了这个分布式计算),假设在 $e_i$ 处理前,系统的 global state 是 $S_i$(系统的初始状态时 $S_0$),那么可以得到下面公式:
$S_{i+1} = next(S_i, e_i)$, for $0 \leq i \leq n$
论文中举了两个示例,这里也介绍一下,对于理解后面的论证是有帮助的。
先看一个最简单的计算系统,这个系统有两个 process p
和 q
,有两个 Channel c
和 c'
(下面第二个示例也是这种基本模型),如下图所示:
在这个系统中,有一个 token
它在两个 process 之间传输处理,每个 process 都有两种状态:$s_0$ 和 $s_1$,如果这个 process 不包含 token
,它的状态就是 $s_0$,如果包含 token
的话,它的状态就是 $s_1$,p
的初始状态是 $s_1$,q
的初始状态是 $s_0$。而对每个 process 而言,都会有两种 event 类型(这里根据这个例子理解前面 event 的概念,如上面的图中所示):
token
时,process 状态从 $s_1$ 转为 $s_0$;token
时,process 状态从 $s_0$ 转为 $s_1$。对于 global state 而言,可能会出现四种不同的状态,如下图所示:
在上面图中,四种状态实际是跟 token
所在的位置有关:in-c
,in-p
、in-q
、in-c'
。这个示例比较简单,但它跟后面作者提出的算法来源的灵感有关。
这里依然是两个 process p
和 q
,它们的状态转移图如下所示:
Example 1 的示例比较比较简单,在每个 global state 中正好只有一个 event(一个状态转换),但是在真实的系统中,很多情况下是一些非确定性计算(nondeterministic computation),可能同时会有多个 event 一起转换,比如:p
发送 M
和 q
发送 M'
这两个 event 同时发生(下面就是这两个 event 同时发生的情况,如下图的 global state $S_2$),那么得到的 global state 就会与预期的不同。下图是这个系统可能的一个 global state 转移情况:
Notice: 这个示例,我在看的时候,最开始一直没有搞明白,主要在 $S_2$ 这一步没有明白,后来仔细想了几次,算是明白了,这个示例举得的是一个非确定计算的示例,上面也只是系统可能出现中的一种状态,比如:p
在发送 M
之后,M
在 Channel c
中还没有被 q
接收到,q
就发送了 M'
。或者换成另一种理解方式,p
发送 M
和 q
发送 M'
同时发送,上图只是把两个拆开了一下展示,于是就有了 global state S1 和 S2,再接着有可能发生的就是 S3 的情况,p
接收到了 M'
,状态发生了变化。这里,把这个示例当作一种在现实系统中的非确定性计算就好理解了。
下面开始进入到算法的核心部分,这里作者介绍了一下算法的由来,以及在数学上的证明。
Global state recording 算法工作过程如下:
因为没有一个全局的锁,所以我们无法保证,所有的 process 和 Channel 都是在同一时刻记录的。因此,我们需要保证记录的 process 和 Channel 状态能够组成 一个有意义的 global state。
这个算法是与跟底层计算嵌套在一起,但是不会对计算产生改变、也不会影响底层的计算。这里通过一个示例来逐步引出我们的算法,假设我们是可以很自然地记录 Channel 的状态,Channel c
是 process p
和 q
的之间的传输通道,下面来分析一下它们之间的状态关系。
p
与 c
状态之间的关系这里以前面 Single-token Conservation 的示例来分析,假设 process p
的状态记录在 global state in-p
中,p
记录的状态显示 token
是在 p
中。现在假设 Channels c
和 c'
以及 process q
的状态时记录在 global state in-c
中的,同样 c
中记录的状态也显示 token
在 c
中(因为无法保证它们在同一时刻记录,所以每个组件是有可能在不同的时刻记录)。组成的 global state 显示系统中有两个 token
,一个是在 p
中、一个是在 c
中。但是由于这个系统是 single-token,它是不可能同时出现两个 token
的,所以一定是哪里有问题了,这样组成的 global state 不是有意义的。先定义两个变量:
p
的状态记录前,p
发往 Channel c
的 msg 数;c
的状态记录前,p
发往 Channel c
的 msg 数;上面出现的情况就是 $n < n’$.
假设另一种情况,c
的状态记录在 in-p
中,而 p
、q
、c'
的状态记录在 in-c
,那么这样组成的 global state 会显示系统没有 token
,这个组成的 global state 同样也是没有意义的,这就是 $n > n’$ 的情况。
从前面的分析中,可以得到:这里有个一致的全局状态要求
$n = n’$
q
与 c
状态之间的关系这里,再定义另外两个变量:
q
的状态记录前,q
从 Channel c
中接收到的 msg 数;c
的状态记录前,q
从 Channel c
中接收到的 msg 数;跟前面的分析类似,这里也会有一个一致性的要求:
$m = m’$
在任何一种状态下,都要求 Channel c
下游接收到的 msg 数不能超过 p
发送给 Channel 的 msg 条数,即:
$n’ \geq m’$ 以及 $n \geq m$
现在来分析一下 Channel 的状态要记录什么数据? 一个 Channel 要记录的状态是,它 sender 记录自己状态之前它所接收到的 msg 列表,再减去 receiver 记录自己状态之前它已经收到的 msg 列表,减去的之后的数据列表就是还在通道中的数据列表,这个列表是需要 Channel 作为状态记录下来的。而如果 $n’ = m’$,那么 Channel c
中要记录的 msg 列表就是 empty 列表。如果 $n’ > m’$,那么要记录的列表是 $(m’+1)st , … n’$ 对应的 msg 列表。
重点,重点,重点:分析到这里之后,就有了下面的一个灵感:那么 Channel c
状态要记录的 msg 列表是可以在 q
中记录的。那么具体怎么做的?就是在发送数据中插入一条特殊的数据 —— marker
数据,这条数据不会对计算有任何影响,那么 c
的状态就是 q
在记录自己状态之后并在收到 marker
之前接收到 msg 列表,另一种情况就是 q
收到 marker
之后,就必须要把自己的状态记录下来(伟大的算法就这样诞生了)。
对于发送者来说:
p
记录自己的状态之后它先向 Channel c
发送一条 marker
,然后才会继续发送数据信息;对于接收者来说:
q
已经还没记录自己的 state,在收到 marker
之后,它会记录自己的状态,并且把 c
的状态设置为 empty;q
已经记录自己的 state,它会把从 c
接收到的数据作为 msg 列表当作 c
的状态记录下来。关于算法能够在有限时间内结束,是有两个前提的:
marker
数据不会在 Channel 阻塞永远发不出去的;有了这两个前提,一个 graph 中每个 process 都会收到相应的 marker
,然后都会记录自己的状态,所以这个是完全可以保证能够在有限的时间内完成。
事先说明,这里证明比较烧脑,我尽量描述清楚,最开始看论文也是看了好几遍、想好几遍把整个过程捋顺,当然如果理解有误,欢迎指正。
以前面 Example 2 的示例来讲述,假设在 global state $S_0$ 时,p
记录下了自己的状态(A
),然后 p
向 Channel c
发送一条 marker
数据(它是在 M
数据之前),假设这个时候系统在正常运行,已经经历 $S_1$、$S_2$,到了 $S_3$ 阶段,但是 marker
数据在传输中。q
在收到 marker
之后,它记录了一下自己的状态 D
(对应 c
的状态为空),然后再发送一条 marker
数据给 Channel c'
。p
因为之前已经记录过自己的状态,所以在收到 c'
传过来的 M'
之后(p
先收到 M'
然后才会收到 q
发送的 marker
消息),会把它作为 Channel c'
的状态记录下来。
整个流程下来,组合的 global state 是 $S_*$,如下如所示:
可以看到这里算法得到的 global state $S_*$ 与真实环境下的 global state($S_0$、$S_1$、$S_2$、$S_3$)都不相同。
那么来考虑一个问?题:如果算法记录的状态,在真实环境中并没有实际存在过?那么这个 global state 有什么用呢?(或许大家之前都理解了这个算法,但很少有人会去思考深入这个问题)
假设 $seq = (e_i, 0 \leq i)$ 是一个分布式计算(是一个 computation),global state $S_i$ 是在 event $e_i$ 处理前系统当时的全局状态(这个是真实的那个时刻的状态)。假设算法在计算 global state 时是在 $S_t$ 时初始化,并且在 $S_ø$ 前终止的(算法在计算全局状态时会横跨多个 event),也就是 $0 \leq t \leq ø$,那么如果我们能证明下面的结论,基本上回答了上面的问题:
如果能证明这个,那就说明,算法得到的 global state 是可以由之前的 global state 得到,并且得到后面的 global state,从工程上来理解就是,算法得到的 global state 是可以完整正确得恢复计算作业的状态信息,让作业继续运行。
前面的是结论,这里将证明转化为:存在一个序列 $seq’$,它可以满足以下条件:
一个更加数学化的描述(方便后面证明):一定存在一个 computation $seq’ = (e’_i, 0 \leq i)$,它满足以下条件:
这里实际要证明的是,找到这个 $seq’$,并且找到上面第四条要求的 $k$。
为了证明上面的结论,这里引入两个概念:
p
中的 event $e_i$,如果 p
做 snapshot(记录自己的状态)发生在收到 $e_i$ 之后,那么这个 $e_i$ 就是 prerecording event(也就是说:做 snapshot 时这个 $e_i$ 已经处理过了);p
中的 event $e_i$,如果 p
做 snapshot(记录自己的状态)发生在收到 $e_i$ 之前,那么这个 $e_i$ 就是 postrecording event(也就是说:这个 $e_i$ 是在做完 snapshot 后才处理的);因此,对于 $e_i, (i < t)$,都是 preEvent,对于 $e_i, (i \geq ø)$,都是 postEvent。
对于一个真实的 computation,可能会出现一个 postEvent $e_{j-1}$ ($i < j < ø$)出现在 preEvent $e_j$ 之前,当然这种情况只可能是 $e_{j-1}$ 和 $e_j$ 出现在不同的节点上(大家可以反向思考一下:对于同一个节点来说,event 的处理会保证 FIFO,如果 $e_{j-1}$ 是 postEvent,那么 $e_j$ 必然也是 postEvent)。
接下来,我们证明一下下面的结论:
对于一个 event 序列 $seq’$($seq$ 序列的变形),在这个序列中,所有的 preEvent 都在 postEvent 之前,下面我们将要证明 $S_*$ 就是 $seq’$ 中处理完所有 preEvent 后的 global state。
这里假设有一个 postEvent $e_{j-1}$ ($i < j < ø$)出现在 preEvent $e_j$ 之前,这里我们将证明 交换 $e_{j-1}$ 和 $e_j$ 的位置之后得到的新 $seq’$ 序列依然是一个 computation(与原来的计算是保持一致的,只不过在中间某些时刻它们当时的状态不完全相同)。根据前面的叙述,这里的 event $e_{j-1}$ 和 $e_j$ 肯定是在两个不同的 process 上的。这里假设 $e_{j-1}$ 发生在 p
上,$e_j$ 发生在 q
上。
首先经过分析可以得到:绝对不可能出现 $e_{j-1}$ 发送一条数据然后在 $e_j$ 中收到,通过反证法分析:
c
向 q
发送了一条数据,那么在发送数据前一定已经有了 marker
发送过去(因为 $e_{j-1}$ 是 postEvent);c
中获得了这条数据,那么在这之前一定先收到了 marker
数据,这样的话,$e_j$ 也变成了 postEvent,所以这种情况是不可能存在的(这里不是很容易理解,可以换一种思路理解,它说明了 $e_{j-1}$ 和 $e_j$ 之间是没有因果关系的)。因为 $e_{j-1}$ 是发生在 p
中的,所以当 $e_{j-1}$ 发生时,q
的状态是不会改变的(可以回顾一下前面关于 event 的公式定义)。而假如 event $e_j$ 触发时,q
会从 Channel c 收到一条数据 M
,那么 M
一定是在 Channel c
中队列的头部,并且是在 $e_{j-1}$ 之前,因为 $e_{j-1}$ 发出的数据是不可能会在 $e_j$ 中接收到的。因此,$e_j$ 可以出现在 global state $S_{j-1}$ 中(这个可以在回顾一下前面关于 $S_j$ 的定义,实际这段说明了 $e_j$ 可以出现在 $e_{j-1}$ 之前,因此也就有了这个结论)。
而且在 $e_j$ 发生时,p
的状态并没有改变,因此,$e_{j-1}$ 是发生在 $e_j$ 之后的。那么也就是说明 $e_1, e_2, … , e_{j-2}, e_j, e_{j-1}$ 也是一个 computation,而且在经过这个 $e_1, e_2, …, e_{j-2}, e_j, e_{j-1}$ 计算之后的 global state 也跟 $e_1, e_2, …, e_{j-1}, e_j$ 计算之后的 global state 是一致的(主要还是因为 $e_{j-1}$ 和 $e_j$ 之间是没有因果关系的)。
假设 $seq^*$ 也是 $seq$ 的序列的一个变形,它只是把交换了 $e_{j-1}$ 和 $e_j$ 的位置,假设 $\overline{S_i}$ 是 $seq^*$ 对应的瞬时(就是当时那一刻系统的真实状态,i
对应的是序列第几个 event)全局状态,则有下面的公式:
$\overline{S_i} = S_i, i \neq j$
如果将前面的 postEvent 与后面紧贴的 preEvent 的位置互换,将会存在一个 $seq’$($seq$ 序列的一个变形),使得:
现在我们证明: 这个 $seq’$ 序列中所有 preEvent 处理完之后的 global state 就是 $S_*$ ,只需要证明下面两点即可:
p
的状态是与 p
处理完所有 preEvent 之后的状态相同的,这个并不用证明,因为 perEvent 的概念就是这样来的,它指的就是那些在 snapshot 前要处理的 event 列表;c
的状态:所有 preEvent 发往 c
的数据列表,减去所有 preEvent 从 c
接收到数据列表。这里看下上面的第二点:假设 c
是 process p
和 q
之前的 Channel,$S_*$ 中关于 Channel c
的状态指的是,q
在记录自己的状态后在收到 marker
前从 c
收到的数据列表。而 c
在收到 marker
前接收到的数据列表都是 preEvent 发送过去的,所以上面第二点也就是完全得证了。
到这里,整个证明就结束了,我在看原论文的时候,这个证明看得云里雾里,看了好几遍才理解这个证明逻辑,这里比较关键的点有两个:
最后,作者给出了前面 Example 2 的解释,前面状态转移图中,发生的 event 事件列表如下:
p
发送 M
,并且将状态转移成 B(这是一个 postEvent,在这之前 p
已经记录了自己的状态);q
发送 M'
,并且将状态转移成 D(这是一个 preEvent,因为它发送在 q
记录自己状态之前,根据前面的讲述,因为 q
是 global state $S_3$ 时收到的 marker
,当然这里只是其中一种情况,这里就是解释前面的所述的情况);p
接收到 M'
,并且将状态转移成 A(这是一个 postEvent,以为在这之前 p
已经记录了自己的状态)。根据上面的证明,这里的 $seq’$ 序列就是 $e_1、e_0、e_2$,而前面图中记录的 global state 就是系统在处理完 $e_1$ 之后的结果。
这篇论文的思想还是比较容易理解的,比如 分布式快照算法: Chandy-Lamport 算法 这篇文章介绍得就很简洁清晰,在我前面的文章 Paper 阅读: Lightweight Asynchronous Snapshots for Distributed Dataflow 中也讲述了 Flink 是如何将这个算法在落地应用的,但是这个算法的证明,并不容易。在看这篇论文之前,我并没有想过这个算法应该怎么证明?因为我潜意识的认为这是一个很容易理解、很正确的算法,甚至感觉完全不需要证明,就像苹果就应该从树上落到地上一样。但是看完这篇论文之后,才不得不佩服 Lamport 大神的牛逼之处,它不但提出了这个算法,还给这个算法找到理论上的证明方法,虽然论文并不是那么容易理解,但看完看明白之后收获很大,再次向 Chandy 和 Lamport 致敬~
参考:
]]>分布式有状态的流处理允许在云上部署和执行大规模的流数据计算,并且要求低延迟和高吞吐。这种模式一个比较大的挑战,就是其容错能力,能够应对潜在的 failure。当前业内的方案都是依赖周期性地全局状态的 snapshot 做 failure recovery。但这种方案有两个非常大的缺陷:
本篇论文中提出了一个新的 global consistent snapshot 算法 —— Asynchronous Barrier Snapshot(ABS),它是一个轻量级的算法,非常适合现代 dataflow 系统,数据存储空间占用也非常小(论文原话是 Our solution provides asynchronous state snapshots with low space costs that contain only operator states in acyclic execution topologies.
)。另外,这个算法不会影响作业计算,性能开销比较小。
在过去的几十年中,关于连续处理系统的 recovery 机制,工业界和学术界提出了很多种解决办法,如: Distributed Snapshots: Determining Global States of Distributed Systems) 和 Naiad: A Timely Dataflow System。有一些系统如 Discretized Streams 和 Comet 会把连续处理当作 无状态的分布式批处理计算 来做状态恢复;对于有状态的 dataflow 系统,如:Naiad、SDGs、Piccolo 和 SEEP,它们是我们的主要关注点,它们使用 checkpoint 获取全局一致的 snapshot 来做故障恢复。
关于 consistent global snapshot 的问题,自从在 Chandy 和 Lamport 的论文中提出来后,过去二十多年一直在被广泛地研究。全局 snapshot 理论上反映了作业执行的总体状态以及 operator 实例的可能状态。对于全局一致性 snapshot 算法,Naiad 中提出了一个简单但代价非常高昂的实现方案:
这个实现方案对吞吐和空间占用都有很大的影响,它并不是一个很好的方案。另一个实现方案,就是 Chandy-Lamport 算法,当前它已经应用在很多的系统中,它是异步地执行快照,并且要求上游数据源可以回溯(也就是要求数据源能够自己备份)。它是通过在数据流中发送 marker 来实现,marker 会触发 operator 和 state 的 snapshot。但这种算法还需要额外的存储空间用于上游数据量恢复,数据流的重新计算也会导致恢复时间较长(主要还是原生算法会对一些 record 也做相应的 snapshot,这会导致存储空间占用过高以及恢复时间过长)。本论文中提出的方案扩展了原生的 Chandy-Lamport 算法,但对于无环 graph 它不会备份未处理及通道中正在传输的 record,对于有环的 graph,它也只需要很少量的 record 备份。
因为这个算法的实现本身就是为了解决 Apache Flink 的容错问题,论文中的描述也是以 Flink 系统为例,所以想要搞明白这个算法还是需要一些 Flink 的基础,本文中,我们就不再对 Flink 展开了。这里只简单介绍一下,有兴趣的可以看下官网资料,Flink 是一个可用于 Streaming 和 Batch 处理的大数据处理引擎,它本身的设计也是深受 Google DataFlow 模型的影响,可以说 Flink 是开源系统中最接近 DataFlow 思想的一个计算引擎。另外,Flink 的作业,在提交的时候都会被翻译成一个有向无环图(DAG),对于 Flink Master 来说,提交过来的作业都是一个 graph。
这里,我们这样定义一个 global snapshot(它需要包含所有的状态信息,这样才能保证 failover 之后能够正确恢复状态):
$G^*=(T^*, E^*)$
它代表一个 execution graph $G = (T, E)$ 的一个全局快照,$T^*$ 代表所有 task 状态的集合,$E^*$ 代表所有 edge 状态的集合。也就是说:
为了能够保证 recovery 后正确恢复状态信息,对于每个 $G^*$,都需要保证以下两个特性:
这里先看下在无环 dataflow 中 ABS 是如何实现的,因为 Flink 只支持有向无环图,所以这个就是 Flink checkpoint 的实现方案。
当把一个作业的执行逻辑划分为多个 stage 时,做 snapshot 不存储 channel 中的 state 是完全有可能的。如果一个 operator 已经完成了对输入的所有计算,并且数据已经完全输出出去,那么只对 operator 的 state 做 snapshot 就可以达到我们的要求。
具体的实现就是:每个阶段的输入数据都会被周期性地插入一些特殊标记 —— barrier,这些 barrier 会推送到整个 dataflow 中直到 sink 节点(dataflow 中结束节点,它没有下游输出),每个 task 如果收到输入所有的 barrier 就开始做相应的 snapshot。这个算法的实现是有以下假设的:
blocked
和 unblocked
,如果一个通道是 blocked
,它会把这个通道接收到的所有数据缓存起来先不发送,直接收到 unblocked
的信号才会发送;blocked
、unblocked
和 send
msgs,Broadcasting
msgs 表示的是向下游所有的 channel 发送数据;Nil
输入通道(一个虚拟通道)。这个算法的伪代码如下:
1 | # Algorithm 1 Asynchronous Barrier Snapshotting for Acyclic Execution Graphs |
dataflow graph 执行图如下所示:
ABS 算法的执行流程如下:
根据前面所示,我们知道,当前一个完整的 snapshot $G^* = (T^*, E^*)$,其 $E^* =0$,Operator 中的 state 信息就是完整的 snapshot。
对于 Termination 要求,它依赖于 channel 的可靠性以及 graph 的无环性;对于 Feasibility 要求,它依赖于 channel 的 FIFO 特性。只要这些是可以满足的,那么这两个要求就是可以满足的。
前面分析完无环的情况,接下来再来看看有环的情况,当前的 ABS 算法稍微做些改造也是可以处理有环的情况。根据前面的介绍,有环带来的最大问题是:
对于有环的情况,论文是在不引入额外 channel block 的情况下扩展了原来的算法,这里就不再列出伪代码了,有兴趣的可以看下论文,这里以下图为例简单介绍一下:
按照改进后的算法,是可以避免死锁的,这样的话 Termination 的要求是可以满足的;Feasibility 的特性依然是依赖于 channel 的 FIFO 来保证,snapshot 中每个 task state 都会包含该 task 在收到前置节点 barrier 之后的状态,对于有后置节点输入的 task 来说,它会把从后置节点接收到的数据记录下来,只会 copy 非常少量的数据。
论文还简单介绍了 Flink 是如何做 failover 恢复的,有了前面的全局一致 snapshot 算法,failover 做起来就简单很多。在 Flink 中,还支持 partial graph recovery,对于失败的 task,只需要恢复它的上游即可,并不需要全局恢复。为了在内部实现 exactly-once,通过给数据进行编号来避免重复数据。
性能对比了本文提出的 ABS 算法以及 Naiad 中提出的全局同步 snapshot 算法,测试 case 选择了一个有 6 个 operator 的作业,它有三个地方会进行网络 shuffle,这样可以尽量增大 ABS 算法 channel block 带来的影响(如下图 5)。实验中,输入端会模拟 1 百万测试数据,operator 的状态信息主要包括按 key 聚合的中间结果以及 offset 信息,下图的纵坐标是作业运行时间,baseline 表示的是不开启 snapshot 时的性能,在这里做对比使用。
如上图 6,可以看到,当 snapshot 时间间隔非常小,同步的 snapshot 性能非常差,因为它在做 snapshot 会阻塞计算,时间都花费在 snapshot 上了,而 ABS 算法的实验结果就好了很多。如上图 7,集群节点及作业并行度从 5 逐渐增加到 40,可以看到 ABS 算法的性能还很稳定的。
这篇论文是工业界对 Chandy-Lamport 算法实践后做的改进优化,将 Chandy-Lamport 算法在 Flink 的 global consistent snapshot 中落地,这篇论文还是非常值得读一下的,看下 Flink 在解决这个问题的时候是怎么去做的,论文的优化点其实并不是很大,一是把同步变成异步,二是从尽量减小存储空间占用的点出发,最后发现只存储 operator 状态不存储 edge 状态也是完全可以的,而且实践起来的效果确实证明比当时其他系统的算法要好。
参考
]]>本章不会严格按照论文翻译,整体会按照下面的思路来叙述:
- 遇到的问题什么?
- 当前业内的方案是什么?
- 论文提出了什么样的解决方案?达到了什么效果?
现在有越来越多的 ML 应用,不仅仅使用静态模型进行训练预测,它们会使用动态、实时决策的反馈来实时调整应用,这种场景就给计算模型提出了一些新的要求:
这些要求如果单独去实现的话,并不是很难,但是把它们在一套系统里同时实现就非常有挑战性了,而目前业内并没有这样的一套方案(指的是这套系统设计之前,业内还没有)。
这里,我们看个示例,下图 a 是一个传统的 ML 应用架构,它主要使用离线数据做训练和预测(ML 中监督式学习),但是现在有个趋势,就是如下图 b 所示的构架越来越多,即 ML 应用与实时反馈的回路紧密集成,它会依赖实时数据做训练和预测。
对于前面提出的场景,对模型的灵活性和性能有了新的要求,在满足这些要求的同时,还要保持现代分布式执行模型的优势(比如:应用级别的容错保证等等),挑战性很大,根据之前在 Spark、MPI 和 TensorFlow 开发 ML 和 RL(强化学习)的经历,这些痛点更加明显。当然这些要求也是通用的,并不仅仅使用在 ML 和 RL 中。
这些 ML 应用也是有严格的延迟和吞吐要求:
尽管现在业内很多的执行模型已经对常见计算模式的识别和优化取得了很大进展,但 ML 应用还需要更大的灵活性:
上面的要求与我们常见的大数据计算系统,如:Flink 和 Spark,最大的区别是 R3~R5,对于 Flink 和 Spark 来说,向集群提交的 dataflow graph 是固定的,提交之后是不能改变的,这种模式在 ML 场景就显得非常不灵活了。
Static dataflow Systems,它们需要开发者提前设计好 dataflow graph,比如:MR 和 Spark。对于其他的像 Dryad 和 Naiad 的系统,它们是支持复杂的依赖结构(R5);TensorFlow 和 MXNet,它们对深度学习场景做了很多优化。然而没有一个系统,可以完全支持根据输入数据和 task 执行任意动态扩展 dataflow graph。
Dynamic Dataflow Systems,像 CIEL 和 Dask 不但支持上面 static dataflow Systems 的很多特性,还支持动态 task 创建(R3),这些模型符合我们 R3~R5 的需要。然而,它们有一些受限的地方,比如:完全中心化的调度,它们会导致我们不得不在吞吐和 latency 之间做取舍。
Other Systems 像 Open MPI 和基于 actor 模型变体的系统(Orleans 和 Erlang)提供了低延迟(R1)和高吞吐(R2)的分布式计算。尽管这些系统也可以支持我们执行模型的需要(R3-R5,并且已经在 ML 中应用了),但是很多系统 level 的逻辑需求却需要应用程序自己去实现,比如:容错和 task 调度的本地感知。
综上,业内并没有一套可以完全符合我们的需求的系统,所以最好的办法就是重新造轮子,从头开始设计和写一套系统,业内对这块也有了很多的实践,虽然是重头开始设计,但还是可以从业内现有的系统中借鉴很多的经验(毕竟这套系统设计的出发点,也考虑到了通用性,而不仅仅用在 ML 领域)。
论文发表的时候,ray 还处于初期,当时的一些架构设计后来也有了一些变化,但本文依然以论文中的架构为主来介绍。
新提出的架构与 Flink 和 Spark 最大区别是在 R3~R5,为了支持这三个执行模型要求,这里设计了一套 API,它允许任意的 function 作为远程的 task 执行(并且还是在 dataflow 中的一环)。
future
做 task 的返回值,task 是在系统中异步执行的;future
,这样的话,新创建的 task 就会依赖这个 future 对应的 task,它也就实现了任意的 DAG 依赖(R5);get
方法得到,它会阻塞直到 task 执行结束;wait
方法可以执行批量任务等待,该方法需要指定一个 future 列表、timeout 参数和要返回的 task number 的数量,这个方法会返回 future 任务的子集,它们是在 timeout 达到或满足数量要求时返回的。这里看这些 API 可能会有一些不太理解,给大家推荐一篇文章:高性能分布式执行框架——Ray,这篇文章对于这些 API 在 ray 上的实现都有详细的示例,有兴趣的可以看下。
这里设计的架构也是 Master/Slave 模型,它包含:运行在每个 node 上的 worker 进程、每个 node 会有一个 local scheduler、一个或多个 global scheduler 以及在 worker 间做数据共享的内存对象存储,如下图所示(大家依然可以看下这篇文章 高性能分布式执行框架——Ray,它介绍了 Ray 的落地实现架构,但论文中更多的还是模型设计)。
Master 负责全局协调和状态维护,Slave 执行分布式计算任务,不同与传统计算系统的是,它使用了混合任务调度的思路:
如前面图中所示,这套架构是依赖一个逻辑中心控制器,为了实现这套架构,设计时使用了一个 database 来做 Control State 的工作,存储系统的状态信息以及用于系统组件间的通信信息。
在这个设计中,除了 Control State,其他组件都是无状态的,所以只要 Control State 具有容错性,系统就可以简单恢复任务中失败的节点(因为 dataflow graph 是不固定,所以真正实现时 recover 的逻辑与 Spark 和 Flink 这类系统是不同的)(R6
)。为了实现高吞吐,在写数据库的时候,允许按 key hash 写入(R1-R2)。
这套架构采用混度调度器的模式,简单来说,在 task 调度时,实现如下:
ray 在真正实现时,提交给作业是一个更细粒度的 remote function,任务 DAG 依赖关系由函数依赖关系自由控制,像 Flink 和 Spark 系统,提交的是任务的 DAG,一旦提交就不能修改。
就像在前面文中说的一样,个人感觉这套架构与目前主流大数据计算引擎最大的区别还是 R3~R5,这样设计也是因为业务场景驱动,static dataflow graph 在一些 ML 和图计算的场景下无法很好的满足业务需求,每套计算引擎最开始要解决的问题都不太一样,都有一个自己的切入点,只不过做着做着大家发现自己的场景很有限,都想切入到更多的领域,做一个更加通用的引擎,通用引擎对于一些简单业务场景来说可能会显得特别重、对另一些复杂业务场景来说可能又显得不能完全支持,这也是计算引擎最近几年遍地开花的原因,而且相信未来还会有很大变化。而且现在有个趋势:对于业务来说,大家发现没有必要非要使用什么统一的引擎,引擎(包括存储和计算)是什么我可以完全不 care,面向用户的是统一的 DSL,用什么引擎由平台来帮业务选择,这个或许是一个趋势,但从另一个方面来说维护多套引擎的成本有点高,就像现在公司不会选择在服务器维护很多套操作系统一样,最终会是什么样子,过几年再看。
关于 ray,目前国内看到的是蚂蚁金服有在使用,其他公司好像没有听说过,ray 目前已经在蚂蚁金服的很多业务上落地,这个大家可以参考今年阿里云栖大会上蚂蚁金服的分享(见:开放计算架构:蚂蚁金服是如何用一套架构容纳所有计算的?),可以看到 ray 中落地比较好的场景还是 ML 和图计算相关的业务,关于图计算,国内估计也只有蚂蚁和腾讯有这么强烈的业务需求。刚好今天看到一篇文章 —— 腾讯开源全栈机器学习平台 Angel 3.0,支持三大类型图计算算法,腾讯这边在图计算这块选择了他们开源的 Angel 平台做图计算,他们有兴趣地的可以深入看下。
最后,说点题外话,笔者本来计划今年每个月都要输出一篇经典论文的阅读笔记的,但不料的是,今年工作实在是太忙太累,很多计划并没有落地执行,后面会多花点工作之外的时间把今年欠的博客补一下,最近也会开始写 Apache Flink1.9 源码分析系列以及 Paper 阅读总结系列,文章会在公众号同步发布,大家多多关注。
参考:
]]>查询优化器是传统数据库的核心模块,也是大数据计算引擎的核心模块,开源大数据引擎如 Impala、Presto、Drill、HAWQ、 Spark、Hive 等都有自己的查询优化器。Calcite 就是从 Hive 的优化器演化而来的。
优化器的作用:将解析器生成的关系代数表达式转换成执行计划,供执行引擎执行,在这个过程中,会应用一些规则优化,以帮助生成更高效的执行计划。
关于 Volcano 模型和 Cascades 模型的内容,建议看下相关的论文,这个是 Calcite 优化器的理论基础,代码只是把这个模型落地实现而已。
基于规则的优化器(Rule-Based Optimizer,RBO):根据优化规则对关系表达式进行转换,这里的转换是说一个关系表达式经过优化规则后会变成另外一个关系表达式,同时原有表达式会被裁剪掉,经过一系列转换后生成最终的执行计划。
RBO 中包含了一套有着严格顺序的优化规则,同样一条 SQL,无论读取的表中数据是怎么样的,最后生成的执行计划都是一样的。同时,在 RBO 中 SQL 写法的不同很有可能影响最终的执行计划,从而影响执行计划的性能。
基于代价的优化器(Cost-Based Optimizer,CBO):根据优化规则对关系表达式进行转换,这里的转换是说一个关系表达式经过优化规则后会生成另外一个关系表达式,同时原有表达式也会保留,经过一系列转换后会生成多个执行计划,然后 CBO 会根据统计信息和代价模型 (Cost Model) 计算每个执行计划的 Cost,从中挑选 Cost 最小的执行计划。
由上可知,CBO 中有两个依赖:统计信息和代价模型。统计信息的准确与否、代价模型的合理与否都会影响 CBO 选择最优计划。 从上述描述可知,CBO 是优于 RBO 的,原因是 RBO 是一种只认规则,对数据不敏感的呆板的优化器,而在实际过程中,数据往往是有变化的,通过 RBO 生成的执行计划很有可能不是最优的。事实上目前各大数据库和大数据计算引擎都倾向于使用 CBO,但是对于流式计算引擎来说,使用 CBO 还是有很大难度的,因为并不能提前预知数据量等信息,这会极大地影响优化效果,CBO 主要还是应用在离线的场景。
无论是 RBO,还是 CBO 都包含了一系列优化规则,这些优化规则可以对关系表达式进行等价转换,常见的优化规则包含:
在 Calcite 的代码里,有一个测试类(org.apache.calcite.test.RelOptRulesTest
)汇集了对目前内置所有 Rules 的测试 case,这个测试类可以方便我们了解各个 Rule 的作用。在这里有下面一条 SQL,通过这条语句来说明一下上面介绍的这三种规则。
1 | select 10 + 30, users.name, users.age |
关于谓词下推,它主要还是从关系型数据库借鉴而来,关系型数据中将谓词下推到外部数据库用以减少数据传输;属于逻辑优化,优化器将谓词过滤下推到数据源,使物理执行跳过无关数据。最常见的例子就是 join 与 filter 操作一起出现时,提前执行 filter 操作以减少处理的数据量,将 filter 操作下推,以上面例子为例,示意图如下(对应 Calcite 中的 FilterJoinRule.FilterIntoJoinRule.FILTER_ON_JOIN
Rule):
在进行 join 前进行相应的过滤操作,可以极大地减少参加 join 的数据量。
常量折叠也是常见的优化策略,这个比较简单、也很好理解,可以看下 编译器优化 – 常量折叠 这篇文章,基本不用动脑筋就能理解,对于我们这里的示例,有一个常量表达式 10 + 30
,如果不进行常量折叠,那么每行数据都需要进行计算,进行常量折叠后的结果如下图所示( 对应 Calcite 中的 ReduceExpressionsRule.PROJECT_INSTANCE
Rule):
列裁剪也是一个经典的优化规则,在本示例中对于jobs 表来说,并不需要扫描它的所有列值,而只需要列值 id,所以在扫描 jobs 之后需要将其他列进行裁剪,只留下列 id。这个优化带来的好处很明显,大幅度减少了网络 IO、内存数据量的消耗。裁剪前后的示意图如下(不过并没有找到 Calcite 对应的 Rule):
有了前面的基础后,这里来看下 Calcite 中优化器的实现,RelOptPlanner 是 Calcite 中优化器的基类,其子类实现如下图所示:
Calcite 中关于优化器提供了两种实现:
前面提到过像calcite这类查询优化器最核心的两个问题之一是怎么把优化规则应用到关系代数相关的RelNode Tree上。所以在阅读calicite的代码时就得带着这个问题去看看它的实现过程,然后才能判断它的代码实现得是否优雅。
calcite的每种规则实现类(RelOptRule的子类)都会声明自己应用在哪种RelNode子类上,每个RelNode子类其实都可以看成是一种operator(中文常翻译成算子)。
VolcanoPlanner就是优化器,用的是动态规划算法,在创建VolcanoPlanner的实例后,通过calcite的标准jdbc接口执行sql时,默认会给这个VolcanoPlanner的实例注册将近90条优化规则(还不算常量折叠这种最常见的优化),所以看代码时,知道什么时候注册可用的优化规则是第一步(调用VolcanoPlanner.addRule实现),这一步比较简单。
接下来就是如何筛选规则了,当把语法树转成RelNode Tree后是没有必要把前面注册的90条优化规则都用上的,所以需要有个筛选的过程,因为每种规则是有应用范围的,按RelNode Tree的不同节点类型就可以筛选出实际需要用到的优化规则了。这一步说起来很简单,但在calcite的代码实现里是相当复杂的,也是非常关键的一步,是从调用VolcanoPlanner.setRoot方法开始间接触发的,如果只是静态的看代码不跑起来跟踪调试多半摸不清它的核心流程的。筛选出来的优化规则会封装成VolcanoRuleMatch,然后扔到RuleQueue里,而这个RuleQueue正是接下来执行动态规划算法要用到的核心类。筛选规则这一步的代码实现很晦涩。
第三步才到VolcanoPlanner.findBestExp,本质上就是一个动态规划算法的实现,但是最值得关注的还是怎么用第二步筛选出来的规则对RelNode Tree进行变换,变换后的形式还是一棵RelNode Tree,最常见的是把LogicalXXX开头的RelNode子类换成了EnumerableXXX或BindableXXX,总而言之,看看具体优化规则的实现就对了,都是繁琐的体力活。
一个优化器,理解了上面所说的三步基本上就抓住重点了。
—— 来自【zhh-4096 】的微博
下面详细讲述一下这两种 planner 在 Calcite 内部的具体实现。
使用 HepPlanner 实现的完整代码见 SqlHepTest。
这里先看下 HepPlanner 的一些基本概念,对于后面的理解很有帮助。
HepRelVertex 是对 RelNode 进行了简单封装。HepPlanner 中的所有节点都是 HepRelVertex,每个 HepRelVertex 都指向了一个真正的 RelNode 节点。
1 | // org.apache.calcite.plan.hep.HepRelVertex |
HepInstruction 是 HepPlanner 对一些内容的封装,具体的子类实现比较多,其中 RuleInstance 是 HepPlanner 中对 Rule 的一个封装,注册的 Rule 最后都会转换为这种形式。
HepInstruction represents one instruction in a HepProgram.
1 | //org.apache.calcite.plan.hep.HepInstruction |
下面这个示例是上篇文章(Apache Calcite 处理流程详解(一))的示例,通过这段代码来看下 HepPlanner 的内部实现机制。
1 | HepProgramBuilder builder = new HepProgramBuilder(); |
上面的代码总共分为三步:
setRoot()
方法将 RelNode 树转换成 HepPlanner 内部使用的 Graph;findBestExp()
找到最优的 plan,规则的匹配都是在这里进行。这几步代码实现没有太多需要介绍的地方,先初始化 HepProgramBuilder 也是为了后面初始化 HepProgram 做准备,HepProgramBuilder 主要也就是提供了一些配置设置和添加规则的方法等,常用的方法如下:
addRuleInstance()
:注册相应的规则;addRuleCollection()
:这里是注册一个规则集合,先把规则放在一个集合里,再注册整个集合,如果规则多的话,一般是这种方式;addMatchLimit()
:设置 MatchLimit,这个 rule match 次数的最大限制;HepProgram 这个类对于后面 HepPlanner 的优化很重要,它定义 Rule 匹配的顺序,默认按【深度优先】顺序,它可以提供以下几种(见 HepMatchOrder 类):
这个匹配顺序到底是什么呢?对于规则集合 rules,HepPlanner 的算法是:从一个节点开始,跟 rules 的所有 Rule 进行匹配,匹配上就进行转换操作,这个节点操作完,再进行下一个节点,这里的匹配顺序就是指的节点遍历顺序(这种方式的优劣,我们下面再说)。
先看下 setRoot()
方法的实现:
1 | // org.apache.calcite.plan.hep.HepPlanner |
HepPlanner 会先将所有 relNode tree 转化为 HepRelVertex,这时就构建了一个 Graph:将所有的 elNode 节点使用 Vertex 表示,Gragh 会记录每个 HepRelVertex 的 input 信息,这样就是构成了一张 graph。
在真正的实现时,递归逐渐将每个 relNode 转换为 HepRelVertex,并在 graph
中记录相关的信息,实现如下:
1 | //org.apache.calcite.plan.hep.HepPlanner |
到这里 HepPlanner 需要的 gragh 已经构建完成,通过 DEBUG 方式也能看到此时 HepPlanner root 变量的内容:
1 | //org.apache.calcite.plan.hep.HepPlanner |
主要的实现是在 executeProgram()
方法中,如下:
1 | //org.apache.calcite.plan.hep.HepPlanner |
这里会遍历 HepProgram 中 instructions(记录注册的所有 HepInstruction),然后根据 instruction 的类型执行相应的 executeInstruction()
方法,如果instruction 是 HepInstruction.MatchLimit
类型,会执行 executeInstruction(HepInstruction.MatchLimit instruction)
方法,这个方法就是初始化 matchLimit 变量。对于 HepInstruction.RuleInstance
类型的 instruction 会执行下面的方法(前面的示例注册规则使用的是 addRuleInstance()
方法,所以返回的 rules 只有一个规则,如果注册规则的时候使用的是 addRuleCollection()
方法注册一个规则集合的话,这里会返回的 rules 就是那个规则集合):
1 | //org.apache.calcite.plan.hep.HepPlanner |
接下来看 applyRules()
的实现:
1 | //org.apache.calcite.plan.hep.HepPlanner |
在这里会调用 getGraphIterator()
方法获取 HepRelVertex 的迭代器,迭代的策略(遍历的策略)跟前面说的顺序有关,默认使用的是【深度优先】,这段代码比较简单,就是遍历规则+遍历节点进行匹配转换,直到满足条件再退出,从这里也能看到 HepPlanner 的实现效率不是很高,它也无法保证能找出最优的结果。
总结一下,HepPlanner 在优化过程中,是先遍历规则,然后再对每个节点进行匹配转换,直到满足条件(超过限制次数或者规则遍历完一遍不会再有新的变化),其方法调用流程如下:
关于这个,能想到的就是:RelNode 是底层提供的抽象、偏底层一些,在优化器这一层,需要记录更多的信息,所以又做了一层封装。
介绍完 HepPlanner 之后,接下来再来看下基于成本优化(CBO)模型在 Calcite 中是如何实现、如何落地的,关于 Volcano 理论内容建议先看下相关理论知识,否则直接看实现的话可能会有一些头大。从 Volcano 模型的理论落地到实践是有很大区别的,这里先看一张 VolcanoPlanner 整体实现图,如下所示(图片来自 Cost-based Query Optimization in Apache Phoenix using Apache Calcite):
上面基本展现了 VolcanoPlanner 内部实现的流程,也简单介绍了 VolcanoPlanner 在实现中的一些关键点(有些概念暂时不了解也不要紧,后面会介绍):
使用 VolcanoPlanner 实现的完整代码见 SqlVolcanoTest。
下面来看下 VolcanoPlanner 实现具体的细节。
VolcanoPlanner 在实现中引入了一些基本概念,先明白这些概念对于理解 VolcanoPlanner 的实现非常有帮助。
关于 RelSet,源码中介绍如下:
RelSet is an equivalence-set of expressions that is, a set of expressions which have identical semantics.
We are generally interested in using the expression which has the lowest cost.
All of the expressions in an RelSet have the same calling convention.
它有以下特点:
rels
中;List<RelSubset> subsets
中.RelSet 中比较重要成员变量如下:
1 | class RelSet { |
关于 RelSubset,源码中介绍如下:
Subset of an equivalence class where all relational expressions have the same physical properties.
它的特点如下:
RelSubset 一些比较重要的成员变量如下:
1 | public class RelSubset extends AbstractRelNode { |
每个 RelSubset 都将会记录其最佳 plan(best
)和最佳 plan 的 cost(bestCost
)信息。
RuleMatch 是这里对 Rule 和 RelSubset 关系的一个抽象,它会记录这两者的信息。
A match of a rule to a particular set of target relational expressions, frozen in time.
importance 决定了在进行 Rule 优化时 Rule 应用的顺序,它是一个相对概念,在 VolcanoPlanner 中有两个 importance,分别是 RelSubset 和 RuleMatch 的 importance,这里先提前介绍一下。
RelSubset importance 计算方法见其 api 定义(图中的 sum 改成 Math.max{}这个地方有误):
举个例子:假设一个 RelSubset(记为 $s_0$) 的 cost 是3,对应的 importance 是0.5,这个 RelNode 有两个输入(inputs),对应的 RelSubset 记为 $s_1$、$s_2$(假设 $s_1$、$s_2$ 不再有输入 RelNode),其 cost 分别为 2和5,那么 $s_1$ 的 importance 为
Importance of $s_1$ = $\frac{2}{3+2+5}$ $\cdot$ 0.5 = 0.1
Importance of $s_2$ = $\frac{5}{3+2+5}$ $\cdot$ 0.5 = 0.25
其中,2代表的是 $s_1$ 的 cost,$3+2+5$ 代表的是 $s_0$ 的 cost(本节点的 cost 加上其所有 input 的 cost)。下面看下其具体的代码实现(调用 RuleQueue 中的 recompute()
计算其 importance):
1 | //org.apache.calcite.plan.volcano.RuleQueue |
在 computeImportanceOfChild()
中计算 RelSubset 相对于 parent RelSubset 的 importance 时,一个比较重要的地方就是如何计算 cost,关于 cost 的计算见:
1 | //org.apache.calcite.plan.volcano.VolcanoPlanner |
上面就是 RelSubset importance 计算的代码实现,从实现中可以发现这个特点:
RuleMatch 的 importance 定义为以下两个中比较大的一个(如果对应的 RelSubset 有 importance 的情况下):
1 | //org.apache.calcite.plan.volcano.VolcanoRuleMatch |
RuleMatch 的 importance 主要是决定了在选择 RuleMatch 时,应该先处理哪一个?它本质上还是直接用的 RelSubset 的 importance。
还是以前面的示例,只不过这里把优化器换成 VolcanoPlanner 来实现,通过这个示例来详细看下 VolcanoPlanner 内部的实现逻辑。
1 | //1. 初始化 VolcanoPlanner 对象,并添加相应的 Rule |
优化后的结果为:
1 | EnumerableSort(sort0=[$0], dir0=[ASC]) |
在应用 VolcanoPlanner 时,整体分为以下四步:
Convention
);setRoot()
方法注册相应的 RelNode,并进行相应的初始化操作;下面来分享一下上面的详细流程。
在这里总共有三步,分别是 VolcanoPlanner 初始化,addRelTraitDef()
添加 RelTraitDef,addRule()
添加 rule,先看下 VolcanoPlanner 的初始化:
1 | //org.apache.calcite.plan.volcano.VolcanoPlanner |
这里其实并没有做什么,只是做了一些简单的初始化,如果要想设置相应 RelTraitDef 的话,需要调用 addRelTraitDef()
进行添加,其实现如下:
1 | //org.apache.calcite.plan.volcano.VolcanoPlanner |
如果要给 VolcanoPlanner 添加 Rule 的话,需要调用 addRule()
进行添加,在这个方法里重点做的一步是将具体的 RelNode 与 RelOptRuleOperand 之间的关系记录下来,记录到 classOperands
中,相当于在优化时,哪个 RelNode 可以应用哪些 Rule 都是记录在这个缓存里的。其实现如下:
1 | //org.apache.calcite.plan.volcano.VolcanoPlanner |
这里分为两步:
replace()
方法,将 RelTraitSet 中对应的 RelTraitDef 做对应的更新,其他的 RelTrait 不变;registerImpl()
方法的实现。VolcanoPlanner 会调用 setRoot()
方法注册相应的 Root RelNode,并进行一系列 Volcano 必须的初始化操作,很多的操作都是在这里实现的,这里来详细看下其实现。
1 | //org.apache.calcite.plan.volcano.VolcanoPlanner |
对于 setRoot()
方法来说,核心的处理流程是在 registerImpl()
方法中,在这个方法会进行相应的初始化操作(包括 RelNode 到 RelSubset 的转换、计算 RelSubset 的 importance 等),其他的方法在上面有相应的备注,这里我们看下 registerImpl()
具体做了哪些事情:
1 | //org.apache.calcite.plan.volcano.VolcanoPlanner |
registerImpl()
处理流程比较复杂,其方法实现,可以简单总结为以下几步:
rel.onRegister(this)
这步操作,递归地调用 VolcanoPlanner 的 ensureRegistered()
方法对其 inputs
RelNode 进行注册,最后还是调用 registerImpl()
方法先注册叶子节点,然后再父节点,最后到根节点;mapDigestToRel
缓存中,如果存在的话,那么判断会 RelNode 是否相同,如果相同的话,证明之前已经注册过,直接通过 getSubset()
返回其对应的 RelSubset 信息,否则就对其 RelSubset 做下 merge;addRelToSet()
将 RelNode 添加到 RelSet 中,并且更新 VolcanoPlanner 的 mapRel2Subset
缓存记录(RelNode 与 RelSubset 的对应关系),在 addRelToSet()
的最后还会更新 RelSubset 的 best plan 和 best cost(每当往一个 RelSubset 添加相应的 RelNode 时,都会判断这个 RelNode 是否代表了 best plan,如果是的话,就更新);parents
中记录其父节点);fireRules()
方法(会先对 RelNode 触发一次),遍历找到所有可以 match 的 Rule,对每个 Rule 都会创建一个 VolcanoRuleMatch 对象(会记录 RelNode、RelOptRuleOperand 等信息,RelOptRuleOperand 中又会记录 Rule 的信息),并将这个 VolcanoRuleMatch 添加到对应的 RuleQueue 中(就是前面图中的那个 RuleQueue)。这里,来看下 fireRules()
方法的实现,它的目的是把配置的 RuleMatch 添加到 RuleQueue 中,其实现如下:
1 | //org.apache.calcite.plan.volcano.VolcanoPlanner |
在上面的方法中,对于匹配的 Rule,将会创建一个 VolcanoRuleMatch 对象,之后再把这个 VolcanoRuleMatch 对象添加到对应的 RuleQueue 中。
1 | //org.apache.calcite.plan.volcano.RuleQueue |
到这里 VolcanoPlanner 需要初始化的内容都初始化完成了,下面就到了具体的优化部分。
VolcanoPlanner 的 findBestExp()
是具体进行优化的地方,先介绍一下这里的优化策略(每进行一次迭代,cumulativeTicks
加1,它记录了总的迭代次数):
firstFiniteTick
,其对应的 Cost 暂时记为 BestCost;BestCost*0.9
,再根据 firstFiniteTick
及当前的迭代次数计算 giveUpTick
,这个值代表的意思是:如果迭代次数超过这个值还没有达到优化目标,那么将会放弃迭代,认为当前的 plan 就是 best plan;上面就是 findBestExp()
主要设计理念,这里来看其具体的实现:
1 | //org.apache.calcite.plan.volcano.VolcanoPlanner |
整体的流程正如前面所述,这里来看下 RuleQueue 中 popMatch()
方法的实现,它的目的是选择 the highest importance 的 RuleMatch,这个方法的实现如下:
1 | //org.apache.calcite.plan.volcano.RuleQueue |
到这里,我们就把 VolcanoPlanner 的优化讲述完了,当然并没有面面俱到所有的细节,VolcanoPlanner 的整体处理图如下:
在初始化 RuleQueue 时,会给 VolcanoPlanner 的四个阶段 PRE_PROCESS_MDR, PRE_PROCESS, OPTIMIZE, CLEANUP
都初始化一个 PhaseMatchList 对象(记录这个阶段对应的 RuleMatch),这时候会给其中的三个阶段添加一个 useless rule,如下所示:
1 | protected VolcanoPlannerPhaseRuleMappingInitializer |
开始时还困惑这个什么用?后来看到下面的代码基本就明白了
1 | for (VolcanoPlannerPhase phase : VolcanoPlannerPhase.values()) { |
后面在调用 RuleQueue 的 addMatch()
方法会做相应的判断,如果 phaseRuleSet 不为 ALL_RULES,并且 phaseRuleSet 不包含这个 ruleClassName 时,那么就跳过这个 RuleMatch,也就是说实际上只有 OPTIMIZE 这个阶段是发挥作用的,其他阶段没有添加任何 RuleMatch。
VolcanoPlanner 的四个阶段 PRE_PROCESS_MDR, PRE_PROCESS, OPTIMIZE, CLEANUP
,实际只有 OPTIMIZE
进行真正的优化操作,其他阶段并没有,这里自己是有一些困惑的:
这两个问题,暂时也没有头绪,有想法的,欢迎交流。
这部分的内容比较多,到这里 Calcite 主要处理流程的文章也终于梳理完了,因为是初次接触,文章理解有误的地方,欢迎各位指教~
附上上一篇文章:Apache Calcite 处理流程详解(一)。
Apache Calcite is a foundational software framework that provides query processing, optimization, and query language support to many popular open-source data processing systems such as Apache Hive, Apache Storm, Apache Flink, Druid, and MapD. Calcite’s architecture consists of
- a modular and extensible query optimizer with hundreds of built-in optimization rules,
- a query processor capable of processing a variety of query languages,
- an adapter architecture designed for extensibility,
- and support for heterogeneous data models and stores (relational, semi-structured, streaming, and geospatial).
This flexible, embeddable, and extensible architecture is what makes Calcite an attractive choice for adoption in bigdata frameworks. It is an active project that continues to introduce support for the new types of data sources, query languages, and approaches to query processing and optimization.
在介绍 Calcite 架构之前,先来看下与 Calcite 相关的基础性内容。
关系代数是关系型数据库操作的理论基础,关系代数支持并、差、笛卡尔积、投影和选择等基本运算。关系代数也是 Calcite 的核心,任何一个查询都可以表示成由关系运算符组成的树。在 Calcite 中,它会先将 SQL 转换成关系表达式(relational expression),然后通过规则匹配(rules match)进行相应的优化,优化会有一个成本(cost)模型为参考。
这里先看下关系代数相关内容,这对于理解 Calcite 很有帮助,特别是 Calcite Optimizer 这块的内容,关系代数的基础可以参考这篇文章 SQL 形式化语言——关系代数,简单总结如下:
名称 | 英文 | 符号 | 说明 |
---|---|---|---|
选择 | select | σ | 类似于 SQL 中的 where |
投影 | project | Π | 类似于 SQL 中的 select |
并 | union | ∪ | 类似于 SQL 中的 union |
集合差 | set-difference | - | SQL中没有对应的操作符 |
笛卡儿积 | Cartesian-product | × | 类似于 SQL 中不带 on 条件的 inner join |
重命名 | rename | ρ | 类似于 SQL 中的 as |
集合交 | intersection | ∩ | SQL中没有对应的操作符 |
自然连接 | natural join | ⋈ | 类似于 SQL 中的 inner join |
赋值 | assignment | ← |
查询优化主要是围绕着 等价交换 的原则做相应的转换,这部分可以参考【《数据库系统概念(中文第六版)》第13章——查询优化】,关于查询优化理论知识,这里就不再详述,列出一些个人不错不错的博客,大家可以参考一下:
Calcite 抛出的概念非常多,笔者最开始在看代码时就被这些概念绕得云里雾里,这时候先从代码的细节里跳出来,先把这些概念理清楚、归归类后再去看代码,思路就清晰很多,因此,在介绍 Calcite 整体实现前,先把这些概念梳理一下,需要对这些概念有个基本的理解,相关的概念如下图所示:
整理如下表所示:
类型 | 描述 | 特点 |
---|---|---|
RelOptRule | transforms an expression into another。对 expression 做等价转换 | 根据传递给它的 RelOptRuleOperand 来对目标 RelNode 树进行规则匹配,匹配成功后,会再次调用 matches() 方法(默认返回真)进行进一步检查。如果 mathes() 结果为真,则调用 onMatch() 进行转换。 |
ConverterRule | Abstract base class for a rule which converts from one calling convention to another without changing semantics. | 它是 RelOptRule 的子类,专门用来做数据源之间的转换(Calling convention),ConverterRule 一般会调用对应的 Converter 来完成工作,比如说:JdbcToSparkConverterRule 调用 JdbcToSparkConverter 来完成对 JDBC Table 到 Spark RDD 的转换。 |
RelNode | relational expression,RelNode 会标识其 input RelNode 信息,这样就构成了一棵 RelNode 树 | 代表了对数据的一个处理操作,常见的操作有 Sort、Join、Project、Filter、Scan 等。它蕴含的是对整个 Relation 的操作,而不是对具体数据的处理逻辑。 |
Converter | A relational expression implements the interface Converter to indicate that it converts a physical attribute, or RelTrait of a relational expression from one value to another. |
用来把一种 RelTrait 转换为另一种 RelTrait 的 RelNode。如 JdbcToSparkConverter 可以把 JDBC 里的 table 转换为 Spark RDD。如果需要在一个 RelNode 中处理来源于异构系统的逻辑表,Calcite 要求先用 Converter 把异构系统的逻辑表转换为同一种 Convention。 |
RexNode | Row-level expression | 行表达式(标量表达式),蕴含的是对一行数据的处理逻辑。每个行表达式都有数据的类型。这是因为在 Valdiation 的过程中,编译器会推导出表达式的结果类型。常见的行表达式包括字面量 RexLiteral, 变量 RexVariable, 函数或操作符调用 RexCall 等。 RexNode 通过 RexBuilder 进行构建。 |
RelTrait | RelTrait represents the manifestation of a relational expression trait within a trait definition. | 用来定义逻辑表的物理相关属性(physical property),三种主要的 trait 类型是:Convention、RelCollation、RelDistribution; |
Convention | Calling convention used to repressent a single data source, inputs must be in the same convention | 继承自 RelTrait,类型很少,代表一个单一的数据源,一个 relational expression 必须在同一个 convention 中; |
RelTraitDef | 主要有三种:ConventionTraitDef:用来代表数据源 RelCollationTraitDef:用来定义参与排序的字段;RelDistributionTraitDef:用来定义数据在物理存储上的分布方式(比如:single、hash、range、random 等); | |
RelOptCluster | An environment for related relational expressions during the optimization of a query. | palnner 运行时的环境,保存上下文信息; |
RelOptPlanner | A RelOptPlanner is a query optimizer: it transforms a relational expression into a semantically equivalent relational expression, according to a given set of rules and a cost model. | 也就是优化器,Calcite 支持RBO(Rule-Based Optimizer) 和 CBO(Cost-Based Optimizer)。Calcite 的 RBO (HepPlanner)称为启发式优化器(heuristic implementation ),它简单地按 AST 树结构匹配所有已知规则,直到没有规则能够匹配为止;Calcite 的 CBO 称为火山式优化器(VolcanoPlanner)成本优化器也会匹配并应用规则,当整棵树的成本降低趋于稳定后,优化完成,成本优化器依赖于比较准确的成本估算。RelOptCost 和 Statistic 与成本估算相关; |
RelOptCost | defines an interface for optimizer cost in terms of number of rows processed, CPU cost, and I/O cost. | 优化器成本模型会依赖; |
关于 Calcite 的架构,可以参考下图(图片来自前面那篇论文),它与传统数据库管理系统有一些相似之处,相比而言,它将数据存储、数据处理算法和元数据存储这些部分忽略掉了,这样设计带来的好处是:对于涉及多种数据源和多种计算引擎的应用而言,Calcite 因为可以兼容多种存储和计算引擎,使得 Calcite 可以提供统一查询服务,Calcite 将会是这些应用的最佳选择。
在 Calcite 架构中,最核心地方就是 Optimizer,也就是优化器,一个 Optimization Engine 包含三个组成部分:
Sql 的执行过程一般可以分为下图中的四个阶段,Calcite 同样也是这样:
但这里为了讲述方便,把 SQL 的执行分为下面五个阶段(跟上面比比又独立出了一个阶段):
这里我们只关注前四步的内容,会配合源码实现以及一个示例来讲解。
示例 SQL 如下:
1 | select u.id as user_id, u.name as user_name, j.company as user_company, u.age as user_age |
这里有两张表,其表各个字段及类型定义如下:
1 | SchemaPlus rootSchema = Frameworks.createRootSchema(true); |
使用 Calcite 进行 Sql 解析的代码如下:
1 | SqlParser parser = SqlParser.create(sql, SqlParser.Config.DEFAULT); |
Calcite 使用 JavaCC 做 SQL 解析,JavaCC 根据 Calcite 中定义的 Parser.jj 文件,生成一系列的 java 代码,生成的 Java 代码会把 SQL 转换成 AST 的数据结构(这里是 SqlNode 类型)。
与 Javacc 相似的工具还有 ANTLR,JavaCC 中的 jj 文件也跟 ANTLR 中的 G4文件类似,Apache Spark 中使用这个工具做类似的事情。
关于 Javacc 内容可以参考下面这几篇文章,这里就不再详细展开,可以通过下面文章的例子把 JavaCC 的语法了解一下,这样我们也可以自己设计一个 DSL(Doomain Specific Language)。
回到 Calcite,Javacc 这里要实现一个 SQL Parser,它的功能有以下两个,这里都是需要在 jj 文件中定义的。
当 SqlParser 调用 parseStmt()
方法后,其相应的逻辑如下:
1 | // org.apache.calcite.sql.parser.SqlParser |
其中 SqlParser 中 parser 指的是 SqlParserImpl
类(SqlParser.Config.DEFAULT
指定的),它就是由 JJ 文件生成的解析类,其处理流程如下,具体解析逻辑还是要看 JJ 文件中的定义。
1 | //org.apache.calcite.sql.parser.impl.SqlParserImpl |
示例中 SQL 经过前面的解析之后,会生成一个 SqlNode,这个 SqlNode 是一个 SqlOrder 类型,DEBUG 后的 SqlOrder 对象如下图所示。
经过上面的第一步,会生成一个 SqlNode 对象,它是一个未经验证的抽象语法树,下面就进入了一个语法检查阶段,语法检查前需要知道元数据信息,这个检查会包括表名、字段名、函数名、数据类型的检查。进行语法检查的实现如下:
1 | //note: 二、sql validate(会先通过Catalog读取获取相应的metadata和namespace) |
我们知道 Calcite 本身是不管理和存储元数据的,在检查之前,需要先把元信息注册到 Calcite 中,一般的操作方法是实现 SchemaFactory,由它去创建相应的 Schema,在 Schema 中可以注册相应的元数据信息(如:通过 getTableMap()
方法注册表信息),如下所示:
1 | //org.apache.calcite.schema.impl.AbstractSchema |
CsvSchemasvSchema 中的 getTableMap()
方法通过 createTableMap()
来注册相应的表信息。
结合前面的例子再来分析,在前面定义了 CalciteCatalogReader 实例,该实例就是用来读取 Schema 中的元数据信息的。真正检查的逻辑是在 SqlValidatorImpl
类中实现的,这个 check 的逻辑比较复杂,在看代码时通过两种手段来看:
比如,在示例中 SQL 中,如果把一个字段名写错,写成 ids,其报错信息如下:
1 | org.apache.calcite.runtime.CalciteContextException: From line 1, column 156 to line 1, column 158: Column 'IDS' not found in table 'J' |
语法检查验证是通过 SqlValidatorImpl 的 validate()
方法进行操作的,其实现如下:
1 | org.apache.calcite.sql.validate.SqlValidatorImpl |
主要的实现是在 validateScopedExpression()
方法中,其实现如下
1 | private SqlNode validateScopedExpression( |
它的处理逻辑主要分为三步:
关于 Rewrite 这一步,一直困惑比较,因为根据 After unconditional rewrite:
这条日志的结果看,其实前后 SqlNode 并没有太大变化,看 performUnconditionalRewrites()
这部分代码时,看得不是很明白,不过还是注意到了 SqlOrderBy 的注释(注释如下),它的意思是 SqlOrderBy 通过 performUnconditionalRewrites()
方法已经被 SqlSelect 对象中的 ORDER_OPERAND
取代了。
1 | /** |
注意到 SqlOrderBy 的原因是因为在 performUnconditionalRewrites()
方法前面都是递归对每个对象进行处理,在后面进行真正的 ransform 时,主要在围绕着 ORDER_BY 这个类型做处理,而且从代码中可以看出,将其类型从 SqlOrderBy 转换成了 SqlSelect,BUDEG 前面的示例,发现 outermostNode 与 topNode 的类型确实发生了变化,如下图所示。
这个方法有个好的地方就是,在不改变原有 SQL Parser 的逻辑的情况下,可以在这个方法里做一些改动,当然如果 SQL Parser 的结果如果直接可用当然是最好的,就不需要再进行一次 Rewrite 了。
这里的功能主要就是将[元数据]转换成 SqlValidator 内部的 对象 进行表示,也就是 SqlValidatorScope 和 SqlValidatorNamespace 两种类型的对象:
这个理解起来并不是那么容易,在 SelectScope 类中有一个示例讲述,这个示例对这两个概念的理解很有帮助。
1 | /** |
接着回到最复杂的一步,就是 outermostNode 实例调用 validate(this, scope)
方法进行验证的部分,对于我们这个示例,这里最后调用的是 SqlSelect 的 validate()
方法,如下所示:
1 | public void validate(SqlValidator validator, SqlValidatorScope scope) { |
它调用的是 SqlValidatorImpl 的 validateQuery()
方法
1 | public void validateQuery(SqlNode node, SqlValidatorScope scope, |
这部分的调用逻辑非常复杂,主要的语法验证是 SqlValidatorScope 部分(它里面有相应的表名、字段名等信息),而 namespace 表示需要进行验证的数据源,最开始的这个 SqlNode 有一个 root namespace,上面的 validateNamespace()
方法会首先调用其 namespace 的 validate()
方法进行验证,以前面的示例为例,这里是 SelectNamespace,其实现如下:
1 | //org.apache.calcite.sql.validate.AbstractNamespace |
最后验证方法的实现是 SqlValidatorImpl 的 validateSelect()
方法(对本示例而言),其调用过程如下图所示:
经过第二步之后,这里的 SqlNode 就是经过语法校验的 SqlNode 树,接下来这一步就是将 SqlNode 转换成 RelNode/RexNode,也就是生成相应的逻辑计划(Logical Plan),示例的代码实现如下:
1 | // create the rexBuilder |
为了方便分析,这里也把上面的过程分为以下几步:
第1、2、4步在上述代码已经有相应的注释,这里不再介绍,下面从第三步开始讲述。
RelOptCluster 初始化的代码如下,这里基本都走默认的参数配置。
1 | org.apache.calcite.plan.RelOptCluster |
SqlToRelConverter 中的 convertQuery()
将 SqlNode 转换为 RelRoot,其实现如下:
1 | /** |
真正的实现是在 convertQueryRecursive()
方法中完成的,如下:
1 | /** |
依然以前面的示例为例,因为是 SqlSelect 类型,这里会调用下面的方法做相应的转换:
1 | /** |
在 convertSelectImpl()
方法中会依次对 SqlSelect 的各个部分做相应转换,其实现如下:
1 | /** |
这里以示例中的 From 部分为例介绍 SqlNode 到 RelNode 的逻辑,按照示例 DEUBG 后的结果如下图所示,因为 form 部分是一个 join 操作,会进入 join 相关的处理中。
这部分方法调用过程是:
1 | convertQuery --> |
到这里 SqlNode 到 RelNode 过程就完成了,生成的逻辑计划如下:
1 | LogicalSort(sort0=[$0], dir0=[ASC]) |
到这里前三步就算全部完成了。
终于来来到了第四阶段,也就是 Calcite 的核心所在,优化器进行优化的地方,前面 sql 中有一个明显可以优化的地方就是过滤条件的下压(push down),在进行 join 操作前,先进行 filter 操作,这样的话就不需要在 join 时进行全量 join,减少参与 join 的数据量。
关于filter 操作下压,在 Calcite 中已经有相应的 Rule 实现,就是 FilterJoinRule.FilterIntoJoinRule.FILTER_ON_JOIN
,这里使用 HepPlanner 作为示例的 planer,并注册 FilterIntoJoinRule 规则进行相应的优化,其代码实现如下:
1 | HepProgramBuilder builder = new HepProgramBuilder(); |
在 Calcite 中,提供了两种 planner:HepPlanner 和 VolcanoPlanner,关于这块内容可以参考【Drill/Calcite查询优化系列】这几篇文章(讲述得非常详细,赞),这里先简单介绍一下 HepPlanner 和 VolcanoPlanner,后面会关于这两个 planner 的代码实现做深入的讲述。
特点(来自 Apache Calcite介绍):
特点(来自 Apache Calcite介绍):
经过 HepPlanner 优化后的逻辑计划为:
1 | LogicalSort(sort0=[$0], dir0=[ASC]) |
可以看到优化的结果是符合我们预期的,HepPlanner 和 VolcanoPlanner 详细流程比较复杂,后面会有单独的文章进行讲述。
Calcite 本身的架构比较好理解,但是具体到代码层面就不是那么好理解了,它抛出了很多的概念,如果不把这些概念搞明白,代码基本看得也是云里雾里,特别是之前没有接触过这块内容的同学(我最开始看 Calcite 代码时是真的头大),入门的门槛确实高一些,但是当这些流程梳理清楚之后,其实再回头看,也没有多少东西,在生产中用的时候主要也是针对具体的业务场景扩展相应的 SQL 语法、进行具体的规则优化。
Calcite 架构设计得比较好,其中各个组件都可以单独使用,Rule(规则)扩展性很强,用户可以根据业务场景自定义相应的优化规则,它支持标准的 SQL,支持不同的存储和计算引擎,目前在业界应用也比较广泛,这也证明其牛叉之处。
本文只是个人理解的总结,由于本人也是刚接触这块,理解有偏差的地方,欢迎指正~
正如 Apache BookKeeper 官网介绍的一样:A scalable, fault-tolerant, and low-latency storage service optimized for real-time workloads。BookKeeper 的定位是一个可用于实时场景下的高扩展性、强容错、低延迟的存储服务。Pulsar-Cloud Native Messaging & Streaming - 示说网 中也做了一个简单总结:
BookKeeper 简介 部分已经对 BookKeeper 的基本概念做了一些讲解,这里再重新回顾一下,只有明白这些概念之后才能对更好地理解后面的内容,如下图所示,一个 Log/Stream/Topic 可以由下面的部分组成(图片来自 Pulsar-Cloud Native Messaging & Streaming)。
其中:
关于 Fragment,它是 Ledger 的物理组成单元,也是最小的物理存储单元,在以下两种情况下会创建新的 Fragment:
Apache BookKeeper 的架构如下图所示,它主要由三个组件构成:客户端 (client)、数据存储节点 (Bookie) 和元数据存储 Service Discovery(ZooKeeper),Bookies 在启动的时候向 ZooKeeper 注册节点,Client 通过 ZooKeeper 发现可用的 Bookie。
这里,我们可以看到 BookKeeper 架构属于典型的 slave-slave 架构,zk 存储其集群的 meta 信息(zk 虽是单点,但 zk 目前的高可用还是很有保障的),这种模式的好处显而易见,server 端变得非常简单,所有节点都是一样的角色和处理逻辑,能够这样设计的主要原因是其副本没有 leader 和 follower 之分,这是它与一些常见 mq(如:kafka、RocketMQ)系统的典型区别,每种设计都有其 trade-off,BeekKeeper 从设计之初就是为了高可靠而设计。
Apache BookKeeper 是一个高可靠的分布式存储系统,存储层的实现是其核心,对一个存储系统来说,关键的几点实现,无非是:一致性如何保证、IO 如何优化、高可用如何实现等,这小节就让我们揭开其神秘面纱。
Ledger 是 BookKeeper 的基本存储抽象单元,这里先看下一个 Ledger 是如何创建的,这里会介绍一些关于 Ledger 存储层的一些重要概念(图片来自 Pulsar-Cloud Native Messaging & Streaming)。
Ledger 是一组追加有序的记录,它是由 Client 创建的,然后由其进行追加写操作。每个 Ledger 在创建时会被赋予全局唯一的 ID,其他的 Client 可以根据 Ledger ID,对其进行读取操作。创建 Ledger 及 Entry 写入的相关过程如下:
这里引入了三个重要的概念,它们也是 BookKeeper 一致性的基础:
从上面 Ensemble、Qw、Qa 的概念可以得到以下这些推论:
对于分布式存储系统,为了高可用,多副本是其通用的解决方案,但也带来了一致性的问题,这里就看下 Apache BookKeeper 是如何解决其带来的一致性问题的。
在介绍其读写一致性之前,先看下 BK 的一致性模型(图片来自 Twitter高性能分布式日志系统架构解析)。
对于 Write 操作而言,writer 不断添加记录,每条记录会被 writer 赋予一个严格递增的 id,所有的追加操作都是异步的,也就是说:第二条记录不用等待第一条记录返回结果。所有写成功的操作都会按照 id 递增顺序返回 ack 给 writer。(图片来自 Twitter高性能分布式日志系统架构解析)。
伴随着写成功的 ack,writer 不断地更新一个指针叫做 Last-Add-Confirm(LAC),所有 Entry id 小于等于 LAC 的记录保证持久化并复制到大多数副本上,而 LAC 与 LAP(Last-Add-Pushed)之间的记录就是已经发送到 Bookie 上但还未被 ack 的数据。
所有的 Reader 都可以安全读取 Entry ID 小于或者等于 LAC 的记录,从而保证 reader 不会读取未确认的数据,从而保证了 reader 之间的一致性(图片来自 Twitter高性能分布式日志系统架构解析)。
从上面的介绍中,也可以看出,对于 BK 的多个副本,其并没有 leader 和 follower 之分,因此,BK 并不会进行相应的选主(leader election)操作,并且限制每个 Ledger 只能被一个 Writer 写,BK 通过 Fencing 机制来防止出现多个 Writer 的状态,从而保证写的一致性。
下面来看下 BK 存储层一个很重要的设计,那就是读写分离机制。在论文 Durability with BookKeeper 中,关于读写分离机制的介绍如下所示(图片来自 Durability with BookKeeper):
A bookie uses two devices, ideally in separate physical disks:
上面是论文中关于 BK 读写分离机制实现的介绍,我当时在看完上面的记录之后,脑海中有以下疑问:
带着这些疑问,接下来来分析其实现(图片来自 Pulsar-Cloud Native Messaging & Streaming):
Journal Device 分析:
Ledger Device 的实现:
了解完 BK 的一致性模型和读写分离机制之后,这里来看下 BK 的读写流程。
这里以一个例子来说明,假设 E 是3,Qw 和 Qa 是2,那么 Entry 写入如下图(图片来自 Durability with BookKeeper):
如果写入过程中有一台 Bookie 挂了怎么办?
如果写入过程中有两个 Bookie 挂了怎么办?
这里依然以一个例子做说明,例子是紧接着上面的示例,如下图所示(图片来自 Durability with BookKeeper):
如何想要读取 id 为1的那条 Entry 应该怎么做?
这种机制会导致,读取数据时可能需要从多个 Bookie 获取数据,需要并发访问多个 Bookie,性能会变差,极端情况会有这个问题。
BK 怎么处理长尾效应的问题(长尾效应指的是某台机器上某段或者某条数据读取得比较慢,进而影响了整体的效率)?
这里来简单来看下 BookKeeper 容错机制的实现。
Fencing 机制在前面已经简单介绍过了,它目的主要是为了保证写的一致性,严格保证一个 Ledger 只能被一个 Writer 来写。
Fencing 怎么触发呢?
一个 Ledger 正常关闭后,会在其 Metadata 中存储 the last entry 的信息,所以正常关闭一个 Ledger 是非常重要的(Ledger 一旦关闭,其就是不可变的,读取的时候可以从任意一个 Bookie 上读取,而不需要再取 care 这个 Ledger 的 LAC 信息),否则可能会出现这样一种情况:
由于 Writer 挂了(Ledger 未正常关闭),导致部分数据写入成功,实际上这个条消息并不满足 Qw(可能满足了 Qa),会导致不同 Reader 读取的结果不一致!如下图所示:
解决方案就是: Log Recovery,正常关闭这个 Ledger,并将 The Last Entry 及状态更新到 metadata 中。
Log Recovery 怎么实现呢?通常有两种方案:
很明显,第二种方案是比较合理的恢复速度更快。
当一个 Bookie 故障时:
Bk 提供自动和手动两种方式:两种方式的复制协议是一样的;自动恢复是 BK 内部自动触发,手动过程需要手动干预,这里重点介绍自动过程:
/underreplicated
znode 节点创建重新复制任务;每个 Bookie 在发现任务时会尝试锁定,如果无法锁定就会执行后面的任务。如果获得锁,那么:
如果 Ledgers 仍然存在副本数不足的 Fragment,则释放锁。如果所有 Fragment 都已经Fully Replicated,则从 /underreplicated
删除重复复制任务。
到这里,关于 BK 内核实现的主要部分已经介绍完毕,这篇文章的主要内容来自之前在团队的一次分享,一直想整理成博客的,但一直拖到了现在(因为并没有去看代码实现,主要是跟 bk 的论文及相关资料来整理的,有问题的地方欢迎指正)。
参考:
对于大多数人来说,我们并不是那种天赋异禀的天才,所以那些速成的学习方法并不适合我们,因为,对于非天才的我们来说,学习是不可能速成的,学习本来就是一件【逆人性】的事,就像锻炼身体一样,需要人持续付出,会让人感到痛苦,并随时想找理由放弃,实际上,痛苦是成长的必经阶段。
大部分人都认为自己热爱学习,但是有多少能真正付出实践、并一直坚持下去,能做到实践和坚持的人,一般运气都不会太差。如果我们去研究一下古今中外的成功人士,就会发现,他们基本上都是非常自律的,也都是非常热爱学习的,他们可以沉得下心来不断学习,在学习中不断地思考、探索和实践。懒,是人类的天性,如果不能克服自己 DNA 中的弱点,不能端正自己的态度,不能自律,不能坚持,不能举一反三,不能不断追问等,那么,无论多好的方法,你都不可能学好。所以,有正确的态度非常重要。
当然只做到上面说的,并不一定能保证能够实现所谓的成功,但是完全可以让你在某个领域做到足够优秀。
下面这张图,大部分人应该都见过,这张图又称为学习金字塔:
人的学习,可以分为【被动学习】和【主动学习】两个层次:
关于这个,我是深有体会的,如果我们只是看书或听一下别人的分享,不去实践,可能不到半个月,能记住 10% 的内容就不错了,我认为最好的学习方法是 实践,总结,教授给别人(要让别人听明白,教授的过程要有深度的讨论,而不是 PPT 走一遍) 。
过去一年多,很幸运的是,遇到了几个热爱学习的小伙伴,我们经常周末一起组织分享,每次分享只涉及很少的一块内容,分享过程中我们以讨论为主,这对分享者的能力锻炼有很好的效果(通过讨论听众也能收获很多),首先他需要自己能够理解这个问题,其次他需要把自己的理解给别人讲清楚,还需要回答其他人提出的问题(这些问题可能是分享者压根没注意的问题)。我也一直想在团队内部推广这种学习方法(这种方法人数太多的话就不太适合了),但是在团队内部去推,效果没有想象中得那么好,而且在团队内部反而很难坚持下去(大家的时间都比较有限,如果占据了别人的工作时间,别人可能需要加班才能完成自己的工作,所以大家兴趣并没有那么高昂)。相反,如果能找几个愿意一起学习的小伙伴一同学习、成长,这样反而效果好很多,如果你能找到这样的一群小伙伴,我是非常推荐这种学习方式,把自己学习的内容分享给其他人(大家一起学习、讨论这种学习效果,考虑问题的深度要比自己独自学习高出很多)。
学习并不是努力读更多的书,盲目追求阅读的速度和数量,这会让人产生低层次的勤奋和成长的感觉,这只是在使蛮力。要思辩,要践行,要总结和归纳,否则,你只是在机械地重复某件事,而不会有质的成长。
在知识的领域其实也有阶层之分(类似于富人和穷人在财富方面的阶层之分,阶层的跨越非常难,但不是没有可能),那么长期在底层知识阶层的人,需要等着高层的人来喂养,他们长期陷入各种谣言和不准确的信息环境中,于是就导致错误和幼稚的认知,并习惯于哪些不费劲儿的轻度学习方式,从而一点点地丧失了深度学习的独立思考能力,从而再也没有能力打破知识阶层的限制,被困在认知底层翻不了身(就像我们经常说的,美国那些在穷人区生活的人们,他们在没有受到很好教育的前提下想突破自己的阶层,真的很难)。
对于知识的学习,我们应该如何进行深度学习呢?下面几点是关键:
学习有三个步骤:
学习目的是什么呢?
对于计算机知识来说,学习英文是是否能够成长的关键,如果我们能用 Google 英文关键词就可以找到自己想要的知识,那么我们只是算得上能跟得上这个时代,但如果能在社区里跟社区里的大牛交流得到答案,这样才算是领先于这个时代。
信息源应该有以下几个特质:
耗子叔比较推荐 Medium 上的文章,这个上面的文章质量比较高。
基础知识和原理性的东西是无比重要的,无论是 JVM 还是 Node,或者是 Python 解释器里干了什么,它都无法逾越底层操作系统 API 对 『物理世界』的限制。
比如,当学习一门新的语言时,除了看每个语言都有的 if-else、for/while-loop、function 等东西外,还需要重点看的就是:
所以,最关键的是,这些基础知识和原理性的东西和技术,都是经历过长时间的考验的,这些基础技术也有很多人类历史上的智慧结晶,会给你很多启示和帮助(基础知识虽然很枯燥不实用、工作上用不到,学习这些知识是为了学得更快,基础打牢,学什么都快,而学得快就会学得多,学得多,就会思考得多,思考得多,就会学得更快…)。
耗子叔在这里介绍一个知识图的学习方式,通过这种方式可以让我们从一个技术最重要的主干的地方开始出发遍历所有的技术细节,以 C++ 为例,分为三部分:
dynamic_cast
和 typeid
等;templete
,想到了操作符重载,想到了函数对象,想到了 STL,想到数据容器,想到了 iterator,想到了通用算法等等。有了这样一颗知识树之后,当出现一些不知道的知识点时,可以往这棵知识树上挂,而这样一来,也使得我们的学习更为系统和全面。
在系统性地学习一项技术时,耗子叔总结了一个学习模板,模板内容如下:
重点是如何才能让自己拥有举一反三的能力,在这方面,耗子叔对自己训练如下:
举一反三的能力,可以分解为:
如果要获得这三种能力,除了你要很喜欢思考和找其它人来辩论或讨论以外,还要看你自己是否真的善于思考,是否有好奇心,是否喜欢打破沙锅问到底,是否喜欢关注细节,做事是否认真,是否严谨……
我们把学到的东西用自己的语言和理解重新组织并表达出来,本质上是对信息进行消化和再加工的过程,这个过程可能会有信息损失,但也可能会有新信息加入,本质上是信息重构的过程。我们积累的知识越多,在知识间进行联系和区辨的能力就越强,对知识进行总结和归纳也就越轻松。而想要提高总结归纳的能力,首先要多阅读,多积累素材,扩大自己的知识面,多和别人讨论,多思辨,从而见多识广。
不过,我们需要注意的是,如果只学了部分知识或者还没有学透,就开始对知识进行总结归纳,那么总结归纳出来的知识结构也只能是混乱和幼稚的。因此,学习的开始阶段,可以不急于总结归纳,不急于下判断,做结论,而应该保留部分知识的不确定性,保持对知识的开放状态。当对整个知识的理解更深入,自己站的位置更高以后,总结和归纳才会更有条理。总结归纳更多是在复习中对知识的回顾和重组,而不是一边学习一边就总结归纳。
最后再总结一下做总结归纳的方法:把你看到和学习到的信息,归整好,排列好,关联好,总之把信息碎片给结构化掉,然后在结构化的信息中,找到规律,找到相通之处,找到共同之处,进行简化、归纳和总结,最终形成一种套路,一种模式,一种通用方法。
实践是很累很痛苦的事,但只有痛苦才会让人反思,而反思则是学习和改变自己的动力。Grow up through the pain,是非常有道理的。
坚持本来也是一件反人性的事情,关于坚持的问题,大家应该都见过很多相似的文章,总之,坚持是一件看似简单、但是完成率非常低的事情。如果想要让自己能够坚持下去,最好能够让自己处于一个正反馈的循环中,比如,学习一个技术之后,与大家去分享自己的经验,或者整理出一篇博客让其他学习,都是一种很好的学习方法。
关于书/文档和代码的关系:
代码是具体的实现,但是并不能告诉你为什么?书和文档是人对人说的话,代码是人对机器说的话:
至于从代码中收获大还是从书中收获大,不同的场景、不同的目的下,会有不同的答案,我个人对这部分的想法是:
关于如何阅读源代码,耗子叔分享了一些干货,我这里简单总结一下
首先是阅读代码之前,最好先有以下了解:
接下来,就是详细地看代码的实现,这里耗子叔分享了一个源代码阅读的经验:
总结一下,阅读代码的方法如下。
知识很多,在学习的时候要抓住本质,关注本质和原理,这些才是不容易改变的,是经得住时间考验的。带着问题去学习也是一种非常好的学习方式,耗子叔根据自己经验在专栏中分享以下几个 tips:
这里有一个 TED 的演讲,TED演讲:只需20个小时,你就能学会任何事情!,保证自己全身心投入、不受外界打扰的情况下,只要20个小时,我们就能达到这里 如何学习开源项目-第三步,当然这20个小时要求是一个非常专注的20个小时,我还没有尝试过这种学习方法,近期准备尝试一次这种学习方法,到时候会写一篇文章来总结一下自己的经验。
最后,以矮大紧的一句话作为结束:【时代变来变去,确实有一些新的东西,但是在这样一个时代里,有一样东西没有变,就是有这样一群人,然后我们都读了一点书,受过不错的教育,然后对自己的心灵能长出什么东西,虽然不知道具体会长什么东西,但是拒绝全部种玉米、拒绝全部长土豆,希望心里有一亩田,有一天能长出一朵不知道是什么的花。(—来自《晓说》)】(这段话好像跟文章的主题没有什么关系,但不知为何突然想到了这段话,这里就列了出来)。
参考:
atomic writes across partitions
,本文以 Apache Kafka 2.0.0 代码实现为例,深入分析一下 Kafka 是如何实现这一机制的。
Apache Kafka 在 Exactly-Once Semantics(EOS)上三种粒度的保证如下(来自 Exactly-once Semantics in Apache Kafka):
第二种情况就是本文讲述的主要内容,在讲述整个事务处理流程时,也顺便分析第三种情况。
Kafka 事务性最开始的出发点是为了在 Kafka Streams 中实现 Exactly-Once 语义的数据处理,这个问题提出之后,在真正的方案讨论阶段,社区又挖掘了更多的应用场景,也为了尽可能覆盖更多的应用场景,在真正的实现中,在很多地方做了相应的 tradeoffs,后面会写篇文章对比一下 RocketMQ 事务性的实现,就能明白 Kafka 事务性实现及应用场景的复杂性了。
Kafka 的事务处理,主要是允许应用可以把消费和生产的 batch 处理(涉及多个 Partition)在一个原子单元内完成,操作要么全部完成、要么全部失败。为了实现这种机制,我们需要应用能提供一个唯一 id,即使故障恢复后也不会改变,这个 id 就是 TransactionnalId(也叫 txn.id,后面会详细讲述),txn.id 可以跟内部的 PID 1:1 分配,它们不同的是 txn.id 是用户提供的,而 PID 是 Producer 内部自动生成的(并且故障恢复后这个 PID 会变化),有了 txn.id 这个机制,就可以实现多 partition、跨会话的 EOS 语义。
当用户使用 Kafka 的事务性时,Kafka 可以做到的保证:
上面是从 Producer 的角度来看,那么如果从 Consumer 角度呢?Consumer 端很难保证一个已经 commit 的事务的所有 msg 都会被消费,有以下几个原因:
简单总结一下,关于 Kafka 事务性语义提供的保证主要以下三个:
Kafka 事务性的使用方法也非常简单,用户只需要在 Producer 的配置中配置 transactional.id
,通过 initTransactions()
初始化事务状态信息,再通过 beginTransaction()
标识一个事务的开始,然后通过 commitTransaction()
或 abortTransaction()
对事务进行 commit 或 abort,示例如下所示:
1 | Properties props = new Properties(); |
事务性的 API 也同样保持了 Kafka 一直以来的简洁性,使用起来是非常方便的。
回想一下,前面一篇文章中关于幂等性要解决的问题(幂等性要解决的问题),事务性其实更多的是解决幂等性中没有解决的问题,比如:
再来分析一下,Kafka 提供的事务性是如何解决上面两个问题的:
对于 Kafka 的事务性实现,最关键的就是其事务操作原子性的实现。对于一个事务操作而言,其会涉及到多个 Topic-Partition 数据的写入,如果是一个 long transaction 操作,可能会涉及到非常多的数据,如何才能保证这个事务操作的原子性(要么全部完成,要么全部失败)呢?
min.isr + ack
机制;__consumer_offset
这个内部的 topic 很像,TransactionCoordinator 也跟 GroupCoordinator 类似,而对应事务数据(transaction log)就是 __transaction_state
这个内部 topic,所有事务状态信息都会持久化到这个 topic,TransactionCoordinator 在做故障恢复也是从这个 topic 中恢复数据;上面的分析都是个人见解,有问题欢迎指正~
下面这节就讲述一下事务性实现的一些关键的实现机制(对这些细节不太感兴趣或者之前没有深入接触过 Kafka,可以直接跳过,直接去看下一节的事务流程处理,先去了解一下一个事务操作的主要流程步骤)。
TransactionCoordinator 与 GroupCoordinator 有一些相似之处,它主要是处理来自 Transactional Producer 的一些与事务相关的请求,涉及的请求如下表所示(关于这些请求处理的详细过程会在下篇文章详细讲述,这里先有个大概的认识即可):
请求类型 | 用途说明 |
---|---|
ApiKeys.FIND_COORDINATOR | Transaction Producer 会发送这个 FindCoordinatorRequest 请求,来查询当前事务(txn.id)对应的 TransactionCoordinator,这个与 GroupCoordinator 查询类似,是根据 txn.id 的 hash 值取模找到对应 Partition 的 leader,这个 leader 就是该事务对应的 TransactionCoordinator |
ApiKeys.INIT_PRODUCER_ID | Producer 初始化时,会发送一个 InitProducerIdRequest 请求,来获取其分配的 PID 信息,对于幂等性的 Producer,会随机选择一台 broker 发送请求,而对于 Transaction Producer 会选择向其对应的 TransactionCoordinator 发送该请求(目的是为了根据 txn.id 对应的事务状态做一些判断) |
ApiKeys.ADD_PARTITIONS_TO_TXN | 将这个事务涉及到的 topic-partition 列表添加到事务的 meta 信息中(通过 AddPartitionsToTxnRequest 请求),事务 meta 信息需要知道当前的事务操作涉及到了哪些 Topic-Partition 的写入 |
ApiKeys.ADD_OFFSETS_TO_TXN | Transaction Producer 的这个 AddOffsetsToTxnRequest 请求是由 sendOffsetsToTransaction() 接口触发的,它主要是用在 consume-process-produce 的场景中,这时候 consumer 也是整个事务的一部分,只有这个事务 commit 时,offset 才会被真正 commit(主要还是用于 Failover) |
ApiKeys.END_TXN | 当提交事务时, Transaction Producer 会向 TransactionCoordinator 发送一个 EndTxnRequest 请求,来 commit 或者 abort 事务 |
TransactionCoordinator 对象中还有两个关键的对象,分别是:
总结一下,TransactionCoordinator 主要的功能有三个,分别是:
在前面分析中,讨论过一个问题,那就是如果 TransactionCoordinator 故障的话应该怎么恢复?怎么恢复之前的状态?我们知道 Kafka 内部有一个事务 topic __transaction_state
,一个事务应该由哪个 TransactionCoordinator 来处理,是根据其 txn.id 的 hash 值与 __transaction_state
的 partition 数取模得到,__transaction_state
Partition 默认是50个,假设取模之后的结果是2,那么这个 txn.id 应该由 __transaction_state
Partition 2 的 leader 来处理。
对于 __transaction_state
这个 topic 默认是由 Server 端的 transaction.state.log.replication.factor
参数来配置,默认是3,如果当前 leader 故障,需要进行 leader 切换,也就是对应的 TransactionCoordinator 需要迁移到新的 leader 上,迁移之后,如何恢复之前的事务状态信息呢?
正如 GroupCoordinator 的实现一样,TransactionCoordinator 的恢复也是通过 __transaction_state
中读取之前事务的日志信息,来恢复其状态信息,前提是要求事务日志写入做相应的不丢配置。这也是 __transaction_state
一个重要作用之一,用于 TransactionCoordinator 的恢复,__transaction_state
与 __consumer_offsets
一样是 compact 类型的 topic,其 scheme 如下:
1 | Key => Version TransactionalId |
终于讲到了 Transaction Marker,这也是前面留的一个疑问,什么是 Transaction Marker?Transaction Marker 是用来解决什么问题的呢?
Transaction Marker 也叫做 control messages,它的作用主要是告诉这个事务操作涉及的 Topic-Partition Set 的 leaders 当前的事务操作已经完成,可以执行 commit 或者 abort(Marker 主要的内容就是 commit 或 abort),这个 marker 数据由该事务的 TransactionCoordinator 来发送的。我们来假设一下:如果没有 Transaction Marker,一个事务在完成后,如何执行 commit 操作?(以这个事务涉及多个 Topic-Partition 写入为例)
在 Transactional Producer 通知这些 Topic-Partition 的 leader 事务可以 commit 时,这些 Topic-Partition 应该怎么处理呢?难道是 commit 时再把数据持久化到磁盘,abort 时就直接丢弃不做持久化?这明显是问题的,如果这是一个 long transaction 操作,写数据非常多,内存中无法存下,数据肯定是需要持久化到硬盘的,如果数据已经持久化到硬盘了,假设这个时候收到了一个 abort 操作,是需要把数据再从硬盘清掉?
再看下如果有了 Transaction Marker 这个机制后,情况会变成什么样?
Transaction Marker 的数据格式如下,其中 ControlMessageType 为 0 代表是 COMMIT,为 1 代表是 ABORT:
1 | ControlMessageKey => Version ControlMessageType |
这里再讲一个额外的内容,对于事务写入的数据,为了给消息添加一个标识(标识这条消息是不是来自事务写入的),数据格式(消息协议)发生了变化,这个改动主要是在 Attribute 字段,对于 MessageSet,Attribute 是16位,新的格式如下:
1 | | Unused (6-15) | Control (5) | Transactional (4) | Timestamp Type (3) | Compression Type (0-2) | |
对于 Message,也就是单条数据存储时(其中 Marker 数据都是单条存储的),在 Kafka 中,只有 MessageSet 才可以做压缩,所以 Message 就没必要设置压缩字段,其格式如下:
1 | | Unused (1-7) | Control Flag(0) | |
TransactionCoordinator 会维护相应的事务的状态信息(也就是 TxnStatus),对于一个事务,总共有以下几种状态:
状态 | 状态码 | 说明 |
---|---|---|
Empty | 0 | Transaction has not existed yet |
Ongoing | 1 | Transaction has started and ongoing |
PrepareCommit | 2 | Group is preparing to commit |
PrepareAbort | 3 | Group is preparing to abort |
CompleteCommit | 4 | Group has completed commit |
CompleteAbort | 5 | Group has completed abort |
Dead | 6 | TransactionalId has expired and is about to be removed from the transaction cache |
PrepareEpochFence | 7 | We are in the middle of bumping the epoch and fencing out older producers |
其相应有效的状态转移图如下:
正常情况下,对于一个事务而言,其状态状态流程应该是 Empty –> Ongoing –> PrepareCommit –> CompleteCommit –> Empty 或者是 Empty –> Ongoing –> PrepareAbort –> CompleteAbort –> Empty。
Client 的事务状态信息主要记录本地事务的状态,当然跟其他的系统类似,本地的状态信息与 Server 端的状态信息并不完全一致(状态的设置,就像 GroupCoodinator 会维护一个 Group 的状态,每个 Consumer 也会维护本地的 Consumer 对象的状态一样)。Client 端的事务状态信息主要用于 Client 端的事务状态处理,其主要有以下几种:
initTransactions()
方法初始化事务相关的内容,比如发送 InitProducerIdRequest 请求;beginTransaction()
方法,开始一个事务,标志着一个事务开始初始化;commitTransaction()
方法时,会先更新本地的状态信息;abortTransaction()
方法时,会先更新本地的状态信息;Client 端状态如下图:
有了前面对 Kafka 事务性关键实现的讲述之后,这里详细讲述一个事务操作的处理流程,当然这里只是重点讲述事务性相关的内容,官方版的流程图可参考Kafka Exactly-Once Data Flow,这里我做了一些改动,其流程图如下:
这个流程是以 consume-process-produce 场景为例(主要是 kafka streams 的场景),图中红虚框及 4.3a 部分是关于 consumer 的操作,去掉这部分的话,就是只考虑写入情况的场景。这种只考虑写入场景的事务操作目前在业内应用也是非常广泛的,比如 Flink + Kafka 端到端的 Exactly-Once 实现就是这种场景,下面来详细讲述一下整个流程。
对于事务性的处理,第一步首先需要做的就是找到这个事务 txn.id 对应的 TransactionCoordinator,Transaction Producer 会向 Broker (随机选择一台 broker,一般选择本地连接最少的这台 broker)发送 FindCoordinatorRequest 请求,获取其 TransactionCoordinator。
怎么找到对应的 TransactionCoordinator 呢?这个前面已经讲过了,主要是通过下面的方法获取 __transaction_state
的 Partition,该 Partition 对应的 leader 就是这个 txn.id 对应的 TransactionCoordinator。
1 | def partitionFor(transactionalId: String): Int = Utils.abs(transactionalId.hashCode) % transactionTopicPartitionCount |
PID 这里就不再介绍了,不了解的可以看前面那篇文章(Producer ID)。
Transaction Producer 在 initializeTransactions()
方法中会向 TransactionCoordinator 发送 InitPidRequest 请求获取其分配的 PID,有了 PID,事务写入时可以保证幂等性,PID 如何分配可以参考 PID 分配,但是 TransactionCoordinator 在给事务 Producer 分配 PID 会做一些判断,主要的内容是:
prepareInitProduceIdTransit()
方法):1 | //note: producer 启用事务性的情况下,检测此时事务的状态信息 |
前面两步都是 Transaction Producer 调用 initTransactions()
部分,到这里,Producer 可以调用 beginTransaction()
开始一个事务操作,其实现方法如下面所示:
1 | //KafkaProducer |
这里只是将本地事务状态转移成 IN_TRANSACTION,并没有与 Server 端进行交互,所以在流程图中没有体现出来(TransactionManager 初始化时,其状态为 UNINITIALIZED,Producer 调用 initializeTransactions()
方法,其状态转移成 INITIALIZING)。
在这个阶段,Transaction Producer 会做相应的处理,主要包括:从 consumer 拉取数据、对数据做相应的处理、通过 Producer 写入到下游系统中(对于只有写入场景,忽略前面那一步即可),下面有一个示例(start 和 end 中间的部分),是一个典型的 consume-process-produce 场景:
1 | while (true) { |
下面来结合前面的流程图来讲述一下这部分的实现。
Producer 在调用 send()
方法时,Producer 会将这个对应的 Topic—Partition 添加到 TransactionManager 的记录中,如下所示:
1 | //note: 如何开启了幂等性或事务性,需要做一些处理 |
如果这个 Topic-Partition 之前不存在,那么就添加到 newPartitionsInTransaction 集合中,如下所示:
1 | //note: 将 tp 添加到 newPartitionsInTransaction 中,记录当前进行事务操作的 tp |
Producer 端的 Sender 线程会将这个信息通过 AddPartitionsToTxnRequest 请求发送给 TransactionCoordinator,也就是图中的 4.1 过程,TransactionCoordinator 会将这个 Topic-Partition 列表更新到 txn.id 对应的 TransactionMetadata 中,并且会持久化到事务日志中,也就是图中的 4.1 a 部分,这里持久化的数据主要是 txn.id 与其涉及到的 Topic-Partition 信息。
这一步与正常 Producer 写入基本上一样,就是相应的 Leader 在持久化数据时会在头信息中标识这条数据是不是来自事务 Producer 的写入(主要是数据协议有变动,Server 处理并不需要做额外的处理)。
Producer 在调用 sendOffsetsToTransaction()
方法时,第一步会首先向 TransactionCoordinator 发送相应的 AddOffsetsToTxnRequest 请求,如下所示:
1 | //class KafkaProcducer |
TransactionCoordinator 在收到这个请求时,处理方法与 4.1 中的一样,把这个 group.id 对应的 __consumer_offsets
的 Partition (与写入涉及的 Topic-Partition 一样)保存到事务对应的 meta 中,之后会持久化相应的事务日志,如图中 4.3a 所示。
Producer 在收到 TransactionCoordinator 关于 AddOffsetsToTxnRequest 请求的结果后,后再次发送 TxnOffsetsCommitRequest 请求给对应的 GroupCoordinator,AddOffsetsToTxnHandler 的 handleResponse()
的实现如下:
1 |
|
GroupCoordinator 在收到相应的请求后,会将 offset 信息持久化到 consumer offsets log 中(包含对应的 PID 信息),但是不会更新到缓存中,除非这个事务 commit 了,这样的话就可以保证这个 offset 信息对 consumer 是不可见的(没有更新到缓存中的数据是不可见的,通过接口是获取的,这是 GroupCoordinator 本身来保证的)。
在一个事务操作处理完成之后,Producer 需要调用 commitTransaction()
或者 abortTransaction()
方法来 commit 或者 abort 这个事务操作。
无论是 Commit 还是 Abort,对于 Producer 而言,都是向 TransactionCoordinator 发送 EndTxnRequest 请求,这个请求的内容里会标识是 commit 操作还是 abort 操作,Producer 的 commitTransaction()
方法实现如下所示:
1 | //class KafkaProducer |
Producer 的 abortTransaction()
方法实现如下:
1 | //class KafkaProducer |
它们最终都是调用了 TransactionManager 的 beginCompletingTransaction()
方法,这个方法会向其 待发送请求列表 中添加 EndTxnRequest 请求,其实现如下:
1 | //note: 发送 EndTxnRequest 请求,添加到 pending 队列中 |
TransactionCoordinator 在收到 EndTxnRequest 请求后,会做以下处理:
WriteTxnMarkerRquest 是 TransactionCoordinator 收到 Producer 的 EndTxnRequest 请求后向其他 Broker 发送的请求,主要是告诉它们事务已经完成。不论是普通的 Topic-Partition 还是 __consumer_offsets
,在收到这个请求后,都会把事务结果(Transaction Marker 的格数据式见前面)持久化到对应的日志文件中,这样下游 Consumer 在消费这个数据时,就知道这个事务是 commit 还是 abort。
当这个事务涉及到所有 Topic-Partition 都已经把这个 marker 信息持久化到日志文件之后,TransactionCoordinator 会将这个事务的状态置为 COMMIT 或 ABORT,并持久化到事务日志文件中,到这里,这个事务操作就算真正完成了,TransactionCoordinator 缓存的很多关于这个事务的数据可以被清除了。
在上面讲述完 Kafka 事务性处理之后,我们来思考一下以下这些问题,上面的流程可能会出现下面这些问题或者很多人可能会有下面的疑问:
下面,来详细分析一下上面提到的这些问题。
对于这个情况,我们这里直接做了一个相应的实验,两个 Producer 示例都使用了同一个 txn.id(为 test-transactional-matt),Producer 1 先启动,然后过一会再启动 Producer 2,这时候会发现一个现象,那就是 Producer 1 进程会抛出异常退出进程,其异常信息为:
1 | org.apache.kafka.common.KafkaException: Cannot execute transactional method because we are in an error state |
这里抛出了 ProducerFencedException 异常,如果打开相应的 Debug 日志,在 Producer 1 的日志文件会看到下面的日志信息
1 | [2018-11-03 12:48:52,495] DEBUG [Producer clientId=ProducerTransactionExample, transactionalId=test-transactional-matt] Transition from state COMMITTING_TRANSACTION to error state FATAL_ERROR (org.apache.kafka.clients.producer.internals.TransactionManager) |
Producer 1 本地事务状态从 COMMITTING_TRANSACTION 变成了 FATAL_ERROR 状态,导致 Producer 进程直接退出了,出现这个异常的原因,就是抛出的 ProducerFencedException 异常,简单来说 Producer 1 被 Fencing 了(这是 Producer Fencing 的情况)。因此,这个问题的答案就很清除了,如果多个 Producer 共用一个 txn.id,那么最后启动的 Producer 会成功运行,会它之前启动的 Producer 都 Fencing 掉(至于为什么会 Fencing 下一小节会做分析)。
关于 Fencing 这个机制,在分布式系统还是很常见的,我第一个见到这个机制是在 HDFS 中,可以参考我之前总结的一篇文章 HDFS NN 脑裂问题,Fencing 机制解决的主要也是这种类型的问题 —— 脑裂问题,简单来说就是,本来系统这个组件在某个时刻应该只有一个处于 active 状态的,但是在实际生产环境中,特别是切换期间,可能会同时出现两个组件处于 active 状态,这就是脑裂问题,在 Kafka 的事务场景下,用到 Fencing 机制有两个地方:
TransactionCoordinator 在遇到上 long FGC 时,可能会导致 脑裂 问题,FGC 时会 stop-the-world,这时候可能会与 zk 连接超时导致临时节点消失进而触发 leader 选举,如果 __transaction_state
发生了 leader 选举,TransactionCoordinator 就会切换,如果此时旧的 TransactionCoordinator FGC 完成,在还没来得及同步到最细 meta 之前,会有一个短暂的时刻,对于一个 txn.id 而言就是这个时刻可能出现了两个 TransactionCoordinator。
相应的解决方案就是 TransactionCoordinator Fencing,这里 Fencing 策略不像离线场景 HDFS 这种直接 Kill 旧的 NN 进程或者强制切换状态这么暴力,而是通过 CoordinatorEpoch 来判断,每个 TransactionCoordinator 都有其 CoordinatorEpoch 值,这个值就是对应 __transaction_state
Partition 的 Epoch 值(每当 leader 切换一次,该值就会自增1)。
明白了 TransactionCoordinator 脑裂问题发生情况及解决方案之后,来分析下,Fencing 机制会在哪里发挥作用?仔细想想,是可以推断出来的,只可能是 TransactionCoordinator 向别人发请求时影响才会比较严重(特别是乱发 admin 命令)。有了 CoordinatorEpoch 之后,其他 Server 在收到请求时做相应的判断,如果发现 CoordinatorEpoch 值比缓存的最新的值小,那么 Fencing 就生效,拒绝这个请求,也就是 TransactionCoordinator 发送 WriteTxnMarkerRequest 时可能会触发这一机制。
Producer Fencing 与前面的类似,如果对于相同 PID 和 txn.id 的 Producer,Server 端会记录最新的 Epoch 值,拒绝来自 zombie Producer (Epoch 值小的 Producer)的请求。前面第一个问题的情况,Producer 2 在启动时,会向 TransactionCoordinator 发送 InitPIDRequest 请求,此时 TransactionCoordinator 已经有了这个 txn.id 对应的 meta,会返回之前分配的 PID,并把 Epoch 自增 1 返回,这样 Producer 2 就被认为是最新的 Producer,而 Producer 1 就会被认为是 zombie Producer,因此,TransactionCoordinator 在处理 Producer 1 的事务请求时,会返回相应的异常信息。
在讲述这个问题之前,需要先介绍一下事务场景下,Consumer 的消费策略,Consumer 有一个 isolation.level
配置,这个是配置对于事务性数据的消费策略,有以下两种可选配置:
read_committed
: only consume non-transactional messages or transactional messages that are already committed, in offset ordering.read_uncommitted
: consume all available messages in offset ordering. This is the default value.简单来说就是,read_committed 只会读取 commit 的数据,而 abort 的数据不会向 consumer 显现,对于 read_uncommitted 这种模式,consumer 可以读取到所有数据(control msg 会过滤掉),这种模式与普通的消费机制基本没有区别,就是做了一个 check,过滤掉 control msg(也就是 marker 数据),这部分的难点在于 read_committed 机制的实现。
在事务机制的实现中,Kafka 又设置了一个新的 offset 概念,那就是 Last Stable Offset,简称 LSO(其他的 Offset 概念可参考 Kafka Offset 那些事),先看下 LSO 的定义:
The LSO is defined as the latest offset such that the status of all transactional messages at lower offsets have been determined (i.e. committed or aborted).
对于一个 Partition 而言,offset 小于 LSO 的数据,全都是已经确定的数据,这个主要是对于事务操作而言,在这个 offset 之前的事务操作都是已经完成的事务(已经 commit 或 abort),如果这个 Partition 没有涉及到事务数据,那么 LSO 就是其 HW(水位)。
如果 Consumer 的消费策略设置的是 read_committed,其在向 Server 发送 Fetch 请求时,Server 端只会返回 LSO 之前的数据,在 LSO 之后的数据不会返回。
这种机制有没有什么问题呢?我现在能想到的就是如果有一个 long transaction,比如其 first offset 是 1000,另外有几个已经完成的小事务操作,比如:txn1(offset:1100~1200)、txn2(offset:1400~1500),假设此时的 LSO 是 1000,也就是说这个 long transaction 还没有完成,那么已经完成的 txn1、txn2 也会对 consumer 不可见(假设都是 commit 操作),此时受 long transaction 的影响可能会导致数据有延迟。
那么我们再来想一下,如果不设计 LSO,又会有什么问题呢?可能分两种情况:
从这些分析来看,个人认为 LSO 机制还是一种相当来说 实现起来比较简单、而且不影响原来 server 端性能、还能保证顺序性的一种设计方案,它不一定是最好的,但也不会差太多。在实际的生产场景中,尽量避免 long transaction 这种操作,而且 long transaction可能也会容易触发事务超时。
Consumer 在拉取到相应的数据之后,后面该怎么处理呢?它拉取到的这批数据并不能保证都是完整的事务数据,很有可能是拉取到一个事务的部分数据(marker 数据还没有拉取到),这时候应该怎么办?难道 Consumer 先把这部分数据缓存下来,等后面的 marker 数据到来时再确认数据应该不应该丢弃?(还是又 OOM 的风险)有没有更好的实现方案?
Kafka 的设计总是不会让我们失望,这部分做的优化也是非常高明,Broker 会追踪每个 Partition 涉及到的 abort transactions,Partition 的每个 log segment 都会有一个单独只写的文件(append-only file)来存储 abort transaction 信息,因为 abort transaction 并不是很多,所以这个开销是可以可以接受的,之所以要持久化到磁盘,主要是为了故障后快速恢复,要不然 Broker 需要把这个 Partition 的所有数据都读一遍,才能直到哪些事务是 abort 的,这样的话,开销太大(如果这个 Partition 没有事务操作,就不会生成这个文件)。这个持久化的文件是以 .txnindex
做后缀,前面依然是这个 log segment 的 offset 信息,存储的数据格式如下:
1 | TransactionEntry => |
有了这个设计,Consumer 在拉取数据时,Broker 会把这批数据涉及到的所有 abort transaction 信息都返回给 Consumer,Server 端会根据拉取的 offset 范围与 abort transaction 的 offset 做对比,返回涉及到的 abort transaction 集合,其实现如下:
1 | def collectAbortedTxns(fetchOffset: Long, upperBoundOffset: Long): TxnIndexSearchResult = { |
Consumer 在拿到这些数据之后,会进行相应的过滤,大概的判断逻辑如下(Server 端返回的 abort transaction 列表就保存在 abortedTransactions
集合中,abortedProducerIds
最开始时是为空的):
abortedProducerIds
集合删掉,是 COMMIT 的话,就忽略(每个这个 PID 对应的 marker 数据收到之后,就从 abortedProducerIds
中清除这个 PID 信息);abortedTransactions
队列(有序队列,头部 transaction 的 first offset 最小)第一个 transaction 做比较,如果 PID 相同,并且 offset 大于等于这个 transaction 的 first offset,就将这个 PID 信息添加到 abortedProducerIds
集合中,同时从 abortedTransactions
队列中删除这个 transaction,最后再丢掉这个 batch(它是 abort transaction 的数据);abortedProducerIds
集合中,在的话,就丢弃,不在的话就返回上层应用。这部分的实现确实有些绕(有兴趣的可以慢慢咀嚼一下),它严重依赖了 Kafka 提供的下面两种保证:
这部分代码实现如下:
1 | private Record nextFetchedRecord() { |
有了前面的分析,这个问题就很好回答了,顺序性还是严格按照 offset 的,只不过遇到 abort trsansaction 的数据时就丢弃掉,其他的与普通 Consumer 并没有区别。
Producer 在开始一个事务操作时,可以设置其事务超时时间(参数是 transaction.timeout.ms
,默认60s),而且 Server 端还有一个最大可允许的事务操作超时时间(参数是 transaction.timeout.ms
,默认是15min),Producer 设置超时时间不能超过 Server,否则的话会抛出异常。
上面是关于事务操作的超时设置,而对于 txn.id,我们知道 TransactionCoordinator 会缓存 txn.id 的相关信息,如果没有超时机制,这个 meta 大小是无法预估的,Server 端提供了一个 transaction.id.expiration.ms
参数来配置这个超时时间(默认是7天),如果超过这个时间没有任何事务相关的请求发送过来,那么 TransactionCoordinator 将会使这个 txn.id 过期。
对于每个 Topic-Partition,Broker 都会在内存中维护其 PID 与 sequence number(最后成功写入的 msg 的 sequence number)的对应关系(这个在上面幂等性文章应讲述过,主要是为了不丢补充的实现)。
Broker 重启时,如果想恢复上面的状态信息,那么它读取所有的 log 文件。相比于之下,定期对这个 state 信息做 checkpoint(Snapshot),明显收益是非常大的,此时如果 Broker 重启,只需要读取最近一个 Snapshot 文件,之后的数据再从 log 文件中恢复即可。
这个 PID Snapshot 样式如 00000000000235947656.snapshot,以 .snapshot
作为后缀,其数据格式如下:
1 | [matt@XXX-35 app.matt_test_transaction_json_3-2]$ /usr/local/java18/bin/java -Djava.ext.dirs=/XXX/kafka/libs kafka.tools.DumpLogSegments --files 00000000000235947656.snapshot |
在实际的使用中,这个 snapshot 文件一般只会保存最近的两个文件。
对于上面所讲述的一个事务操作流程,实际生产环境中,任何一个地方都有可能出现的失败:
beginTransaction()
时,如果出现 timeout 或者错误:Producer 只需要重试即可;commitTransaction()
时出现 timeout 或者错误:Producer 应该重试这个请求;陆陆续续写了几天,终于把这篇文章总结完了。
参考:
]]>