HarmonyOS开发者限时福利来啦!最高10w+现金激励等你拿~ 了解详情
写点什么

数据库内核杂谈(四):执行模式

  • 2019-12-16
  • 本文字数:5749 字

    阅读完需:约 19 分钟

数据库内核杂谈(四):执行模式

本篇文章选自数据库内核杂谈系列文章。


在之前的文章中,我们通过存储索引,了解了如何把数据存储在文件系统里,然后根据不同的查询语句,通过建立索引来提速读取。今天,我们来聊一下当数据读进内存后,数据库怎么继续执行查询。


之前系列文章都是以自底向上的线索来介绍数据库内核。但在介绍具体的执行前,我觉得有必要从宏观角度来看一下数据库的内部结构是如何对输入的 SQL 语句做处理,并返回结果集(Resultset)的。这样一个宏观的了解也会让读者对后续的数据库执行和优化更加期待。在系列的第一期里,我们介绍的一小时数据库是直接根据 SQL 语句来看如何实现,那一个正儿八经的数据库内部是怎样的呢?下图给出了一个宏观数据库的内部架构,我们跟着图片一一介绍。



数据库内部结构

编译(parsing)

当用户输入 SQL 语句后,第一步是通过编译器(parser)把语句编译成抽象语法树(Abstracted Syntax Tree)。这一步的主要过程就是确保输入语句没有 SQL 语法和词法错误。常见的语法错误如:关键词拼写错误-把"SELECT"拼成"SELCT"; 是否有多余的标点符号; 整个语句是否合法-SELECT 后需要跟有 FROM 子语句,并且不应该有两个 WHERE 子语句等等。编译器的实现,一般不需要手动一个规则一个规则地去实现,而是会通过定义词法和语法,然后由编译器的库来生成相关的代码。一般每个语言都有比较成熟的编译器库,比如 Java 语言的JavaCC。这是网上找到的一个开源 JavaCC 实现的SQLParser ,有兴趣的同学可以去深入了解下。生成的语法树就是一个保留原语句的结构化的树。比如,把下面示例语句编译成语法树



示例 SQL


得到如下图所示:



语法树


根节点 SELECT 包含 Projection-expression 和 GroupBy-expression,并且它有一个同为 SELECT 的子节点。这个子节点有自己的 Projection-expression 和 Where-clause。这里,留一个小问题,假如查询语句如下(查询一个不存在的数据表),编译器会报错吗?



错误示例 SQL

绑定(binding)

揭晓答案,编译器是无法察觉上面的 SQL 中的错误的,因为它只负责把文本的 SQL 语句转化成了一棵符合 SQL 语法结构的树。那谁来赋予这棵树灵魂呢?答案就是 binder(姑且就翻译为绑定器吧)。顾名思义,绑定器的作用就是将语法树通过和数据库的元数据(metadata)结合,为它附上语义(semantic)。比如语句里有 SELECT…FROM student,绑定器会去查询元数据确认 student 表是否存在;如果存在,是否有 class 和 id 两个属性;对于属性的后续操作是否符合规则-比如,对于 SUBSTR()这个方法,输入表达式必须是字符串类型等的一系列检查。检查过程是自底向上对整棵语法树的节点依次进行,检查的同时也把相关表的元数据,属性的元数据附在语法树上,最后生成含有语义的语法树(bound AST)。绑定器在绑定的过程中就能察觉到上述 SQL 的问题而返回编译错误。一旦绑定完成,这个 SQL 语句就算是通过编译过程了。

优化(optimizing)

下一步就是优化器(Optimizer)的表演了。有这么一个传言,有这么多很好用的开源数据库,为什么商业数据库还卖这么贵?贵就贵在优化器这。优化器实现非常复杂,往往需要一个团队来开发。但同时,一个好的优化器可以把执行速度提高好几个数量级,特别是在针对复杂语句的优化上优势更加明显,可能就是一个小时和几秒的差距(是不是应该买买买!)。后面会有专门的章节介绍优化器,今天稍微提一下大概。给定了语法树,优化器会先生成一个逻辑执行树(logical operator tree)。这个执行树和我们第一章(一小时数据库)末尾的执行树类似。以上面的示例语句为例,生成的执行树如下:



逻辑执行树


