网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
可伸缩性: 客户的需求各种各样,从只影响一两条数据的 OLTP 类需求,到大规模、超大规模的数据处理,不应该随着数据量、请求延迟性的变化而要用完全不同的数据处理引擎来处理,这里面有很大的迁移成本。
可扩展性:用户的需求千奇百怪,用户可能需要支持新的存储格式、存储系统、嵌入新的业务的逻辑等等,一个理想的系统要支持这些扩展性。
其实这些需求都不是 Google 特有的,任何一个大公司甚至任何一个公司在这个大数据的时代都需要这些数据处理的能力,那么我们一起来看看 F1 Query是怎么做到如上的这些特点的。
整体架构
整体架构
整个架构从纵向来看分为三层: F1 Client, F1集群以及各种异构的数据源。而F1集群内部主要的角色主要是5个。
首先是 F1 Master
, 它负责对所有的查询进行监控并且管理所有 F1 Worker
。然后是 F1 Server
,F1 Server
在角色上有点像我们 Data Lake Analytics 的 FrontNode 的角色, 在请求真正执行之前做一些执行计划编译、优化的工作,是整个系统的“前端”,而真正的数据处理是由 F1 Worker
来完成的。
Catalog Service
扮演的元数据中心的角色,各种异构数据的元信息都保存在这个服务里面,也就形成了一个全局的统一视图 – 不管你数据是保存在什么介质里面。(我们 Data Lake Analytics 和 AWS的 Athena Glue都有类似的服务)。
Batch Metadata
保存的是 Batch Execution
模式下任务的一些元信息,比如执行计划之类的。
UDF Server
是 Google 比较创新的一个概念,它是一个 UDF 的仓库,而且是在执行引擎之外的,执行引擎通过 RPC 与 UDF Server 进行交互。
由于整体架构上存储和计算的分离,F1 Server
和 F1 Worker
都是无状态的,当需要水平扩展的时候,只需要向集群里面加入新的机器就好了,数据层面不需要做任何重新分布的工作。
查询的执行
因为 F1 Query 强调的是跨机房部署,因此查询的请求跟实际的数据很可能不在一个集群里面,当请求到达一台 F1 Server的时候,它首先对查询进行解析,看看查询里面涉及哪些数据源,如果有任何数据源不在这个数据中心里面, 它会看看哪些 F1 Server
离这些数据更近,然后返回一个 F1 Server
的列表给客户端,客户端接到之后,把这个请求重新发给这些新的 F1 Server
进行查询。F1 Query 强调虽然把计算和存储分离了,并且借助高效的网络设置,已经解决了很多数据本地化的问题,但是数据还是离计算越近,性能越好。
一个查询过来之后,首先在接到请求的 F1 Server
上进行编译和优化,然后把这个优化好的执行计划推到执行层,而执行的时候根据客户端指定的模式偏好来选择到底用何种模式来执行。
数据源
F1 Server
和 F1 Worker
不止可以访问本数据中心的数据,还可以跨数据中心访问数据。F1 Query
同时也像 Presto
一样,可以支持对各种异构数据源的查询。而且跟 Presto 一样,F1 Query 把所有的数据源都抽象成一个关系型的表(因为最终使用的查询语言是SQL嘛),因此隐藏掉了数据源本身的实现细节。不同的数据源之间可以进行关联的JOIN查询,同时借助前面提到的 Catalog Service
来统一管理这些异构数据源的元数据。整个就是一个企业级的大数据库啊,可以看到整个企业里面的所有数据。
除了查询 Catalog Service
管理的数据, F1 Query 还能查询不在这个元数据中心里面的数据,通过一个叫做 DEFINE TABLE
(而不是 CREATE TABLE
)的语句来对这个要查询的数据源进行描述,描述之后就可以进行查询了:
DEFINE TABLE People(
format = ‘csv’,
path = ‘/path/to/peoplefile’,
columns = ‘name:STRING, DateOfBirth:DATE’
);
SELECT Name, DateOfBirth FROM People WHERE Name = ‘John Doe’;
其实本质上就是创建一个临时表,只在当前的 session 有效,为什么不用
CREATE TEMP TABLE
这种更容易理解的语法呢?这是我始终不大明白的地方。
我们 Data Lake Analytics 也有类似的直接查询裸数据的语法,可以说英雄所见略同啊:
SELECT count(*) FROM
TABLE temp_1
(
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没有透露关于这个算法更详细的信息。
网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
信息。
[外链图片转存中…(img-CMabwSX3-1715752488735)]
[外链图片转存中…(img-nFTfAQxB-1715752488735)]
网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!