目录
4.1 重复层级(Repetition Levels)和定义层级(Definition Levels)
=====================================================================================
本技术论文的翻译工作由不动明王1984独自完成,特此声明。
翻译辛苦,珍惜劳动,引用时请注明出处!
=====================================================================================
概要
Dremel是一个在只读的嵌套数据上的可伸缩的、交互式的点对点的查询系统。通过结合多层执行树和列式数据布局,其有能力在数秒之内在万亿行的表上成功执行聚合查询。系统可以扩展到数千个CPU以及PB级的数据,且在Google有成千上万的用户。在本论文中,我们描述了Dremel的架构和实现,并解释它是如何补充基于MapReduce的计算的,我们展示了一个新颖的列式存储表达,其支持嵌套格式的记录,并讨论了在数千节点的系统上做的一些实验。
1. 介绍
大规模的分析型数据处理在互联网公司及工业领域已经变得非常广泛了,不仅是因为低成本的存储允许了收集并保存大量的业务上关键的数据。将这种数据放在数据分析师和工程师手上变得越来越重要;交互查询的响应时间快慢通常会在如下场景中造成一个质的不同,包括数据探索、监控、在线用户支持、快速的原型制作、数据流水线的调试、以及很多其他的任务场景。
执行大规模的交互式数据分析要求高度的并行性。例如,通过现在的商用磁盘每秒读取1TB的压缩数据,得需要从数万块磁盘上一起读取。相似的,对于CPU密集型的查询可能需要在数千个内核上一起计算才能在数秒内完成任务。在Google大规模并行计算是通过使用共享的商用机器集群来完成的。一个集群通常运行了大量的分布式应用,它们共享该集群中的资源,具有非常广泛和不同的工作负载类型,并在具有不同硬件参数的机器上运行。在一个分布式应用中的一个单独的worker节点可能会花费比其他worker节点长的多的时间来执行一个任务,或者由于节点失败或被集群管理系统抢占而根本不会完成该任务。因此,处理挣扎者(stragglers)和失败(failures)对于完成快速执行和错误容忍是至关重要的。
在网络和科学计算中使用的数据经常是非关系型的。因此,在这些领域中使用一个复杂的数据模型是至关重要的。在编程语言中、分布式系统消息交换中、结构化文档中等等使用的数据结构自然的将自己交给一种嵌套式表达格式。在网络规模上标准化及重新组装这样的数据的代价经常是非常高昂的。一个嵌套的数据模型可以支持Google及据报道的其他主要互联网公司的绝大部分结构化数据处理。
本论文描述了一个叫做Dremel的系统,其支持在非常巨大的数据集上执行交互式查询,在共享的商用机器集群上。不像传统的数据库,其有能力在施工现场(situ)的嵌套数据上直接进行操作。施工现场(site)这个词的意思是其有能力就地(in place)访问数据,例如,在一个分布式文件系统(像GFS)或其他存储层(例如BigTable)。Dremel可以在一些数据上执行很多查询,这些数据通常情况下是要求使用一系列的MapReduce任务来进行处理的,但是其仅仅使用MR执行时间的几分之一。Dremel并不是想要取代MR,其经常用于与MR协同工作,来分析MR流水线的输出结果,或者用来快速构建更大规模计算的原型。
Dremel在2006年被用于生产环境中,并在Google有成千上万的用户。多个Dremel的实例被部署在公司中,集群的范围从数十个节点到数千个节点。使用Dremel的应用例子包括:
- 对爬取的网页文档的分析
- 追踪在安卓市场上的应用注册数据
- Google产品的崩溃报告
- 从Google图书应用来的OCR结果
- 垃圾邮件分析
- Google地图中图砖(map tiles)的调试
- 在管理Bigtable实例时的子表迁移信息
- Google的分布式构建系统中的测试结果
- 数十万磁盘上的磁盘I/O统计
- Google数据中心运行的任务的资源监控信息
- Google的代码库中的符合和依赖
Dremel构建于从网络查询和并行数据库系统而来的观念。首先,其架构借鉴了从分布式查询引擎而来的服务树(serving tree)的概念。就像一个网络查询请求一样,一个查询在树上被下推,并在每一步都被重写。查询的结果会通过将树的低层返回的结果聚合来组装形成。其次,Dremel提供了一个高层次的、像SQL一样的语言来表达点对点查询。与像Pig和Hive这样的层次不同,Dremel在本地执行查询,而不是将其转换成MR任务。
最后,很重要的是,Dremel使用一个列式条纹的存储表达格式,该列式格式允许Dremel从辅助存储中读取更少的数据,并基于开销更小的压缩降低CPU开销。列式存储已经被分析型关系数据所采纳,但是就我们所知尚未被扩展到嵌套数据模型上。我们在本论文中所展示的列式存储格式被很多Google的数据处理工具所支持,包括MR、Sawzall、以及FlumeJava。
在本文中我们做了如下的贡献:
- 我们描述了一个新颖的支持嵌套数据的列式存储格式。我们提出了可以将嵌套数据记录解剖为列并组装它们的算法(第4章)。
- 我们勾勒出Dremel的查询语言和执行过程的轮廓。两者都是被设计用来直接在列式的嵌套数据上进行高效操作的,并不需要重新构建出嵌套记录才可以执行操作(第5章)。
- 我们展示了在网络搜索系统中的执行树是如何能够被应用到数据库处理中的,并解释了它们在高效的回答聚合查询时带来的好处(第6章)。
- 我们展示了在万亿条记录、数TB的数据集上的试验,在部署在1000-4000个节点的集群上的实例中运行。
本论文的结构如下。在第2章,我们解释了Dremel是如何与其他数据管理工具配合用于数据分析的。其数据模型在第3章呈现。上面逻辑的主要贡献在第4-8章详细介绍。相关的工作在第9章讨论。第10章是结论。
2. 背景
我们通过穿过一个展示交互式查询在数据管理生态中非常适用的场景来开始。想象一下Alice,Google的一个工程师,突然冒出一个新颖的点子,可以从网页中获取一些新类型的信号。她执行一个MR的任务,处理输入数据并产生一个包含了新信号的数据集,并将该结果集在分布式文件系统上存储为数十亿的记录。为了分析她的实现结果,她启动Dremel并执行一些交互式命令:
DEFINE TABLE t AS /path/to/data/*
SELECT TOP(signal1, 100), COUNT(*) FROM t
她的命令在几秒钟之内就执行完成了。她运行了一些其他的查询来确认她的算法是有效的。她找出了在信号1中的一些不规范并通过写一个FlumeJava程序来在她的输出数据集上执行一个更加复杂的分析计算。一旦问题被修复了,她会启动一个流水线来立即处理将要到来的输入数据。她制定了一些打包的SQL查询来将流水线的执行结果在各个尺度上进行聚合,并将其添加到一个交互式的仪表盘中。最终,她将她的新数据集注册到一个catalog中,如此一来其他的工程师可以快速的定位并查询它。
上述场景要求查询执行器与其他数据管理工具之间的交互操作。这所需要的第一个要素是一个通用的存储层。Google文件系统(GFS)就是这么一个在企业中广泛使用的分布式存储层。GFS使用复制来保护数据免受硬件故障的影响,并在存在性能落后节点时保证快速的响应时间。一个高性能的存储层对于施工现场类型的数据管理来说是至关重要的。其允许不用一个非常消耗时间的加载阶段来访问数据,这在分析型数据执行过程中是一个主要的阻力,在这种执行过程中往往会在数据库系统能够加载数据并执行一个查询之前可能会运行一打的MR分析任务(也就是数据库系统或者数据仓库往往需要首先花费很长时间来将数据加载进自己的系统中)。另外作为一个额外的好处,一个文件系统中的数据可以方便的使用标准工具来操作,例如,转移到另一个集群上,修改访问权限,或基于文件名来确定用于分析的一个数据的子集。
构建数据管理组件之间的交互操作的第二个要素是一个共享的存储格式。列式存储证明了其在平坦的关系型数据上非常成功,但是为了能在Google中工作需要使其也适用于一种嵌套的数据模型。图1展示了主要的思路:一个嵌套字段的所有值,例如A.B.C,被连续的存储在一起。如此一来,A.B.C可以直接被获取而无需读取A.E、A.B.D等等。我们解决的挑战是如何保留所有的结构上的信息并能够从所有字段的一个随意的子集中重新构建出数据记录。接下来我们讨论一下我们的数据模型,随后转到算法和查询执行过程上。
3. 数据模型
在本章中,我们展现了Dremel的数据模型并介绍了一些后面会用到的专用术语。起源于分布式系统环境中的数据模型(这解释了它的名字,“Protocol Buffers”),在Google中被广泛应用,并作为一个开源实现而可以拿来直接使用。该数据模型基于强类型定义的嵌套记录。其抽象的语法通过下面的公式给出:
τ = dom | ⟨A1 : τ[∗|?],...,An : τ[∗|?]⟩
其中τ是一个原子的类型或一个记录(record)类型。dom中的原子类型包括了整数,浮点数,字符串,等等。Record类型由一个或多个字段组成。一个记录(record)中的字段i有一个字段名Ai以及一个可选的各种各样的标签。可重复的字段(*)在一个record中可能出现多次。它们被解释为值的列表(lists),也就是说,字段在一个record中出现的顺序是有意义的。可选字段(?)在record中可能不会出现。否则,一个字段就是必备(required)的,也就是说,必须出现且只出现一次。
为了展示,考虑一下图2。其描绘了一个schema,其中定义了一个record类型的文档,用于表示一个网页文档。schema定义使用的具体语法可以参考文献21。每个文档有一个必备的DocId和可选的Links字段,Links字段包含了Forward和Backward这两个entry类型的列表,用于维护其他网页的DocIds。一个文档可以有多个Names,其代表了该文档可以被引用到的不同的URLs。一个Name包含了一组由必备的Code值和(可选的)Country值组成的数值对。图2同样展示了两个records的样例,r1和r2,遵循了上述的schema。record的结构通过缩进勾勒出来了。我们会使用这些样例的records来解释后续章节中的算法。在schema中定义的字段构成了一个树形的层次体系。一个嵌套字段的全路径使用通常的点标记法来表示,例如,Name.Language.Code。
该嵌套数据模型在Google内部支持一种对结构化数据的序列化的平台中立的、可扩展的机制。代码生成工具会为像C++或Java这样的编程语言生成绑定工具。跨语言的互操作性是通过使用一个标准的对records的二进制连线上的表达来实现的,其中字段的值按照它们在record中出现的次序被顺序的布局。这样一来,一个用Java写的MR程序可以消费一个通过C++库暴露的数据源。如果records被保存为一个列式表达,快速将其组装对于与MR及其他数据处理工具之间的互操作就很重要。
4. 嵌套列式存储
正如在图1中所展示的,我们的目的是将一个指定字段的所有值顺序的存储起来以改善读取效率。在本章中,我们将处理如下的挑战:无丢失的将一个record结构表达为列式格式(第4.1节),快速的编码(第4.2节),以及高效的重组为record(第4.3节)。
4.1 重复层级(Repetition Levels)和定义层级(Definition Levels)
值本身是无法表达一个record的结构的。假设有一个可重复字段的两个值,我们并不知道该值是在哪个“层级”重复的(例如,这些值是否来自于不同的records,或者两个值来自于同一个record)。同样的,对于一个有缺失的可选字段,我们并不知道哪个records中明确的包含该值。因此我们引入了重复层级和定义层级的概念,其在下面被定义。用于参考,看一下图3,其中总结了我们的样例records中的所有原子字段的重复层级和定义层级。
重复层级. 考虑图2中的Code字段。其在r1记录中出现了3次。其中“en-us”和"en"在第一个Name中,而"en-gb"在第3个Name中。为什么不混淆这些值,我们为每一个值贴上一个重复层级。该重复层级会告诉我们该值是在字段的哪个层次上重复的。字段Name.Language.Code的路径包含了两个可重复字段,Name和Language。于是,Code字段的重复层级就分布在0和2之间;在0级重复意味着一个新的record的开始。现在假设我们正在自顶向下的扫描记录r1。当我们遇到了“en-us”,我们尚未看到任何重复的字段,也就是说,其重复级别为0。当我们看见“en”,其在Language字段上重复了,因此其重复级别为2。最终,我们遇到了“en-gb”,最近一次发生重复的是Name,因此其重复级别为1。如此一来,在记录r1中字段Code的重复级别为0, 2, 1。
注意在记录r1中的第二个Name并没有包含任何的Code值。为了确定“en-gb”是出现在第三个Name中而非第二个Name中,我们在“en”和“en-gb”之间添加了一个NULL值(查看图3)。字段Code是字段Language中的一个必备字段,因此字段Code缺失暗含了字段Language没有定义。一般来说,确定嵌套的记录在哪一层上存在,这需要额外的信息。
定义层级. 每个路径为p的字段的值,特别是,每一个NULL值,都有一个定义级别来指出在路径p上有多少可能是未定义的字段(由于其类型为可选的或重复的)实际上出现在了记录中。为展示这一点,观察记录r1没有Backward链接字段。然而,字段Links是存在的(在层次1上)。为了保留这个信息,我们为Links.Backward字段添加一个NULL值,其定义级别为1。相似的,在r2记录中Name.Language.Country字段的缺失也带了一个定义层级为1,而其在r1中的缺失却带了一个定义层级为2(说明是在Name.Language中缺失了Country)以及1(说明是在Name中缺失了整个Language),相应的。
我们使用整数的定义层级而非是否为空的位图表示,这样一来一个叶子字段(例如Name.Language.Country)的数据就包含了其父字段是否出现的信息;该信息如何使用的一个例子会在4.3节给出。
上面描绘出的编码无损的保留了record记录的结构。我们为了节省空间就省略了其证明。
编码. 每一个列都被存储为一组的数据块(blocks)。每一个block包含了重复级别和定义级别(以后,简称为级别)以及压缩后的该字段的值。NULL值不会被明确的存储,因为它们可以通过定义层级来决定:对于任何的重复字段或可选字段,当定义层级小于该字段在路径上的位置时说明其值为NULL。对于值总是不为空的字段无需保存其定义层级。相似的,重复层级也是只有在需要时才会保存;例如,定义层级0暗含了重复层级0(整个路径上全都是必备字段),因此后者就可以被省略掉。事实上,在图3中DocId字段是不需要保存任何层级数据的。层级数据被包装为位序列(bit sequences)。我们只需要使用够用的位便可;例如,如果最大的定义层级为3,那么我们为每一个定义层次使用2位就够了。
4.2 将记录Records分解为列Columns
上面我们展示了记录record结构是如何编码为列式格式的。我们要解决的下一个挑战是如何高效的产生带有重复层级和定义层级的列式条纹数据。
附录A给出了计算重复层级和定义层级的基本算法。该算法在record结构上递归执行,并计算每一个字段值的层级数据。如同前面展示的一样,重复层级和定义层级可能在字段的值缺失的情况下仍需要被计算。Google中使用的很多数据集都是稀疏的;具有成千上万字段的schema定义也不是很不常见,而只用到其中某个record记录的一百个字段。如此一来,我们尽量使得对于缺失字段的处理开销越小越好。为了生产出列式条纹数据,我们创建了一个字段写出器(field writer)组成的树型结构,其结构匹配了schema定义中字段的结构。基本的想法是只有在字段写出器有自己的数据时才会更新它们,并且假如没有绝对的必要,不会将父字段写出器的状态往树结构的下方传播。为此,子写出器会继承它们父亲写出器的层级信息。任何时候一个新值被添加,子写出器都同步到其父写出器的层级。
4.3 记录record组装
从列式数据高效的组装回记录records,对于基于记录的数据处理工具来说是至关重要的(例如MapReduce)。假如有字段的一个子集,我们的目的是重新构建出原始的记录,就好像它们只包括了选中的字段一样,去掉了所有的其他字段数据。核心的想法如下:我们创建了一个有限状态机(finite state machine: FSM),读取每一个字段的层级数据及其值,并将值顺序的追加到输出记录中。一个FSM的状态(state)对应了一个选中字段的字段读取器。状态转换被标注以重复层级。一旦一个读取器取到一个值,我们查看下一个重复层级的值来决定接下来使用哪一个读取器。FSM对于每一个record都会从开始状态转移到最后状态一遍。
图4展示了一个FSM在我们的的运行示例中重构一条完整记录的过程。开始的状态为DocId。一旦一个DocId的值被读取了,FSM状态机就转移到Links.Backward。在所有重复的Backward值被全部获取以后,FSM状态机就跳到了Links.Forward上,如此反复。附录B中有记录组装算法的详细说明。
为了描述FSM的状态转换是如何构建的,假设l是当前的针对字段f的字段读取器返回的下一个重复层级值。在schema定义树中从字段f开始,我们发现其在层级l上的祖先字段发生重复,于是我们选择该祖先字段的第一个叶子字段n。这就给了我们一个FSM状态转换(f, l) -> n。例如,假使l = 1是字段f = Name.Language.Country的下一个重复层级。其重复级别1的祖先是字段Name,该字段的第一个叶子字段是n = Name.Url。FSM状态转换构造算法的细节参见附录C。
如果只有字段的一个子集需要被取回,我们可以构建一个更简单的FSM,其执行起来开销会更加的小。图5描述了一个FSM状态机,用于读取字段DocId和Name.Language.Country。图5展示了自动机生产的输出记录s1和s2。注意我们的编码和组装算法保留了字段Country的包裹结构。这对于那些需要访问诸如出现在第二个Name中的第一个Language中的Country的应用来说是非常重要的。在路径语言(XPath)中,这对应了计算如下表达式的能力:/Name[2]/Language[1]/Country。
5. 查询语言
Dremel的查询语言是基于SQL的,且其被设计为针对列式嵌套存储可以高效实现的。正式的语言定义不在本论文的讨论范围;相反,我们会展示该语言的风格。每一个SQL语句(以及其转换成的关系代数算子)会将一个或者多个嵌套的表及其schemas定义作为输入,并生产一个嵌套的表及其输出schema定义。图6描述了一个示例查询,其执行投影、选择、以及一个within记录聚合。查询在图2中所示的表t = {r1,r2}上被计算。使用路径表达式来引用字段。查询产生了一个嵌套的结果,虽然查询中没有定义任何的记录构造器。
为了解释该查询到底做了什么,考虑一下其选择操作(WHERE子句)。将一个嵌套的记录record看作一个标签树,其中每一个标签都对应一个字段名。选择操作会将不满足指定条件的树的分支裁剪掉。如此一来,只有Name.Url有定义且其值以http开头的嵌套记录被保留了。接着,考虑一下投影操作。每一个在SELECT子句中的标量表达式会在与表达式中最重复出现的输入字段相同的层次上生成一个值。因此,字符串拼接表达式会在输入的schema中的Name.Language.Code字段的层级上生成Str值。COUNT表达式展示了within记录聚合。该聚合在每一个Name子记录中发生,并生成64位非负整数(uint64)的Name子记录内Name.Language.Code的出现次数。
查询语言支持嵌套的子查询、记录内及跨记录的聚合、top-k、joins、用户定义函数,等等;其中的一些特性会在下面的试验章节中示范。
6. 查询执行
为了简单起见,我们讨论一下在一个只读系统上下文中的核心思路。很多Dremel的查询是单趟扫描的聚合;因此,我们专注于解释它们并在下一节使用这种查询来做实验。我们把对joins、索引、更新等操作的讨论延后到未来的文章中。
树型架构. Dremel使用一个多层级的服务树(serving tree)来执行查询(参见图7)。一个根服务器接收到来的查询,从表中读取元数据,并将查询路由到服务树的下一层。根节点服务器与存储层或者本地磁盘相沟通以访问数据。考虑一个如下的简单聚合查询:
SELECT A, COUNT(B) FROM T GROUP BY A
当根服务器接收到上述的查询时,它确定所有的子表(tablets),也就是,组成了表T的水平分片,并将查询重写为如下:
SELECT A, SUM(c) FROM (R1 UNION ALL ... Rn1 ) GROUP BY A
表R1,...,Rn1是在服务树的第1层将查询发送到节点1,...,n所得到的结果:
Ri1 = SELECT A, COUNT(B) AS c FROM Ti1 GROUP BY A
Ti1是一个T中的不想交的子表,其在第1层上的服务器i中处理。每一个服务层都执行一个相似的重写。最终,查询到达了叶子节点,其会并行的扫描T中的子表。在服务树中往上走时,居间的服务器对部分的结果执行一个并行的聚合。上述的执行模型非常适合于执行结果集较小或中等的聚合查询,这种类型在交互查询中非常普遍。大的聚合和其他类型的查询可能需要依赖于广为人知的并行数据库和MapReduce的执行机制。
查询分发器. Dremel是一个多用户系统,也就是说,经常会有多个查询并发的执行。一个查询分发器会基于查询的优先级及负载均衡来对其进行调度。其另一个重要的角色是提供错误容忍性,当一个服务器变得比其他服务器慢得多时或者当一个子表副本变得不可达时。
在每一个查询中处理的数据量经常经常大于用于执行的处理单元数量,我们称这些处理单元为槽(slots)。一个槽对应着一个叶子服务器上的执行线程。例如,一个具有3000个叶子服务器的集群,每个服务器使用8个执行线程,总共具有24000个槽。因此,一个横跨了100000个子表的表可以通过将大约每5个子表分发到一个槽中来处理。在查询执行过程中,查询分发器会计算一个子表处理时间的分布图,如果一个子表花费了特别长的时间来处理,查询分发器会将其重新调度到另一个服务器。一些子表可能需要被重新分发多次才能被成功处理。
叶子服务器读取列存格式的嵌套数据的某些列的条状数据。每一个条状数据(列对应的连续存储的数据)中的数据块都会被异步的预取回来;预读缓存通常会达到95%的缓存命中率。子表通常进行三副本复制。当一个叶子服务器不能访问一个子表副本时,它会在另一个副本上尝试。
查询分发器提供一个参数来指定必须扫描最少多大比例的子表之后才可以返回一个结果。我们做个简短的论证,将该参数设置为一个更低的值(例如,98%而非100%)经常能够巨大的提升执行速度,特别是当使用更小的复制因素时(更小的复本数)。每一个服务器都有一个内部的执行树,正如在图7的右边所描述的。内部树对应了一个物理查询执行计划,包括对标量表达式的计算。对于绝大部分标量函数,可以直接生成优化的,类型特定的代码。一个对于投影-选择-聚合查询的执行计划包含了一组迭代器,它们步调一致的扫描输入列数据并产生聚合和标量函数的结果,该结果会以正确的重复层级和定义层级来注释,并在整个执行过程中整个的绕过了记录record组装。查看附录D以获得更多的细节。一些Dremel的查询,例如top-k和count-distinct等,会使用被称为一趟算法的算法来返回近似值。
7. 试验
在本节中我们在几个Google中使用的数据集上评估了Dremel的性能,并检查了针对嵌套数据的列式存储的效率。图8总结了我们试验中的数据集的特性。在未压缩、没有复制的情况下,这些数据集占用了大约1PB的空间。所有表都复制三副本,除了一个两副本的表,并含有从100KB到800KB大小不等的子表。我们通过检查在单个机器上的基本数据访问开始,然后展示了列式存储是如何有益于MR执行的,最终专注于Dremel的性能测试。本实验运行于跑在两个数据中心上的系统实例中,与很多其他的应用混合部署,并在执行期间同时也会处理常规的业务操作。除非特殊说明,执行时间会在五次运行中取平均值。下面实验中使用的表和字段被匿名隐藏了。
本地磁盘. 在第一个实验中,我们检查了在列式存储vs.记录原生的存储之间的性能权衡,扫描表T中的一个包含300K行数据的1GB的片段(查看图9)。数据存储在本地磁盘上,在压缩的列式存储格式下占据375MB大小。记录原生的格式使用了更重的压缩算法,但是也仅仅达到与列式压缩数据相同的大小。试验在一个双核Intel机器上执行,其带有一个读取带宽为70MB的磁盘。所有报告的时间都是冷的;系统缓存在每次扫描数据之前都被清理。
图9展示了五条曲线,展示了读取及解压数据,以及组装和解析记录所花费的时间,处理的是所有字段的一个子集。曲线(a)-(c)勾画出了列式存储的结果。这些曲线中的每一个数据点都是通过在30次运行中取平均值来获得的,每一次运行都会随机选择指定基数的一组列。曲线(a)展示了读取和解压时间。曲线(b)添加了从列式格式组装为嵌套记录的时间。曲线(c)展示了将记录records解析为强类型的C++数据结构所花费的时间。
曲线(d)-(e)描述了访问在记录原生存储上的数据的时间。图(d)展示了读取和解压缩的时间。一大块时间花费在了解压缩上;实际上,压缩数据可以在其中大约一半的时间里被读取。正如曲线(e)所指出的,解析操作在读取与解压花费的时间上额外添加了50%的时间。这些开销会花费在所有的字段上,包括哪些不需要的字段。
该实验主要提供的看法是:当少量字段被读取时,列式存储带来的性能提升是一个数量级的。列式的嵌套数据的获取时间与读取字段数量是线性相关的。记录record的组装和解析是很昂贵的,每个操作都可能会将执行时间翻倍。我们在其他数据集上也观察到了相似的趋势。一个自然而然要问的问题是,顶部曲线和底部曲线是在何处相交的,也就是说,何时记录原生存储开始在性能上超过列式存储。在我们的试验中,相交点经常位于几十个字段处,但是在不同数据集上是有区别的,并且依赖于是否要求记录的组装。
MR和Dremel. 接下来我们展示一下MR和Dremel在列式数据vs.记录原生数据上的执行。我们考虑一个场景,其中只会访问单个字段,也就是说,性能提升是最明显的。多个字段的执行时间可以通过图9的结果推断出来。在该实验中,我们计算了表T1中的一个字段txtField中包含的单词的平均数量。MR是通过如下的Sawzall程序来执行的:
numRecs: table sum of int;
numWords: table sum of int;
emit numRecs <- 1;
emit numWords <- CountWords(input.txtField);
记录的个数保存在变量numRecs中。对于每一个记录,numWords都会由其input.txtField中包含的单次个数来增加,该个数由函数CountWords返回。在程序执行之后,平均单次个数可以通过numWords/numRecs来计算。在SQL中,该计算可以表达为:
Q1: SELECT SUM(CountWords(txtField)) / COUNT(*) FROM T1
图10展示了在一个质数时间度量尺寸上的两个MR任务和Dremel的执行时间。两个MR任务都在3000个工作节点上运行。相似的,一个3000节点的Dremel实例被用于执行查询Q1。Dremel和在列存数据上跑的MR任务读取了大约0.5TB的压缩列式数据,对比于在记录原生数据上跑的MR任务读取的87TB的数据。正如图10所展示的,通过从记录原生的数据切换到列式存储数据,MR任务获得了一个数据量的效率提升(从小时级别到分钟级别)。另一个数量级的提升是通过使用Dremel(从分钟级提升到秒级)。
服务树拓扑. 在接下来的试验中,我们展示了服务树的深度对查询执行时间的影响。我们考虑在表T2上的两个GROUP BY查询,每一个都通过一个单个的扫描全部数据来执行。表T2包含了240亿行嵌套记录。每个记录都包含了一个可重复字段item,其中包含了一个数值的amount字段。在数据集中字段item.amount重复出现了大约400亿次。第一个查询通过country分组,将item.amount加起来:
Q2: SELECT country, SUM(item.amount) FROM T2 GROUP BY country
其返回了几百条记录,从磁盘上读取了大约60GB的压缩数据。第二个查询在一个文本字段domain上执行分组,并伴随着一个选择条件。它读取了大约180GB的数据并生产了大约110万条不同的domains数据:
Q3: SELECT domain, SUM(item.amount) FROM T2 WHERE domain CONTAINS ’.net’ GROUP BY domain
图11将每一个查询的执行时间展示为服务器拓扑的一个函数。在每一个拓扑中,叶子服务器持续保持在2900台,如此一来我们可以假设它们具有相同的累加扫描速度。在2层的拓扑中(1:2900),一个单个的root服务器直接与所有的叶子服务器沟通。而在3层的拓扑中,我们使用一个1:100:2900的设置,也就是说,有一个额外的100台中间服务器的层次。4层拓扑的设置为1:10:100:2900。
查询Q2使用3层服务树时在3秒内运行完毕,并且在此基础上额外再添加一层没有带来什么好处。相反,Q3的执行时间会由于并行度的提升而成半的减少。在2层的时候,Q3执行时间都超过图示的范围,由于root服务器需要几乎以顺序的方式聚合从数千个节点上收到的结果。该试验展示了返回很多组的聚合查询是如何从多层服务树上得到好处的。
每子表的直方图. 为了更深入的研究在查询执行期间发生了什么,考虑一下图12。图12展示了对于特定的查询Q2和Q3,叶子服务器处理子表的速度到底是怎样的。对时间的度量开始于当一个子表被调度到一个可用任务槽中执行时,也就是说,不包含在任务列表中等待的时间。这种测量方法排除了并发执行的其他查询的影响。每个立方图的底部区域都对应了100%的子表。正如图12所指出的,99%的Q2(或Q3)的子表都会在1秒(或2秒)之内被处理完。
记录内(within-record)聚合. 在另一个试验中,我们检查了在表T3上运行的查询Q4。该查询展示了记录内聚合:其计数了所有这样的记录,该记录中的所有a.b.c.d值之和大于所有a.b.p.q.r值之和。字段在不同的嵌套层次上都有重复。基于列式条纹数据,只有13GB(在70TB中的)被从磁盘中读取出来,整个查询在15秒内完成了。如果不支持嵌套,在表T3上运行这个查询会非常的昂贵。
Q4 : SELECT COUNT(c1 > c2) FROM
(SELECT SUM(a.b.c.d) WITHIN RECORD AS c1,
SUM(a.b.p.q.r) WITHIN RECORD AS c2 FROM T3)
伸缩性. 如下的试验展示了在一个万亿级记录的表上系统的伸缩性。如下展示的查询Q5会选择top-20的aid's以及它们出现在表T4中的次数。该查询扫描了4.2TB的压缩数据。
Q5: SELECT TOP(aid, 20), COUNT(*) FROM T4 WHERE bid = {value1} AND cid = {value2}
该查询使用四种不同的系统配置来运行,集群范围从1000个节点到4000个节点。执行时间展示在图13中。在每一次运行中,总的CPU花费时间几乎是相同的,大约30万秒,而用户感觉到的消耗时间会随着系统集群规模的增加而接近线性的降低。该结果建议如下,一个更大的系统可以在资源使用方面达到与一个更小的系统相同的效率,但是允许更快的执行。
性能挣扎者. 我们最后一个实验展示了性能挣扎者造成的影响。如下的查询Q6运行在一个万亿行级的表T5上。与其他数据集相比,T5是两副本进行复制的。因此,由于性能挣扎者造成的查询减慢的可能性会更高,由于更少的重新调度工作的机会。
Q6: SELECT COUNT(DISTINCT a) FROM T5
查询Q6读取了超过1TB的压缩数据。要获取的字段的压缩率大约为10。正如在图14中所描述的,99%的子表的每工作槽每子表执行时间都在5秒以内。然而,一小部分子表会花费长的多的时间,这拖慢了整个的查询响应时间,将其从一分钟以内延长到了数分钟,当在一个2500节点的系统上运行时。下一节概述了我们实验性的发现以及我们学到的经验教训。
8. 观察结果
Dremel每个月会扫描数千万亿(quadrillions)条记录。图15展示了在一个典型的Dremel系统的每月工作负载中查询响应时间的分布,以一个指数级的度量尺度。正如图中所指出的,绝大部分查询会在10秒以内处理完,很好的处于交互式查询的范畴内。一些查询在共享集群上的扫描吞吐达到了接近每秒1000亿条记录,而在专用的机器上会达到更高的速度。上面展现的实验性的数据建议了如下的观察结果:
- 基于对存放在磁盘上的多达万亿条记录的数据集扫描的查询可以以一种交互式查询的速度被执行。
- 在包含数千个节点的系统上可以实现对于列数量和服务器数量的近乎线性的伸缩性。
- MR可以像一个数据库系统一样从列式存储中获益。
- 记录的组装和解析是昂贵的。软件层(查询执行层之外的)需要被优化以直接消费列式数据。
- MR和查询执行可以以一种互补的方式被使用;一层的输出可以作为另一层的输入。
- 在一个多用户环境中,更大的系统会从规模效益上更加获益,同时可以提供定性的更好用户体验(性质上的区别)。
- 如果将执行速度权衡为并发度是可以接收的,一个查询可以被更早的终止执行,但是其可以看到绝大部分的数据(避免翘尾)。
- 大部分网络规模的数据集都可以被快速扫描。而在严格限制的时间边界内达到最后几个百分点是很困难的(翘尾效应)。
Dremel的代码库是很紧凑的;它包含了不超过10万行的C++,Java,以及Python代码。
9. 相关工作
MapReduce(MR)框架被设计来在长时间运行的批任务上下文中解决大规模计算带来的挑战的。像MR一样,Dremel提供了错误容忍执行、一个复杂的数据模型、以及施工现场级的数据处理能力。MR的成功导致了大范围的第三方实现的出现(特别是开源的Hadoop),以及一堆的将并行数据库与MR组合起来的混合系统,由供应商提供的像Aster,Cloudera, Greenplum及Vertica等。HadoopDB是处于这种混合类型的一个研究系统。最近的文章对照了MR和并行数据库。我们的工作强调了这两种范式的互补的本性。
Dremel被设计用来执行大规模操作的。虽然想象中并行数据库也可以扩展到数千个节点,但是我们没有看到任何公开的工作或生产环境中的报告尝试过这么做。我们也没看到先前有文献研究过在列式存储上跑MR。
我们在嵌套式数据上的列式表达构建于几十年前的一些想法:将数据内容和数据的结构描述分离开,并转换数据表达。一个最近的对于列式存储的回顾,包括了压缩和查询执行,可以在[1]中找到。很多商用的数据库支持通过XML来存储结构化数据(例如[19])。XML存储模式尝试将结构与内存分开,但是由于XML数据模型的复杂性而面临着更多的挑战。XMill[17]是一个使用列式XML表达的系统。XMill是一个压缩工具。其存储了所有字段组合在一起的结构,并没有专门针对获取可选的几列数据进行设计。
Dremel中使用的数据模型是在[2]中讨论的复杂值模型及嵌套惯性模型的一个变种。Dremel的查询语言构建于[9]中的想法,其中介绍了一种在访问嵌套数据时避免重新构建记录的语言。相反,对记录的重构经常在XQuery和对象原生的查询语言中被要求,例如,使用嵌套的for循环及构造器。我们没有看见对于[9]的实际的实现。一个最近的操作于嵌套数据上的类似于SQL的语言是Pig[18]。其他的并行数据处理系统包括Scope[16]和DryadLINQ[23],并在[7]中做了详细讨论。
10. 结论
我们展现了Dremel,一个用于在大数据集上做交互式分析的分布式系统。Dremel是一个构建在更简单的组件上的定制的、可伸缩的数据管理解决方案。其补充了MR计算范式。我们讨论了其在万亿条记录、数TB实际数据构成的数据集上的表现。我们勾勒出了Dremel的核心方面,包括了其存储格式、查询语言、以及查询执行。未来,我们计划更进一步的深入覆盖如下的领域,包括正规的关系代数规范、joins、扩展性机制,等等。
11. 致谢
略.....
=====================================================================================
本技术论文的翻译工作由不动明王1984独自完成,特此声明。
翻译辛苦,珍惜劳动,引用时请注明出处!
=====================================================================================