这个过程通常是语法树节点到操作符节点一对一生成。生成后执行树上的每个节点,称为逻辑操作符(logical operator)。再下一步,就是对应每个逻辑操作符,扩展出所有的物理操作符(physical operator)。何为逻辑和物理操作符呢?比如 TableScan,只是说明了这个操作符所要做的就是读取某个表的数据,这就是逻辑操作符。而对应的物理操作符则同时表明了应该用什么方法来实现这个功能,比如 SequentialTableScan(全表扫描)就是 TableScan 的一个物理实现,指明了通过扫描全表来得到数据。而如果用 BTreeIndexScan 就表明通过读取该表的 BTree 索引来读取数据(建立在相应属性已建立 BTree 索引的前提下)。再比如示例中的 GroupByOperator,有什么样对应的物理操作符呢?方法一,通过建立 Hash 表来实现 GroupBy(HashGroupByOperator);方法二,通过对子节点的输入的 key 属性进行排序,然后对于相同 key 进行聚合操作再输出(SortGroupByOperator)。(注:后续文章讲 GroupByOperator 实现的时候我们会深入讲解,这边就先简略带过)扩展之后的物理执行树,相对应与原来的逻辑执行树,相当于变出了很多分身:每个逻辑节点对位多个物理节点,比如一个物理执行树可以用 HashGroupBy 配 SequentialTableScan,也可以用 HashGroupBy 配 BTreeIndexScan。对应示例的逻辑执行树,相当于总共形成了 4 棵物理执行树。下一步就是最最困难的,在这浩如烟海的物理执行树中选出最好的一个,作为执行计划(Physical query plan)。看到这,读者可能一脸懵比?4 个等于浩如烟海?这只是因为示例的语句很简单,没有太多的组合可能。那什么情况会形成执行树数量的爆炸呢?就是表的联合(join)。假如一个 SQL 语句包含 10 个表的联合,这 10 个表可以相互两两联合形成中间表(intermediate result),这些中间表还需要再一次进行两两联合,然后再继续。并且,每一次联合有两种选择(table1 join table2 或者 table2 join table1),而且联合对应的物理操作符又有好几个(HashJoin 或者 MergeSortJoin 注:在讲 Join operator 的时候会深入讲解)。这样一来,一个复杂的查询语句对应上百万个执行树就不难理解了。这里先不深入讲解优化器是怎么做出选择的,我们暂且假设它就是个黑盒操作选出了一个它认为最优的作为执行计划。

执行(executing)

有了这个执行计划,执行器要做的就是加载相应操作符的代码,然后依次执行这些代码。这些代码和我们第一章的一小时数据库给出的示例代码功能类似。从执行树的底层,由读取表数据开始,依次向上执行。最后把执行得到的结果以 Rowset 的形式返回给用户。


至此,一个完整的由输入 SQL 语句开始,到输出结果集的生命周期完整结束。梳理一下:


1)用户输入 SQL 语句 -> 编译器 -> 抽象语法树


2)抽象语法树 -> 绑定器 -> 绑定语义的语法树


3)绑定语义的语法树 -> 优化器 -> 物理执行计划


4)物理执行计划 -> 执行器 -> 运行执行计划,得到结果集,返回给用户

执行模式

了解了整个流程,我们就可以更好地来看执行器是如何根据执行计划,一步一步将数据读取出来,然后计算出结果集的。先回到咱们第一章的一小时数据库的执行器,看看它是怎么执行的。在那个执行器里,我们定义了两个抽象的操作符,单元操作符(unary operator)和二元操作符(binary operator),示例代码如下。



UnaryOperator 和 BinaryOperator


它们都实现了 process 逻辑,然后相应的物理操作符比如 SequentialTableScan 或者 SortOperator 只需要实现具体的__impl 方法即可。根据这样的执行器,生成如下的执行计划代码,即可运行我们示例中的 SQL 语句:



对应示例语句的执行计划代码


代码运行时,自底向上,每个节点 process 方法只需要运行一次,一次性处理子节点的全部输入 Rowset,__impl 处理后,返回处理过的 Rowset。这种执行模式称为 materialization 模式(额,不知如何翻译成中文)。寓意为把自己的输出打包一次性传给上层节点。这种执行模式有哪些优点呢?首先,非常直观,实现起来也相对容易,上下 operator 的交互只有一次。那这种模式有什么缺点吗?所谓成也风云,败也风云。简单就是它的缺点:一次性需要处理所有的输入。如果我们要处理的数据特别大,假设某个表有 1 亿条数据,TableScan 需要把所有数据先读取到内存中,再传输给上层节点,可能在这个过程中,就已经 OOM(out of memory)了。因此,materialization 模式并不适用数据量相对很大的 OLAP(online analytical processing)查询语句。


