(
col1 int,
col2 string
)
LOCATION ‘oss://test-oss-bucket/tbl1_part/kv1.txt’;
如果要支持一种新的数据源的话,在Presto里面,我们是实现一个 Connector
, 而在 F1 Query 里面是实现一个 Table-Valued Function(TVF)。
Data Sink
数据查询出来之后可以直接返回给客户端显示,也可以根据客户端的语句直接插入到另外一个表,这个表可以是被 Catalog Service
管理的表,也可以不是。如果是被管理的表,那么是通过 CREATE TABLE
语法创建出来的。而这个 Data Sink 的表默认是的实现是保存到 Google 的 Colossus 分布式文件系统上面去了。而用户也可以像 DEFINE TABLE 语法一样,可以用 EXPORT DATA
语法指定输出到自定义的表里面去。
在这一点上 F1 Query 貌似没有 Presto 来的灵活,Presto 里面的 Data Sink 可以是任何类型的存储,并且不需要什么特殊的
EXPORT DATA
的语法。
查询语言
F1 Query的查询语言是 SQL 2011
, 他们在这上面做了一些扩展以进行嵌套结构的数据查询。比较值得一提的是,F1 Query
的SQL方言跟 Big Query
、Dremel
以及 Spanner SQL
是一样的,这样用户可以在这些系统之间很容易进行迁移 – 统一是主旋律啊。
三大执行模式
前面也提到过,F1 Query 支持三种执行模式,他们的名字分别为 Centralized Execution
, Distributed Execution
以及 Batch Execution
。其中 Centralized Execution
和 Distributed Execution
都属于交互式(Interative)的执行模式。
交互式执行模式
所谓的交互式执行模式很容易理解:用户是“在线等”的,因此要求响应时间要短,F1 Query内部对于这种执行模式使用的都是完全基于内存的流式执行策略的。
Centralized Execution
对于中心化的执行,接到这个请求的 F1 Server
直接就执行掉了, 因为这种请求处理的数据量不大,对于资源的要求不高,因此 F1 Server
内部其实是以单线程的 pull-based
模式来执行的:
pull-based模型
之所以把它叫做 pull-based
模型,是因为当这个计划开始执行的时候,上层的算子递归地调用底层算子的 GetNext()
方法来获取它自己的输入。总体来说数据都是被从下向上“拉”出来的,因此叫 pull-based
。
Distributed Execution
对于 Distributed Execution
,第一个接到这个查询请求的 F1 Server
只是充当一个调度者的角色,真正的执行是由一组 F1 Worker
共同执行。
这种模式的架构就跟 Presto 很像了,这两个角色在 Presto 里面分别叫做
Coordinator
和Worker
。
那么什么时候用 Centralized
模式,什么时候用 Distributed
模式呢? 优化器对 SQL 进行解析,如果发现这个查询最好要用大并发进行分区读的话,那么它会走 Distributed
的模式,否则走的就是 Centralized
模式。
在分布式的执行计划里面,整个执行计划会被分拆成一些执行计划片段( Fragments
), 每个片段由一组 F1 Worker
来执行,这些片段是同时并发执行的,并且内部可能会应用流水线技术。
优化器是怎么把整个执行计划拆分成多个 Fragments
的呢? 优化器使用的是自底向上的策略来拆分的,每个单独的算子对于输入数据的分布(Data Distribution)都会可以有一定的要求的。一般来说这种要求是指数据是否按照某个字段进行分片。典型的例子是 Hash Join , Hash Join 需要数据按照 Group Key 或者 Join Key 进行 hash 分片 – 这就是 HashJoin 算子的数据分布需求。如果当前的数据分布策略能够满足这个算子的要求,那么这个算子保留在当前的 Fragment 里面,否则我们就要在执行计划当中插入一个 Exchange 节点来进行数据的重新分布,同时也划分了Fragment 之间的边界。
分布式模式下的执行计划分片
划分了 Fragment
边界之后下面一件事件就是决定这些 Fragment 的并行度, 并行度的计算也是自底向上的过程,首先最底层的 TableScan 决定了最初的并行度,然后这种并行度的信息会被一层一层地上推给一个叫做 Width Calculator
的模块来逐步计算每个 Fragment 的并行度。比如一个 HashJoin 在一个 50 并行度和一个100 并行度的两个输入 Fragment 之间进行的话,那么这个 HashJoin 算子会选用 100 并行度以照顾比较大的那个输入算子。
感觉这就是在描述Presto的实现啊。在读这篇论文之前我一直搞不清楚的就是这个神奇的 Exchange 算子是怎么来的,看了这篇论文总算搞清楚了。
数据重分布(Reparitition)策略
F1 Query 里面的 Fragment
是并行执行的,整个执行的数据流可以看作一个DAG,数据在流经 Fragment 边界的时候会被一个 Exchange
算子进行重新分布(repartition), 对于每条数据, 数据的发送者利用一个分区函数来计算它的目的地(一个分区值: partition number
),而每个 partition number
对应到目标 Fragment 里面的一个具体的 Worker
。
而这个 Exchange
的算子是通过 RPC 来实现的(Presto里面也是这样的), 而且数据的发送和接收之间还有流控的机制,这种基于 RPC 的通信机制的并发性还是挺好的,可以做到每个 Fragment 几千个分区,如果要求更高的并发度,那么就要使用 Batch Execution
模式来执行了。
为了达到高效的查询,查询优化器会要求最底层的 TableScan 算子把数据切分成指定的并发度,而具体的 TableScan 算子就会产生 N 个分片描述,然后集群的调度器就会起N个 Worker ,来执行这N个分片的数据扫描操作。有时候数据的分片的个数会比 Worker 的数量要大,这样调度器会动态的把数据分片交给比较空闲的 Worker 去做,这样可以避免数据倾斜。
有些算子本身会作为当前 Fragment 的一个输入,比如 LookupJoin 会作为所在 Fragment 的左边输入,因为 LookupJoin 的两个输入的数据分布规则是一样的(左边输入的数据是根据右边输入数据查询出来的)。相对应的 HashJoin 则需要多个属于不同的Fragment,并且都有自己的多个分区。
一般来说优化器会把 HashJoin 每个输入放在一个单独的 Fragment 里面,除非它本身的数据分布跟 HashJoin 算子已经一样了。HashJoin 的两个输入根据相同的分片函数把数据发送到 HashJoin 所在的 Fragment 里面, 这样才能保证相同的 Key 的数据最后是在同一个分区里面,从而可以让每个分区可以处理一段独立的 Key 的取值空间。(否则数据就 Join 不上啊)
跟 HashJoin 类似, 聚合操作通常也需要对输入进行重新分布, 只不过聚合操作是根据要聚合的 Key 进行数据重分布。而如果聚合函数不是针对特定的 Key 进行聚合(比如 count(*)
), 那么所有的数据会被发送一个分区。这种情况是可以优化的,通常会在目标 Aggregation
算子之前生成另外一个 PartialAggregation
算子,这样做的好处一是提高了总体的并行度,因为多个Worker参与了聚合操作;另外因为做了部分聚合之后,要往下游发的数据变少了,Worker 间传送的总数据也就少了。
前面也说过 F1 Query
的执行是一个可能会有多个根节点的DAG, 一个上游节点的数据可能会流向多个下游的 Fragment , 比如对同一份输入进行多种聚合,F1 Query在实现这种执行计划的时候上游的 Fragment 只会执行一次,只是把数据发往多个下游而已。这种方式对于下游数据消费速度非常敏感,因为多个不同分支可能以不同的速度消费数据,任何一个有问题就可能造成上游 Fragment 数据的堆积。F1 Query 规避这个问题的方法是把数据在内存里面进行缓冲,让下游 Fragment 慢慢消费;如果所有的下游都 Block 住的话,那么它会把数据吐到文件系统上面去避免上游 Fragment 内存爆掉。
这貌似在描述我们要做的多路输出的技术方案啊。
性能考虑
F1 Query 里面性能问题的主要诱因是数据倾斜以及不理想的数据访问模式。比如 HashJoin 就对热点数据比较敏感,因为比较热门的 Key 的数据被读入到 HashTable 里面,数据太多的时候可能会被吐到磁盘上面去,导致性能的下降。
如果 HashJoin 的一个输入很小的话,那么F1 Query支持把这个输入完全读入内存,并且把这个输入发送到所有的 HashJoin Worker
的内存里面,Broadcast HashJoin
对于数据倾斜天生免疫,因为数据是可以随机发的,但是对于 Build Input
的大小比较敏感。
对于 LookupJoin
,比较初级的做法是来一条数据我们查询一下 BuildInput
,这样显而易见性能会很差,时间可能都花在查询 BuildInput 上面了。F1 Query 当然不会这么做,F1 Query会做批量、异步处理,它会 batch 一堆数据,一次性的发给 BuildInput 去一次性查询,因为是批量查询,中间如果有重复的key也可以自动去重,节省总体的执行时间。而查询 BuildInput 的时候它会继续消费上游过来的数据,而不会堵住,保证整个过程的流水线式的执行。
在 LookupJoin 中如果我们不做任何优化直接对 Join 的左边输入进行查询的话可能也会产生性能问题,因为同一个 Key 可能被分配到不同的 Worker 去做,从而使得单个 Worker 里面的去重效果大大降低,如果一个查询里面有多个这种 LookupJoin 累加在一起的话就可能导致数据倾斜。
对于这种问题 F1 Query 的优化器会对 LookupJoin 的左边输入的数据进行重新分布,比如进行 Hash 分布,这样相同的 Key 被分配到同一个worker,去重效果就能提高。但是因为进行了 Hash 分布,同一个 Worker 里面对 Lookup 数据源访问会呈现出类似随机访问的特性,使得 Lookup 数据源的查询完全没有本地性可言,效率会比较低。
一个解法是静态的给不同的 Worker 分配不同的 Key 的分区,这样因为 Key 是连续的,因此可以得到比较好的数据本地性,但是如果某些 Key 过热的话,又会出现数据倾斜。
F1 Query 发明了一种叫做 动态KeyRange
的数据分布算法,上游的数据发送者根据它看到的数据的分布动态地对数据的KeyRange进行分配,这个做法的依据是它本地看到的数据分布情况应该跟总体数据的分布情况类似,因此可以得到比较好的数据分布效果,避免数据倾斜。F1 Query没有透露关于这个算法更详细的信息。
在交互式的执行模式下, 查询基本都是在内存里面执行的,因为没有中间落盘的过程,纯内存的计算速度非常的快,这样才可以保证“交互性”。再加上数据源端比较激进的缓存策略,对于分布式的复杂查询,影响时间可以做到几十到几百毫秒;同时也因为中间数据不落盘,纯内存的计算对于局部的 Worker 的失败比较敏感,只能依赖客户端的自动重试。在实际过程中,执行时间在一个小时内的查询还是比较可靠的,超过一个小时的查询往往会不停的失败,这种情况下使用 Batch Execution
更好。
Ba****tch Execution
在Google内部,ETL的 Pipeline 基本都是用 FlumeJava
或者 MapReduce
写出来, 而不是SQL,MapReduce
(以及 FlumeJava
) 代码主要的问题主要在于开发和维护的成本太高,而且SQL优化器层面可以做很多优化的事情比如属性裁剪、条件下推等等,手写的 MapReduce
都是享受不到的。
这一点貌似我们阿里巴巴倒是更现代化,阿里巴巴内部绝大多数的这种ETL工作都是用SQL来写,通过UDF来支持特定的业务逻辑,实在复杂的才用 MapReduce 任务来做。
Batch Execution
模式下接受的查询语言还是SQL,但是它后台会把SQL任务翻译成 MapReduce 的任务来执行。我们知道跟流式执行不一样,MapReduce 的不同 Stage
不是同时执行的,后一个Stage 必须等前一个 Stage 完全成功之后才能开始,因此中间结果全部落盘(Colossus分布式文件系统),这使得 MapReduce 的不同的 Stage 可以异步交互,而不需要同时在线。同时这种机制又提供了一定的容错性,如果一个 Stage 出错了,我们不需要重跑整个任务,因为 Stage 的输入保存在文件系统上,我们重跑这个失败的 Stage 就好了。
Batch Execution
不止可以自动处理服务端的异常,它还能自动规避客户端的异常,客户端可以提交异步的查询,然后断开连接,而在服务器端查询会继续执行不会终止。而交互式查询都是同步执行的,客户端一旦断开整个查询也就失败了。
虽然 Interative Execution
跟 Batch Execution
执行方式迥异,但是他们的查询解析、查询优化等等前端模块是完全一样的,区别只在于最后的执行阶段,如下图所示·:
三种模式共享同一个前端
个人理解这样的好处很多,一是省时省力,一份代码可以供底层不同的核心引擎共享,上层有改进所有的引擎都受益;另外对用户来说也有好处,因为整个系统的前端完全一样,那么对用户来说体验也是完全一样的,不管你使用哪种模式。只是要能做到这一点应该也不是很容易的事情,要求设计者对于几种执行模式特点的深刻理解以及高超的抽象、设计能力。
可扩展性
F1 Query的可扩展性主要表现在两个方面
-
支持自定义的数据源
-
支持新的UDF, UDAF, TVF
UDF,UDAF 本身并不是什么新概念,很多系统都有。但是F1 Query还是有一些创新在上面。一是跟其它系统 UDF 必须用高级编程语言来编写相比,F1 Query可以用高级编程语言Lua来编写,也可以用 SQL 直接描述,简单的 UDF 用 SQL来描述无疑会更便捷。
另外 F1 Query 还支持一种叫做 UDF Server
的服务,UDF Server 是一个远程的 RPC 服务,它里面承载着 UDF,而这些 UDF 可以用任何语言比如 C++, Java, Go 来写。UDF Server 的概念第一次听说是在 Apache Beam 里面,Apache Beam 也是出自 Google 之手,可见 UDF Server 在 Google 内部已经是个很成熟的概念了。这个概念还是很创新的,以前总感觉UDF这种东西性能一定要高,不能有远程调用,否则性能会很差,没想到 Google 干脆把真个 UDF 的实现都放到远端了。而性能问题则通过之前解决 HashJoin 里面 解决 BuildInput 性能类似的手段,通过 批量化 + 异步化 + 流水线化
,使得远端 UDF Server 的延迟完全被掩盖掉了。跟UDF类似,UDAF也是采用类似的策略,只不过调用UDAF 远程服务的时候除了要传递当前要聚合的输入数据,还要传当前已经聚合的结果,远程的UDAF服务则会返回新的聚合结果。因为UDF Server都是无状态的,使得F1 Query可以很好地把整体的流量分布到整个 UDF Server 集群里面,提高整体的性能。
UDF Server 这样设计的好处我理解有两个;
-
把 UDF 的概念从具体的执行引擎里面拿出来了,使得各种不同的数据执行引擎可以共用同一个 UDF Server,而不需要重复开发。用户在编写 UDF 的时候也只需要编写一份,因为业务处理的逻辑都是一样的,没必要为了每种引擎单独适配。
-
因为引擎与 UDF Server 通过 RPC 进行交互,这就不限定 UDF 到底用什么编程语言进行编写了,给了 UDF 编写者更大的自由度。
再说说 Table Valued Function
, 这种函数比较有意思,它的输入是一张表(当然还可以有其它普通的参数),输出是另外一张表,这种给了用户更大的自由度,对于一些新兴的场景比如机器学习就特别适合: 机器学习在模型训练的时候就是把一张表作为输入,然后输出一张新的表。比如下面的例子:
SELECT * FROM EventsFromPastDays( 3, TABLE Clicks);
通过从 Clicks
表查数据,通过一个TVF( EventsFromPastDays
) 产生新的一张临时表( EventsFromPastDays( 3, TABLE Clicks)
),最后再用 SELECT
查询出来进行展现。
F1 Query 也支持通过SQL定义 TVF:
CREATE TABLE FUNCTION EventsFromPastDays( num_days INT64,
events ANY TABLE
) AS
SELECT * FROM
events
WHERE date >= DATE_SUB( CURRENT_DATE(), INTERVAL num_days DAY);
网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
[外链图片转存中…(img-tE8Ipp6A-1714252392140)]
[外链图片转存中…(img-MtZ8ywXE-1714252392140)]
网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!