本篇论文是Facebook 2019年发表介绍Presto的综述类论文,本篇论文从Presto的使用示例、架构、系统设计等几个方面系统的介绍了Presto的内核和实现原理,对于通识性的了解Presto有一定帮助。
注:本篇论文中所介绍的Presto版本是0.211版本,当时Presto还没分裂出PrestoDB和PrestoSQL。
一、Presto介绍
Presto作为一个分布式查询引擎,于2013年开始就已经在Facebook的生产环境中使用。并且如今已经在Uber、Netflix、Airbnb、Bloomberg以及LinkedIn这样的大公司中使用。
Presto具有自适应、灵活以及可扩展等特性。Presto提供了标准的ANSI SQL接口来查询存储于各系统中的数据,如Hadoop、RDBMS、NoSQL数据库中的数据,以及Kafka这样的流式组件中的数据(Presto中内置了非常多的connectors供用户使用)。Presto对外提供了开放式的HTTP API、提供对JDBC的支持并且支持商业标准的BI的查询工具(如Tableau)。其内置的Hive connector源生支持对HDFS或Amazon S3上的文件进行读写,并且支持多种流行的开源文件格式,包括ORC、Parquet以及Avro。
二、Presto在Facebook的使用示例
1.Interactive Analytics(交互式分析)
Facebook内运行着一个庞大的多租户数据仓库,一些业务部门或个别团队会共享其中一小部分托管的集群。其数据存储在一个分布式文件系统之上,而元数据则存储在单独的服务中,这些系统分别具有HDFS和Hive Metastore服务类似的API。
Facebook的工程师经常会检索少量的数据(50GB-3TB的压缩数据),用来验证假设,并构建可视化的数据展板。这些用户通常会使用查询工具、BI工具或Jupyter notebooks来进行查询操作。各个群集需要支持50-100的并发查询能力,并且对查询响应时间非常敏感。而对于某些探索性的查询,用户可能并不需要获取所有的查询结果。通常在返回初始结果后,查询就会被立即取消或者用户会通过LIMIT来限制系统返回的结果。
2.Batch ETL (批量ETL)
上面我们介绍到的数据仓库会使用ETL查询任务定期填充新的数据。查询任务通常是通过一个工作流系统依次调度执行的。Presto支持用户从历史遗留的批处理系统迁移ETL任务,目前ETL查询任务在Facebook的Presto工作负载中占了很大一部分。这些查询通常是由数据工程师开发并优化的。相对于Interactive Analytics中涉及的查询,它们通常会占用更多的硬件资源,并且会涉及大量的CPU转换和内存(通常是数TB的分布式内存)密集型的计算,例如大表之间的join及聚合。因此相对于资源利用率以及集群吞吐量来说,查询延迟不是首要关注的。
3.A/B Testing (A/B测试)
Facebook使用A/B测试,通过统计假设性的测试来评估产品变更带来的影响。在Facebook大量的A/B测试的基础架构是基于Presto构建的。用户期望测试结果可以在数小时之内呈现(而不是数天),并且结果应该是准确无误的。对于用户来说,能够在交互式延迟的时间内(5~30s),对结果数据进行任意切分来获得更深入的见解同样重要。而通过预处理来聚合这些数据往往很难满足这一需求,因此必须得实时计算。生成这样的结果需要关联多个大型数据集,包括用户、设备、测试以及事件属性等数据。由于查询是通过编程方式实现的,所以查询需要被限制在较小的集合内。
4.Developer/Advertiser Analytics(开发者/广告主分析)
为外部开发者和广告客户提供的几种自定义报表工具也都是基于Presto构建的。Facebook Analytics就是其中一个实际案例,它为使用Facebook平台构建应用程序的开发人员提供了高级的分析工具。这些工具通常对外开放一个Web界面,该界面可以生成一组受限的查询模型。查询需要聚合的数据量是非常大的,但是这些查询是有目的性的,因为用户只能访问他们的应用程序或广告的数据。大部分的查询包括连接、聚合以及窗口函数。由于这些工具是交互式的,因此有非常严格的查询延迟限制(约50ms~5s)。鉴于用户的数量,集群需要达到99.999%的高可用,并且支持数百个并发查询。
三、Presto架构概览
一个Presto集群需要由一个Coordinator以及一个或多个Worker节点组成。Coordinator主要负责接收查询请求、解析语句、生成计划、优化查询以及查询调度。Worker节点主要负责查询处理。如下所示的即为Presto架构:
整体的执行流程可以简述如下:
客户端向Coordinator发送一个包含sql的http请求。Coordinator接收到这个请求,会通过评估队列策略,解析和分析sql文本,创建和优化分布式执行计划来处理请求。
Coordinator将执行计划分发给Worker节点,接着Worker节点开始启动tasks并且开始枚举splits,而这些splits是对外部存储系统中可寻址的数据块的一种隐晦处理。Splits会被分配给那些负责读取数据的tasks。
Worker节点运行这些tasks来处理从外部存储系统获取的splits,以及来自于其他Worker节点处理过得中间数据。Worker节点之间通过多任务合作机制来并发处理来自不同查询的tasks。任务尽可能的以流水线的方式来执行,这使得数据可以在tasks之间进行流动。对于某些特定的查询,Presto能够在处理完所有数据之前就返回结果。中间数据以及状态会尽可能地存储在内存中。当在节点之间对数据进行shuffle时,Presto会调整缓冲区来达到最小化的延迟。
Presto被设计成可扩展的,并提供通用的插件接口。插件可以提供自定义的数据类型、函数、访问控制、事件监听策略、排队策略以及属性配置。更重要的是插件还提供了connector,这使得Presto可以通过Connector API和外部的存储系统进行通信。Connector API主要包含如下四个部分:Metadata API、Data Location API、Data Source API以及Data Sink API。这些API可以帮助在分布式查询引擎中实现高性能的connectors,开发者已经为Presto社区提供了数十个connector,而且我们也注意到了一些专有的connectors。
四、Presto系统设计
1.SQL Dialect(SQL支持)
Presto采用了标准的ANSI SQL规范 ,具备一定的扩展能力,例如支持maps and arrays, 支持anonymous functions (lambda expressions) ,支持高阶函数 higher-order functions。
2.Client Interfaces, Parsing, and Planning(接口、语法解析和逻辑计划)
Presto的Coordinator基于RESTful HTTP提供命令行接口并支持JDBC。基于ANTLR的解析器把sql转为相应的语法树。logical planner把AST抽象语法树变为逻辑执行计划,叶子节点是input。例如下面的SQL转化为如图的逻辑执行计划。
SELECT
orders.orderkey, SUM(tax)
FROM orders
LEFT JOIN lineitem
ON orders.orderkey = lineitem.orderkey
WHERE discount = 0
GROUP BY orders.orderkey
如上生成查询的逻辑计划如下所示:
3.Query Optimization(查询优化)
计划优化器会将逻辑计划转换成能够表示有效执行策略的物理结构。转换过程是通过RBO(Rule-Based Optimization:基于规则的优化器)做plan nodes的等价变换,较常见的规则包括predicate and limit pushdown(查询下推)、column pruning(列剪枝)以及decorrelation(去相关性)。基于CBO(Cost-Based Optimization:基于代价的优化器)也在增强,原理是利用Cascades framework在搜索空间里面找到最优的计划,目前已经实现的两类CBO是join方法的选择(hash index,index join等)以及join reorder。
以下是对一些比较重要的优化手段的列举:
3.1 Data Layouts(数据属性)
Connector提供了Data Layout API,优化器可以获取待扫描数据的位置信息以及分区、排序、分组和索引等信息。例如做任务分发的locality aware,partition pruning,sort pushdown,index join等。
3.2 Predicate Pushdown(查询下推)
范围和等值查询的下推,可以更好的filter inputs。举例来说,在MySQL分片之上构建了专有的Connector。Connector将存储在Mysql实例上的数据划分成小的数据分片,并且根据范围和点查,下推到指定的分片。
另外对于highly selective filters的查询非常适合查询下推,可以利用分区裁剪(partition pruning)和 file-format feature(比如min-max等粗糙集索引)减少IO。
3.3 Inter-node Parallelism(节点间并行计算)
Plan node中包含了输出数据的各类信息(如分区、排序、分片及分组等数据特性),而后Plan node会在worker间并行执行Plan nodes组成的stages,stage物理上体现为多个tasks的并行执行,task就是相同的算子,只是在不同的input data上。stage间通过插入exchange算子,利用buffer来交换数据,shuffle是计算和IO密集型的,因此怎么在stages间做shuffle非常重要。下图就是一个执行计划,靠shuffle串联起来。
3.4 Intra-node Parallelism(节点内并行执行)
单节点进程内同样可以并行,hash table和字典可以在线程里面shards分区并行,论文里提了两个use case,用节点内多线程并行可以加速计算,下图是一个例子,扫描数据和hash build都可以并行起来,靠local shuffle交换数据。
4.Scheduling(调度)
Coordinator通过分发可执行的task的方式,向Woker节点分配执行计划的各个stage,这些可执行的task可以被看作单个处理单元。接着,Coordinator将一个stage的task与其他stage的task通过shuffles相连接,从而形成一个树形的处理链路。只要各个stage都是可用的,数据就会在stage之间流转。
这里需要先理解Presto里面的stage、task、pipeline、driver的概念(可以参阅Presto官方文档)。Coordinator把plan stages分发给worker,stage只是抽象的概念,worker内实际运行的被称作task,这是最小的执行单元。task有输入和输出,靠shuffle把上下游的task连接起来。比如在实际调试页面里面有0.x,1.x,0表示stage 0,x表示stage内task的并行度。
一个task可以包含1到多个pipeline,pipeline包含一系列operators,例如hash-join算子包括至少两个pipeline,build table pipeline,另外一个是流式的probe pipeline。优化器如果发现一个pipeline可以支持并行优化,那么就会把一个pipeline拆开,例如build pipeline可以拆成scan data和build partitions两个pipeline,节点内并行度可以不同。pipeline直接用local shuffle串联起来。driver在Query Execution中来介绍。
调度策略用于决定哪些stage该被执行,stage内多少个tasks并行调度起来。
4.1 Stage scheduling
在这一调度阶段,Presto支持两种调度策略:一次性调度(all-at-once)以及阶段调度(phased)。前者可以实现将所有的stage调度起来,适用于延迟敏感的场景,例如上文中的A/B测试和广告主服务。后者的典型操作如hash-join,需要先build好,再启动probe端,比较节约内存,适用于批处理场景。
4.2 Task Scheduling
Table scan stage的调度会考虑网络拓扑和数据locality,比如share-nothing的部署模式下,需要有一部分worker和storage node co-located起来,Intermediate Stages可以执行在任何worker节点类型上。profling的结果表明table scan很多花在解压、decoding、filters、applying transformation,读数据到connector上,通过上面提到的节点内并行可以最小化wall time。在facebook,presto用share-storage模式部署,这时候网络往往最先成为瓶颈。为了弥补计算存储分离架构下的访问延迟高问题,发展出了Raptor connector。
4.3 Split Scheduling
在table scan stage读取数据之前,需要先获取存储的split,比如file的路径和offset,分好split后才可以正式启动执行;Intermediate Stages则不同,随时可以执行。
5.Query Execution(查询处理)
5.1 Local Data Flow(本地数据流)
一旦data split分配给线程后,它会在driver中循环执行。Presto的driver中的循环比流行的Volcano递归迭代器模型更复杂,但却提供了重要的功能。它更适用于多任务的协作处理,因为算子(operator)可以在线程生成之前就迅速进入已知的状态,而不是无限地阻塞下去。此外,driver可以在不需要额外的输入文件的情况下,通过移动operator之间的数据使每个数据量子的执行效率达到最大化(如恢复资源密集型的计算或爆炸性转换的计算)。循环的每次迭代都会在operators之间移动数据驱使查询不断的执行。
drive循环中操作的数据单元称之为page,而一个page为某一列一串行值编码后的数据。Connector的Data Source API在传入一个data split之后会返回若干个page。drive中的循环会在operators之间移动page数据,直到调度完成或operator无法继续执行为止。
5.2 Shuffles
为了尽可能降低查询延迟,最大化资源利用率,shuffle采用了In-memory buffer shuffle,同时基于HTTP协议,上游worker通过long-polling向下游worker请求数据,实现了一种ACK(消息确认)的机制,保证数据不丢不重,下游可以按照上游吞吐产出数据和清理数据。
对于shuffle的并发度Presto设计了一种监控机制,例如一旦output buffer过满,执行stall并且消耗内存,input buffer又较为空闲,便会导致处理效率又不足。因此通过监控buffer的使用率,可以实现自行调控shuffle并行度,进而避免上述的极端现象,网络资源便可以在多个请求间进行平衡。
5.3 Writes
ETL是insert into select的工作模式,通常会写入其他表,connector data sink writer的并行度控制是自适应adaptive的,以此来实现最大化的写入吞吐。
6.Resource Management(资源管理)
Presto非常适合多租户使用,而关键的因素就在于它内置了一个细粒度资源管理系统。这使得一个集群可以同时执行数百个查询,并最大程度地利用CPU,IO和内存资源。
6.1 CPU Scheduling(CPU调度)
上文中曾经介绍过Presto要支持的特点是adaptive,最大化集群资源利用率,多个queries间要做公平调度(fair sharing),这样可以让短查询也有机会执行。
一个worker里面有多个task需要执行,task执行分片粒度叫做quanta(maximum quanta of one second),一个task执行完quanta周期后,会放回queue里面等待再次被调度。如果output buffer已满,或者input buffer空闲,那么会提前退出,不等一个周期的quanta。调度方案采用多级反馈队列(multi-level feedback queue,5级),执行总时间越长的task会放到更高级别的队列中。Presto使用一个内部的yield指令来做task间的切换(个人理解,即使看起来context switch,但是非os级别的,有点协程的轻量级特性,使得集群可以multi-tenant的服务,短查询也有时间高优完成)。cpu运行时间越少的task会被优先调度,这样可以确保短查询能更快被执行完,而长查询本身对于延迟也不是非常敏感。
6.2 Memory Management(内存管理)
Presto的Memory pool(内存池)分两类,user or system memory(用户内存), 以及reserve memory(预留内存)。
reserve memory一般存放shuffle buffers这些和用户查询无关的数据。这二者的内存都有控制,超过限制的阈值查询就会被kill掉。一般根据查询的pattern来规划集群规模和内存。对于超过内存限制的查询,Presto有两种应对方法,spilling:对于大join和agg计算,spill to disk。不过在Facebook生产环境不会使用spill to disk,因为太过影响性能,集群会被prevision成足够的规模,在内存计算和shuffle。
reserved pools:如果节点内存不足,并且集群没有配置为Spilling,或没有剩余的可撤销内存,reserved pool机制可以保证集群不被阻塞。每个节点上的查询内存池会进一步被细分为两个池:general pool和reserved pool。当Worker节点上的general pool耗尽时,那么在worker节点上的占用内存最多的那查询会在整个集群中被提升到reserved pool中。在这种情况下,该查询所消耗的是reserved pool中的内存,而不在是general pool中的内存。为了避免死锁,在整个集群中同时只有一个查询可以在reserved pool中执行。如果Worker节点上的general pool内存已用完,而reserved pool也已经被占用,那么该Worker节点上其他task的所有内存相关的请求将被阻塞。运行在reserved pool中的查询会一直占用该pool直到其执行完成,这个时候,群集将停止阻塞之前所有未完成的内存请求。这在某种程度上看起来有点浪费,因此必须合理的调整每个Worker节点上reserved pool的大小,以满足在本地内存限制下运行查询。当某个查询阻塞了大部分的Worker节点时,集群也可以配置成kill掉这个查询。
7.Fault Tolerance(容错)
Presto可以通过low-level(低级别)的retry(重试)从临时的错误中恢复。但是,一旦Coordinator或Worker节点发生了崩溃,没有任何内置的容灾措施可以挽救。Coordinator的故障将导致集群的不可用,而Worker节点的崩溃将导致该节点上所有正在执行的查询失败。目前,对于这样的错误,Presto需要依靠Client端重新提交失败的查询来解决。
目前,对于上问题的容错处理在Facebook的生产环境中是通过额外的机制保证某些特定场景下的高可用。在Interactive Analytics 和Batch ETL 的案例中运行着一个备用的Coordinator,而在A/B Testing以及Developer/Advertiser Analytics 的案列中,运行着多个集群来保证高可用。外部监控系统会识别那些产生异常故障数量的节点并将它们从集群中剔除,而被修复的节点会重新加入集群。以上的措施都是在某种程度上降低服务不可用的时间,但是无法完全屏蔽故障的发生。
常用的check pointing 以及部分恢复机制通常是非常消耗计算资源的,并且很难在即席查询系统中实现。基于复制策略的容错机制通常也是相当耗资源的。考虑到这样的成本开销,这种技术的预期价值还尚不明确,尤其是在考虑到节点平均故障时间时,如在Batch ETL的案列中,集群的节点数达到了数千个并且大部分的查询都是在数小时之内完成的。在其他的研究中也得出过类似的结论。
五、查询优化
1.Working with the JVM(使用JVM)
Presto是通过java实现的,并且运行在Hotspot Java虚拟机上,一些性能敏感的计算例如压缩,checksum可以用特殊的指令和优化。JIT(JVM即时编译器)可以将字节码runtime的优化为machine code,例如inlining, loop unrolling, and intrinsics(一些native代码的调用),同时也在探索用GraalVM来优化。
Java的实现需要非常重视GC(垃圾收集),Presto采用了G1 GC,同时为了减轻GC的负担,避免创建humongous大对象,flat memory arrays to reduce reference and object counts and make the job of the GC easier。同时由于G1需要维护对象集合的结构(remembered sets tructures),所以大型和高度关联的对象图可能会存在一些问题。查询执行路径上的关键步骤的数据结构是通过扁平化的内存数组来实现的,目的就是为了减少引用以及对象数量,从而使GC变得相对轻松一点。例如,在HISTOGRAM聚合中,会在一个扁平的数组或哈希表中存储所有组中的bucket keys(桶键)以及对象计数,而不是为每一个histogram维护独立的对象。
2.Code Generation(代码生成)
引擎的主要性能特征之一,就是生产JVM字节码。这有如下两种表现形式:
Expression Evaluation(表达式求值):Presto所提供的表达式解释器可以将表达式计算编译成java代码,从而加速表达式的求值。
Targeting JIT Optimizer Heuristics(针对JIT的优化器):Presto会为一些关键算子(operators)和算子组合生成字节码。字节码生成器利用引擎在计算语义方面的优势来生成更易于JIT优化器进行优化的字节码。避免quanta做task时间片切换对JIT的影响,避免类型推导以及虚函数调用,JIT也会进一步适配数据的变化。
生成的字节码还得益于内联所带来的二次效应。JVM能够扩大优化范围,自动向量化大部分的计算,并且可以利用基于频率的基本块布局来最大程度地减少分支。这样使得CPU分支预测也变得更加有效。字节码生成提高了引擎将中间结果存储在CPU寄存器或CPU缓存中而不是内存中的能力。
3.File Format Features(文件格式的特性)
扫描算子(Scan operator)使用叶子阶段split的信息来调用Connector API,并以Pages的形式接收列数据。一个page由一个block列表组成,而每个block是具有扁平内存表示形式的列。使用扁平内存的数据结构对性能非常重要,尤其是对那些复杂的数据类型。在紧凑的循环体内,指针跟踪,拆箱和虚拟方法调用都增加了大量的开销。
而这类connectors会尽可能的使用特定的文件格式。Presto配有自定义的reader,可以通过使用文件页眉/页脚中的统计信息(如页眉中保存的最小-最大范围和布隆过滤器),来高效地过滤数据。Reader可以直接将某些形式的压缩数据直接读成blocks,从而让引擎可以有效地对其进行处理。
下图显示了一个page中列的布局方式,其中每一列都有其对应的编码方式。字典编码的块(DictionaryBlock)在压缩数据的低基数部分非常高效,游程编码的块(run-length encoded block,RLEBlock)对重复的数据进行压缩。多个page可以共享一个字典,这可以大大提高内存利用率。ORC文件中的一列可以为整个“stripe”(最多数百万行),使用单个字典。
4.Lazy Data Loading(数据的懒加载)
Presto支持数据的惰性物化(lazy materialization)。此功能可以利用ORC,Parquet和RCFile等文件格式的列压缩特性。Connector可以生成惰性的blocks,仅当实际访问时才读取,解压缩和解码数据。
5.Operating on Compressed Data(对压缩数据的处理)
Presto所采取的第一种处理方法是在压缩数据上直接进行计算。如上图中的结构,当page处理器在计算转换或过滤时遇到dictionary block,它将处理 dictionary 内的所有的值(或RLE的块中的单个值)。这允许引擎以快速的无条件循环处理整个dictionary。在某些情况下,dictionary中存在的值多于block中的行。在这种场景下,page处理器推测未引用的值将在后续的block中使用。Page处理器会持续跟踪产生的实际行数和dictionary的大小,这有利于对比处理索引和处理dictionary的效率。如果行数大于dictionary的大小,则处理dictionary的效率可能更高。当page处理器在block序列中遇到了新的dictionary时,它会使用这种启发式的方法确定是否继续推测。
Presto所采取的第二种处理方法是在用字典值代替数据本身进行计算。在构建哈希表(如联接或聚合)时,Presto还会利用dictionary block结构。在处理索引时,operator会在数组中记录每个dictionary entry(字典条目)在哈希表中的位置。如果有条目在后续的索引中重复,会简单的重用该条目的的位置而不是重新对其进行计算。当连续的blocks共享同一dictionary 时,page处理器将保留该数组,来进一步减少必要的计算。
六、工程相关
在这一部分中,提供了Presto在设计上所参考的一些工程哲学,这些对于Presto的设计和发展有着重要意义,也非常具有借鉴意义。
- Adaptiveness over configurability(基于配置的自适应性)
自适应高于配置。例如cpu执行的quanta分片机制使得短查询可以快速执行,ETL 写入的并行度,反压等特性。
- Effortless instrumentation(唾手可得的工具)
细粒度的性能统计信息。通过Presto的库(libraries)收集统计信息,并且针对每一个查询收集了算子(operator)级别的统计信息,并将这些信息合并到task以及stage级别的统计信息中。通过这些细粒度的监控手段,使得Presto在优化时能以数据为驱动。
- Static configuration(静态配置)
如Presto这样的复杂系统,诸多操作问题很难快速地定位根源并予以解决。因此Presto选择不动态调整配置,避免集群不稳定。
- Vertical integration(垂直集成)
Presto的各组件都应该可以很好的与Presto交互,比如发现gzip包慢,那么Presto就自行改造实现。在多线程下的debug和控制能力也非常重要。
最后,我们简短的进行一个总结。在本篇论文中,我们介绍了Presto,这是一个由Facebook开发的开源的MPP SQL查询引擎,可以快速的处理大型的数据集,具有自适应、灵活以及可扩展等特性,值得我们对其的一些实现原理进行深入的研究。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。