如何改进能够避免 OOM 呢?有同学可能想到了,每个操作符并不需要把所有数据一次性处理完再打包传给上层节点,完全可以借鉴时下流行的流系统(streaming system)的运行模式:每一个操作符既是 producer,又是 consumer:consume 子节点的输入,然后 produce 输出给上层节点:数据就像水流那样流过所有的节点,最后以一个一个 tuple 的形式返回给 user。其实呢,可能正确的说法应该是 streaming system 借鉴了数据库的这种运行模式。这个模式称之为 iterator model(迭代模式)或者叫 Volcano model(火山模式),最早由科学家 Goetz Graefe 于 1990 年提出(再次感谢一下计算机先贤)。下图给出了简单的伪代码实现:



迭代模式下的 UnaryOperator


每个 operator 会有一个 next_tuple 函数,用来让上层节点调用来获得下一个 tuple,以及 emit 函数用来输出一个处理过的 tuple 到上层节点。整个 process 的过程入下图示例代码:



迭代模式下的 process 逻辑


在 while 循环中,不断获取子节点的下一个输入 tuple,处理后输出给上层节点直至子节点输入全部处理完毕。这个迭代模式,是不是就完美解决了所有的 OOM 问题呢?答案是否定的。因为,并不是所有的操作都适用于流模型,比如处理 order by 语句的 SortOperator,如果要对全部输入进行排序,必须等到所有输入都得到后才能进行排序,因此执行过程会堵塞(block)。再比实现示例语句中的 group by 语句的 HashGroupByOperator,也是需要获取所有的输入后才能做聚合操作来得到正确的结果再输出。下图给出了 HashGroupByOperator 的伪代码实现:



迭代模式里的 HashGroupByOperator


对于子节点的每个输入,我们先进行哈希表的建立和更新,当所有的输入都结束后,依次输出哈希表的键值对。这类操作也称之为堵塞操作(blocking operator)。那如何解决堵塞操作的 OOM 问题呢?这就需要这些操作能够在处理的过程中把中间的结果集(intermediate result)暂存到文件系统中(spill to disk)。比如 sort,可以用 external file sorting algorithm。具体的这些操作符的实现我们会在后续的章节中详细介绍。这里插个题外话:大家可能觉得,实现 spill to disk 功能对数据库引擎是必须的。但从工程角度来说,实现正确又高效是挺有难度的。比如作者公司内部使用最多的Presto, 已经是一个比较出名的开源数据库系统实现。但至今为止,感觉 spill to disk 功能也没完全实现。在运行某些语句时,经常因为遭遇单个节点内存 limit 或者集群内存 limit 而报错。吐槽一下!既然吐槽了自家公司,就再吐槽一家很出名的大数据公司。当时它们也推出了一款分布式数据库执行引擎。然后在测试的过程中发现,执行 order by 语句必须同时加上 limit 限定语句。哈,这一看就是当时 sorting 还不支持 spill to disk. 留个思考题给大家,猜猜当时他们是怎么实现 sorting 的。答案在结尾揭晓。


总结一下,迭代模式实现了流式处理,配合上 spill to disk 的实现来解决堵塞操作符,就是一个非常通用的执行模式,完美解决了 materialization 模式的缺点。那它自己有什么缺点吗?其实 materialization 的优点就是它的缺点:实现复杂度很高。而且数据是一个一个 tuple 在操作符间传递,这导致不同的操作符之间需要多次的协调,因此处理相同的数据,迭代模式比前者更慢。


materialization 模式是一次性处理所有数据,而迭代模式是一个一个 tuple 处理数据,有没有一种折中的方式呢?就是今天的最后一个知识点向量模式(vectorization model),或者叫批处理模式(batch model)。相比于迭代模式一个一个 tuple 处理,向量模式是一批一批处理数据,伪代码如下图所示:



向量模式下的 UnaryOperator



向量模式下的 process 逻辑


向量模式相比于迭代模式,每一次处理多个数据,减少了操作符之间的交互;而相较于 materialization 模式,又更不容易导致 OOM,所以实现相对容易。并且,可以更好地利用处理器的 SIMD(single instruction multiple data)指令来提高执行速度。向量模式的另一个优点在于,很切合列存:批量从列存中读取数据后进行批量处理。因此,比较适用于数据量很大的 OLAP 查询语句。


最后,我们对今天介绍的所有执行模式来一个总结:


