在大数据处理和分析中,Apache Spark
和 Presto
是在数仓行业中标杆产品,都经常被用于各家数仓架构中用于离线实时建设。尽管这几年云原生大数据数据库产品大爆发,但是从技术架构上说仍然都还是这两种范式。
在这篇文章中,我们首先会用一个简单的SQL说明数据库 Volcano
算法,然后再阐述 Spark SQL
与Presto
分布式执行架构和并发环境下是如何做任务调度的。希望通过这个案例分析能让大家对 StageByStage 和 MPP 执行引擎的区别有所认知,也更希望本文能够帮助大家在数仓选型和业务调优上有一些帮助。
基本算法
首先让我们考虑一个简单的数据库查询,我们将使用伪代码来描述每种方法的处理方式。
ini
SELECT *
FROM Orders
JOIN Customers ON Orders.CustomerID = Customers.ID
WHERE Customers.Country = 'Germany';
方案 1
一种简单的方法,我们一次性处理每个操作,并将中间结果存储在某个地方以供下一步使用,这里假设Customers和Orders就是个在内存中的一个集合(在具体业界中,这里涉及的东西非常负载,需要决策怎么去做IO,扫描文件还是索引,怎么处理不同文件,怎么做并发并行)
ini
# 假设我们有两个函数: join_tables 和 filter_rows
# 首先,我们执行连接操作,并存储结果
joined_result = join_tables(Orders, Customers, on='CustomerID=ID')
# 接下来,我们对连接后的结果应用过滤条件,并再次存储结果
final_result = filter_rows(joined_result, where="Country='Germany'")
# 最后,我们遍历最终结果并返回
for row in final_result:
print(row)
在这种方式中,join_tables
和 filter_rows
函数分别处理它们的任务并产生中间结果。这些结果可能很大,需要被完整地存储在内存或临时存储中,直到下一步操作完成, 资源消耗就很高。这种实现的好处是控制逻辑很简单,也很容易确定哪个阶段失败了,恢复逻辑简单。
方案 2 (Volcano 算法)
我们可以使用迭代器模式来处理这个查询操作,我们先给每个运算定义好三个接口,open()
,next()
和close()
如下:
python
class JoinOperator:
def __init__(self, left_input, right_input, condition):
self.left_input = left_input
self.right_input = right_input
self.condition = condition
self.current_left_row = None
def open(self):
self.left_input.open()
self.right_input.open()
def next(self):
while True:
if self.current_left_row is None:
self.current_left_row = self.left_input.next()
if self.current_left_row is None:
return None
self.right_input.rewind()
right_row = self.right_input.next()
if right_row is not None and self.condition(self.current_left_row, right_row):
return self.current_left_row, right_row
elif right_row is None:
self.current_left_row = None
def close(self):
self.left_input.close()
self.right_input.close()
class FilterOperator:
def __init__(self, input_operator, condition):
self.input_operator = input_operator
self.condition = condition
def open(self):
self.input_operator.open()
def next(self):
while True:
row = self.input_operator.next()
if row is None or self.condition(row):
return row
def close(self):
self.input_operator.close()
真正执行的时候,迭代器模式就是先创建好迭代器其实就是定义好这个依赖关系。然后从Root节点开始调用 next()
方法时,JoinOperator
和 FilterOperator
都仅处理并返回一行(部分)结果,而不是整个中间结果集。
python
# 创建迭代器
join_op = JoinOperator(OrdersIterator(), CustomersIterator(), condition=lambda o, c: o.CustomerID == c.ID)
filter_op = FilterOperator(join_op, condition=lambda row: row.Customers.Country == 'Germany')
filter_op.open()
# 执行查询
while True:
result_row = filter_op.next()
if result_row is None:
break
print(result_row)
filter_op.close()
这种方式其实就是20世纪90年代早期由Goetz Graefe 提出用于数据库查询执行的Volcano 模型。Volcano 模型避免了需要大量内存来存储中间结果,并且可以在数据流经操作符时即时进行处理。这种按需处理的方式使得 Volcano 模型在处理大型数据集时更高效,尤其是在数据无法完全放入内存的情况下,但是会有大量的虚函数调用开销以及某些场景下 cpu cache 命中率不够高。
Spark
架构
上述SQL优化之后会变成下述的Plan,会把过滤条件下推到 JOIN
操作之前 (图1(a))。然后,Spark
的DAG
执行引擎将这个优化的计算计划转换成一个或多个作业, 每个作业由一个DAG的Stage
组成,图1(b)表示产生当前作业最终结果的数据变换的血缘关系。每个Stage之间的中间数据通过 shuffle
操作传输。
Shuffle 算法
Shuffle
操作是 MapReduce
计算范式的关键所在,它将涉及的中间数据 map
和reduce
任务之间之间进行传输。尽管 shuffle
操作的基础概念很直接,但是不同框架采取了不同的方法来实现它。
在Spark中,根据部署模式的不同,执行shuffle的方式也略有不同。在YARN上部署Spark,并利用外部shuffle服务来管理shuffle数据的部署模式下,Spark中的shuffle操作工作如图2所示:
-
每个Spark执行器启动时都会向位于同一节点上的Spark外部shuffle服务(ESS)注册。这样的注册让Spark ESS知道每个已注册执行器的本地map任务产生的实体化shuffle数据的位置。注意,Spark ESS实例是独立于Spark执行器的,并且可能在许多Spark应用程序之间共享。
-
shuffle map 阶段中的每个任务处理它的数据部分。在map任务结束时,它产生一对文件,一个用于shuffle数据,另一个用于索引前者中的shuffle块。为了做到这一点,map任务根据分区键的哈希值对所有转换后的记录进行排序。在这个过程中,如果map任务不能在内存中对所有数据进行排序,它可能会将中间数据溢出到磁盘上。排序完成后,就会生成shuffle数据文件,在该文件中,属于同一shuffle分区的所有记录被组织到一个shuffle块中。同时也会生成相应的shuffle索引文件,该文件记录了块边界偏移量。
-
当下一阶段的reduce任务开始运行时,它们将向Spark驱动程序查询它们输入shuffle块的位置。一旦这个信息可用,每个reduce任务将建立与相应的Spark ESS实例的连接,以便获取它们的输入数据。Spark ESS在收到这样的请求后,利用shuffle索引文件跳转到shuffle数据文件中相应的块数据,从磁盘上读取它,并将其发送回reduce任务。
DAG Task 调度
经过Stage划分之后,会产生一个或者多个互相关联的Stage。其中,真正执行Action算子的RDD所在的Stage被称为Final Stage
,DAGScheduler
会从这个Final Stage
生成作业实例。
在提交Stage
时,DAGScheduler
会先判断该Stage
的父Stage
的执行结果是否可用。如果所有父Stage
的执行结果都可用,则提交该Stage
。如果有任意一个父Stage
的结果不可用,则尝试递归提交该父Stage
。
所以,从上述两个角度来看,就可以发现 Spark
这种引擎实现的时候是通过Shuffle
把数据持久化,一个Stage
一个Stage
的调度执行,我猜这也是业界说这种方式是StageByStage
的原因。
Presto / Trino
Task 调度
还是上面的SQL 经过Trino Parse/Optimize
之后会变成下述的Plan
,然后按照 Stage
将这个 Plan
转换成一个或多个 Task
,每个Stage
之间的中间数据通过 shuffle
操作传输。
这里和 Spark 不同的地方在于:
i. shuffle 不落地,只是把数据放到内存中。比如Stage 1-2 中的buffer,按照下游并发的数量分桶,Task 中PartitionOutput 算子会把数据按照shuffle 的规则分到这几个buffer中,下游Stage 3 每个Woeker(并发)分来来拉取对应buffer中的数据。
ii. 在某个Query内,下游的Stage 也不需要等待前面的Stage的所有Task都计算完才开始拉数据,而是会定期轮询找上游依赖的Stage 来拉取部分buffer数据。不过下图中的Join(HashJoin的话)比较特殊,会先把一边的数据全部拉齐,建好HashTable,然后再开始pipeline拉取另一边的数据,往后pipeline起来。
本地数据流
如下图所示,在 Trino 执行模型中,除了节点的并发之外,还有节点内部的并行(实现过程中是线程上的并行)。一个Stage会生成多个Task调度到到各个 Worker节点
内执行,每个Task内还会分成多个pipeline(一般是一个),每个pipeline内部会并发生成多个Driver,每个Driver执行的内容都是一段一样的算子树。
每个Driver处理的数据单元被抽象成Split
,被指派给一个线程执行。Trino的DriverLoop
比流行的Volcano模型(拉模式)的递归迭代器要复杂,首先它更加适合协作式多任务处理,因为在让出线程之前,可以快速将操作符带到一个已知的状态,而不是无限期地阻塞。此外,Driver
可以在没有额外输入的情况下在操作符之间移动数据(例如,恢复资源密集型或爆炸性转换的计算)来最大限度地增加每一个时间段执行的工作。
DriverLoop
持续地在操作符之间移动Page
,直到调度时间片完成,或直到操作符无法取得进展。详细的逻辑大家可以参考代码io.trino.operator.Driver#processInternal()
Stage之间的调度
Trino旨在最大限度地减少端到端延迟,同时最大化资源利用率。Trino使用基于内存的buffer
,通过HTTP来交换中间结果, 任务产生的数据存储在内存buffer
中,供其他Worker
消费。使用HTTP长轮询从其他工作节点请求中间结果,这种机制提供的延迟比持久化到磁盘的其他系统要低得多。
从上面看的出来,Trino是以 pipeline 方式保证数据持续流动的。在某个Task内不需要前面的Operator计算完所有数据再输出结果给后面的Operator,在某个Query内也不需要前面的Stage的所有Task都计算完所有数据再输出结果给后面的Stage。