1)materialization 模式:执行的过程自底向上,每个节点都一次性处理所有数据。优势是实现简单,但对于数据量很大的 OLAP 语句不太合适,但比较适合单次操作数据量较小的 OLTP(online transactional processing)语句。


2)迭代模式(或者叫 volcano model): 一种通用的执行模式。流式的执行过程,数据以一个一个 tuple 形式传递与操作符之间。有一些操作符会需要阻塞等待所有数据,需要 spill to disk 实现。缺点是实现复杂,由于操作符之间不断交互,所以效率相对较低。


3)向量模式:介于前两者之间,批量处理数据。更好地利用 SIMD 来提高执行速度。对于大量数据处理比迭代模式高效,所以也更适合 OLAP 语句。


这一期,我们先从宏观上大致讲解了数据库的内部构造,然后具体聊了聊不同的执行模式以及它们的优缺点。下一期,我们聊一聊那些比较复杂的操作如 sorting, join 和 GroupByOperator 的具体实现,尽情期待。


揭晓上面的思考题,要求 sorting 加 limit 语句来限制总数,应该是用了 minHeap 来实现的排序。


作者介绍:


顾仲贤,现任 Facebook Tech Lead,专注于数据库,分布式系统,数据密集型应用后端架构与开发。拥有多年分布式数据库内核开发经验,发表数十篇数据库顶级期刊并申请获得多项专利,对搜索,即时通讯系统有深刻理解,爱设计爱架构,持续跟进互联网前沿技术。


2008 年毕业于上海交大软件学院,2012 年,获得美国加州大学戴维斯计算机硕士,博士学位;2013-2014 年任 Pivotal 数据库核心研发团队资深工程师,开发开源数据库优化器 Orca;2016 年作为初创员工加入 Datometry,任首席工程师,负责全球首家数据库虚拟化平台开发;2017 年至今就职于 Facebook 任 Tech Lead,领导重构搜索相关后端服务及数据管道, 管理即时通讯软件 WhatsApp 数据平台负责数据收集,整理,并提供后续应用。


相关阅读:


数据库内核杂谈(一):一小时实现一个基本功能的数据库


数据库内核杂谈(二):存储“演化论”


数据库内核杂谈(三):索引优化


2019-12-16 11:1610941

评论 16 条评论

发布
用户头像
感谢作者的分享,收获颇丰
2023-03-22 19:11 · 北京
回复
用户头像
学习了,之前很多模糊的概念清晰啦~ 感谢大佬
2022-07-05 14:18
回复
用户头像
为啥需要next_tuple和emit同时存在呢?pull模式和push模式不是一般选其一吗?
2022-01-17 18:34
回复
用户头像
作者留言:2022年1月1日,祝大家新年快乐。不知不觉,数据库内核杂谈又陪伴大家一年(虽然。。还是不可避免地拖更了)。今年的new year resolution,希望创建一个群,和大家一起交流。加我微信 81211430(请备注数据库内核杂谈,谢谢),我会建群。期待和大家交流。
2022-01-01 12:28
回复
用户头像
这也太好读了,又好读又能学到东西,感谢感谢!作者就是我大哥!
2021-10-12 20:31
回复
用户头像
作者写的真的很好,希望可以再深入一些
2021-09-05 14:35
回复
用户头像
正在学习MIT的15-445的数据库设计课程,感觉您的说法更加符合国人的理解哈哈,也通俗易懂一些
2021-08-16 17:00
回复
用户头像
作者来留个言,一言难尽的2020终于过去了,新年快乐,希望2021对所有人,都充满惊喜。数据库内核杂谈也已经更新到第十四期了,再次感谢各位的关注。如果你是内核杂谈的真粉丝,觉得内核杂谈对你有收获,愿意听我多扯扯,欢迎加入我的知识星球:Dr.ZZZ 聊技术和管理:https://t.zsxq.com/VrZbeAE(由于不知道能输出多少,也不知道有多少粉丝,所以还是先免费吧)
2021-01-01 02:35
回复
用户头像
呜呜呜,这才是深入浅出呀。感觉iterator mode有点类似于cpu的流水线思想,在执行过程中各个部件都在运转,就是每个tuple一次的函数调用代价会不会太大

2020-12-31 16:04
回复
用户头像
给力!支持作者!
2020-09-02 18:24
回复
用户头像
入门很难的数据库知识,作者讲得清晰易懂,感觉作者很有教书天赋,哈哈,感谢分享
2020-02-22 15:11
回复
用户头像
作者来留个言,感谢大家阅读。我一定再接再厉,不会断更的。。就是平时工作也挺忙的。更新稍微缓慢一些。
2020-01-16 13:27
回复
非常好的系列文章,认真拜读中。 原文中"其实 materialization 的优点就是它的缺点" 我的理解应该是 "其实 迭代模式 的优点就是它的缺点"
2020-06-13 04:25
回复
用户头像
非常感谢作者的分享,很详细。
2020-01-06 15:13
回复
用户头像
学习了,感谢作者分享
2019-12-24 22:55
回复
用户头像
学习了
2019-12-16 18:26
回复
没有更多了
发现更多内容

头脑风暴:最长递增子序列

HelloWorld杰少

算法 LeetCode 数据结构, 8月月更

从函数计算到 Serverless 架构

阿里巴巴中间件

阿里云 开源 Serverless 云原生

打破底层技术瓶颈,RTC技术或成为物联网市场的强劲引擎

擎声科技

物联网 RTC sdk 实时音视频 擎声Qtt

5 分钟温故知新 RxJS 【转换操作符】

掘金安东尼

前端 函数式编程 8月月更

【Java】:数组的创建、赋值、访问以及长度

翼同学

Java 学习 编程语言 分享 8月月更

微服务的拆分与设计原则

阿泽🧸

微服务 8月月更

程序员面试太卷?我选择背这份阿里最新Java面试八股文(详解版)

Java面试那些事儿

Java 面试 Java 面试 java程序员 java 编程

短视频源码APP开发——短视频的功能

开源直播系统源码

软件开发 直播系统源码 短视频直播系统

个推TechDay直播预告 | 8月24日晚19:30,实时数仓搭建保姆级教程开课

个推

数据仓库 实时数仓 Flink 平台

【源码解析】MyBatis结果集映射和参数绑定

小明Java问道之路

源码解析 mybatis源码 mybaits 深度 8月月更

RocksDB在大规模分布式系统应用中的经验

CnosDB

时序数据库 开源社区 CnosDB 工程师有话说 CnosDB Tech Talk

leetcode 303. Range Sum Query - Immutable 区域和检索 - 数组不可变(简单)

okokabcd

LeetCode 算法与数据结构

C语言基本的窗口开发

计算机魔术师

8月月更

字节跳动嵌入式数据分析最佳实践

字节跳动数据平台

字节跳动 数据分析 BI 嵌入式分析 数据看板

【Django | 开发】面试招聘信息网站(快速搭建核心需求)

计算机魔术师

8月月更

每日一R「09」类型系统(三)

Samson

8月月更 ​Rust

开源贡献者计划 2022 第二期正式启动!争做战“码”先锋!

InfoQ写作社区官方

开源 OpenHarmony 热门活动

HTML

武师叔

8月月更

把充电宝拆了看看

Sher10ck

拆解 充电宝

百炼成钢 —— 声网实时网络的自动运维丨Dev for Dev 专栏

声网

Dev for Dev 自动运维

IPv6邻居发现协议详解

穿过生命散发芬芳

NDP 8月月更

HPC、AI与云计算:当智能时代三叉戟在亚马逊云科技完美融合

脑极体

开源一夏 | Node.js实战对于Buffer和Stream模块系统的深入剖析

恒山其若陋兮

开源 8月月更

ABAP工作进程对数据库表读取操作的检测

汪子熙

数据库 SAP abap Netweaver 8月月更

避免 10 大 NGINX 配置错误(下)

NGINX开源社区

nginx 配置 配置分析 故障排除

新书上市 | 连载 5 年,千万读者追更,这本书讲透了通信背后的故事!

图灵教育

深度学习公式推导(3):初探神经网络

老崔说架构

关于 SAP ABAP 字符变量和字符串变量字符个数的一个知识点

汪子熙

字符串 SAP abap Netweaver 8月月更

大专的我狂刷29天“阿里内部面试笔记”最终直接斩获十七个Offer

收到请回复

Java 程序员 阿里 面试八股文 Java面试八股文

openEuler代码贡献之星:麒麟软件裴建康

openEuler

开发者 成长 代码规范 openEuler 开源社区

[极致用户体验] 网页里的「返回」应该用 history.back 还是 push ?

HullQin

CSS JavaScript html 前端 8月月更

数据库内核杂谈(四):执行模式_数据库_顾仲贤_InfoQ精选文章