开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第22天, 点击查看活动详情
openGauss数据库是华为深度融合在数据库领域多年经验,结合企业级场景要求推出的新一代企业级开源数据库。此前,Gauss松鼠会已经发布了openGauss数据库核心技术系列文章,介绍了openGauss的技术原理。从本期开始,Gauss松鼠会将陆续推出openGauss数据库源码解析系列文章,带你解析openGauss各功能模块的源代码逻辑和实现原理。该系列文章主要面向openGauss内核开发者及研究人员。
接下来会先向大家概要介绍openGauss。本篇将从
openGauss概述、应用场景、系统结构、代码结构
四个方面对openGauss进行介绍。
一、openGauss概述
openGauss是关系型数据库,采用客户端/服务器,单进程多线程架构;支持单机和一主多备部署方式,同时支持备机可读、双机高可用等特性。
openGauss有如下基本功能:
1、支持标准SQL
openGauss数据库支持标准的SQL(Structured Query Language,结构化查询语言)。SQL标准是一个国际性的标准,定期会进行更新和演进。SQL标准的定义分成核心特性以及可选特性,绝大部分的数据库都没有100%支撑SQL标准。openGauss数据库支持SQL92/SQL99/SQL2003等,同时支持SQL2011大部分的核心特性,另外还支持部分的可选特性。
2、 支持标准开发接口
openGauss数据库提供业界标准的ODBC(Open Database Connectivity,开放式数据库连接)及JDBC(Java Database Connectivity,java数据库连接)接口,保证用户能将业务快速迁移至openGauss。目前支持标准的ODBC3.5及JDBC4.0接口,其中ODBC能够支持CentOS、openEuler、SUSE、Win32、Win64等平台,JDBC无平台差异。
3、混合存储引擎支持
openGauss数据库支持行存储引擎、列存储引擎和内存存储引擎等。行存分为“inplace update” 和 “append update”两种模式,前者通过单独的回滚段(undo log)来保留元组的前像以解决读写冲突,可以更自然的支持数据更新;后者将更新记录混杂在数据记录中,通过新旧版本的形式来支持数据更新,对于旧版本需要定期做vacuum操作来支持磁盘空间的回收。列存支持数据快速分析,更适合OLAP(Online Analytical Processing,联机分析处理)业务。内存引擎支持实时数据处理,对有极致性能要求的业务提供支撑。
4、事务支持
事务支持指的就是系统提供事务的能力,openGauss支持事务的原子性、一致性、隔离性和持久性。事务支持及数据一致性保证是绝大多数数据库的基本功能,只有支持了事务,才能满足事务化的应用需求。 (1)A(Atomicity) :原子性。整个事务中的所有操作,要么全部完成,要么全部不完成,不可能停滞在中间某个环节。 (2)C(Consistency) :一致性。事务需要保证从一个执行性状态转移到另一个一致性状态,不能违反数据库的一致性约束。 (3)I(Isolation) :隔离性。隔离事务的执行状态,使它们好像是系统在给定时间内执行的唯一操作。例如有两个事务并发执行,事务的隔离性将确保每一事务在系统中认为只有该事务在使用系统。 (4)D(Durability) :持久性。在事务提交以后,该事务对数据库所作的更改便持久的保存在数据库之中,不会因掉电,进程异常故障而丢失。openGauss数据库支持事务的隔离级别有读已提交和可重复读,默认隔离级别是读已提交,保证不会读到脏数据。 事务分为隐式事务和显式事务 ,显式事务的相关基础接口如下:(1)Start transaction:事务开启(2)Commit:事务提交(3)Rollback:事务回滚另有用户还可以通过“Set transaction”命令设置事务的隔离级别、读写模式或可推迟模式。
5、软硬结合
openGauss数据库支持软硬件的结合,包括多核的并发访问控制、基于SSD(Solid-State Drive,固态硬盘)的IO(输入/输出Input/Output,)优化、智能的Buffer Pool(缓冲池)数据管理。
6、智能的优化器
openGauss数据库提供了智能的代价模型、智能计划选择,可以显著提升数据库性能。openGauss的执行器包含了向量化执行和LLVM(Low Level Virtual Machine,底层虚拟机,一种构架编译器的框架系统)编译执行,可以显著提升数据库性能。
7、AI的支持
传统数据库生态依赖于DBA(Database Administrator,数据库管理员)进行数据的管理、维护、监控、优化。但是在大量的数据库实例中,DBA难以支持海量实例,而AI(Artificial Intelligence ,人工智能)则可以自动优化数据库,openGauss数据库的AI功能包括AI自调优、AI索引推荐、AI慢SQL诊断等。
8、安全的支持
openGauss数据库具有非常好的安全特性,包括透明加密(即在磁盘的存储文件是加密的)、全密态(数据传输、存储、计算都是加密的)、防篡改(用户不可篡改)、敏感数据智能发现等。
9、函数及存储过程支持
函数和存储过程是数据库中的一种重要对象,主要功能将用户特定功能的SQL语句集进行封装,并方便调用。存储过程是SQL、PL/SQL(Procedural Language SQL,过程语言SQL)的组合。存储过程可以使执行商业规则的代码从应用程序中移动到数据库。从而代码存储一次能够被多个程序使用。(1)允许客户模块化程序设计,对SQL语句集进行封装,调用方便。(2)存储过程会进行编译缓存,可以提升用户执行SQL语句集的速度。(3)系统管理员通过对执行某一存储过程的权限进行限制,能够实现对相应数据访问权限的限制,避免了非授权用户对数据的访问,保证了数据的安全。(4)为了处理SQL语句,存储过程分配一段内存区域来保存上下文。游标是指向上下文区域的句柄或指针。借助游标,存储过程可以控制上下文区域的变化。(5)支持6种异常信息级别方便客户对存储过程进行调试。支持存储过程调试,存储过程调试是一种调试手段,可以在存储过程开发中,一步一步跟踪存储过程执行的流程,根据变量的值,找到错误的原因或者程序的bug,提高问题定位效率。支持设置断点和单步调试。openGauss支持SQL标准中的函数及存储过程,增强了存储过程的易用性。
10、PostgreSQL接口兼容
兼容PSQL客户端,兼容PostgreSQL标准接口。
11、支持SQL hint
支持SQL hint(hints是SQL语句的注释,可以指导优化器选择人为指定的执行计划。)影响执行计划生成、提升SQL查询性能。Plan Hint为用户提供了直接影响执行计划生成的手段,用户可以通过指定Join顺序\Join、Scan方法\指定结果行数\等多个手段来进行执行计划的调优,以提升查询的性能。
12、Copy接口支持容错机制
openGauss数据库提供用户封装好的函数来创建Copy错误表,并允许用户在使用“Copy From”指令时指定容错选项,使得“Copy From”语句在执行过程中部分解析、数据格式、字符集等相关的报错不会报错中断事务、而是被记录至错误表中,使得在“Copy From”的目标文件即使有少量数据错误也可以完成入库操作。用户随后可以在错误表中对相关的错误进行定位以及进一步排查。
二、 应用场景
openGauss数据库主要的应用场景分为:
1、交易型应用
大并发、大数据量、以联机事务处理为主的交易型应用,如电商、金融、O2O、电信CRM/计费等,应用可按需选择不同的主备部署模式。
2、物联网数据
在工业监控和远程控制、智慧城市的延展、智能家居、车联网等物联网场景下,传感监控设备多、采样率高、数据存储为追加模型,操作和分析并重的场景。
三、系统架构
openGauss主要包含了openGauss服务器、客户端驱动、OM等模块,它的架构如图1所示,模块说明如表1所示。|图1 openGauss软件架构| 表1 openGauss模块说明
名称 | 描述 |
---|---|
OM | 运维管理模块(Operation Manager)。提供openGauss日常运维、配置管理的管理接口、工具 |
客户端驱动 | 客户端驱动(Client Driver)。负责接收来自应用的访问请求,并向应用返回执行结果;负责与openGauss实例的通信,下发SQL在openGauss实例上执行,并接收命令执行结果 |
openGauss主(备) | openGauss主(备)。负责存储业务数据(支持行存、列存、内存表存储)、执行数据查询任务以及向客户端驱动返回执行结果 |
Storage | 服务器的本地存储资源,持久化存储数据 |
四、 代码结构
(一)通信管理
openGauss查询响应是使用简单的“单一用户对应一个服务器线程”的客户端/服务器模型实现的。由于我们无法提前知道需要建立多少个连接,因此必须使用主进程(GaussMaster)主进程在指定的TCP/IP(Transmission Control Protocol/Internet Protocol,传输控制协议/互联网协议)端口上侦听传入的连接,只要检测到连接请求,主进程就会生成一个新的服务器线程。服务器线程之间使用信号量和共享内存相互通信,以确保整个并发数据访问期间的数据完整性。客户端进程可以被理解为满足openGauss协议的任何程序。许多客户端都基于C语言库libpq进行通信,但是该协议有几种独立的实现,例如Java JDBC驱动程序。建立连接后,客户端进程可以将查询发送到后端服务器。查询是使用纯文本传输的,即在前端(客户端)中没有进行解析。服务器解析查询语句、创建执行计划、执行并通过在已建立的连接上传输检索到的结果集,将其返回给客户端。openGauss数据库中处理客户端连接请求的模块叫做postmaster。前端程序发送启动信息给postmaster,postmaster根据信息内容建立后端响应线程。postmaster也管理系统级的操作比如调用启动和关闭程序。postmaster在启动时创建共享内存和信号量池,但它自身不管理内存、信号量和锁操作。当客户端发来一个请求信息,postmaster立刻启动一个新会话,新会话对请求进行验证,验证成功后为它匹配后端工作线程。这种模式架构上处理简单,但是高并发下由于线程过多,切换和轻量级锁区域的冲突过大导致性能急剧下降。因此openGauss通过线程资源池化复用的技术来解决该问题。线程池技术的整体设计思想是线程资源池化,并且在不同连接直接复用。
1. postmaster源码组织
postmaster源码目录为:
/src/gausskernel/process/postmaster。postmaster源码文件如表2所示。 表2 postmaster源码文件
postmaster | postmaster.cpp | 用户响应主程序 |
---|---|---|
aiocompleter.cpp | 完成预取(Prefetch)和后端写(BackWrite)I/O操作 | |
alarmchecker.cpp | 闹钟检查线程 | |
lwlockmonitor.cpp | 轻量锁的死锁检测 | |
pagewriter.cpp | 写页面 | |
pgarch.cpp | 日志存档 | |
pgaudit.cpp | 审计线程 | |
pgstat.cpp | 统计信息收集 | |
startup.cpp | 服务初始化和恢复 | |
syslogger.cpp | 捕捉并写所有错误日志 | |
autovacuum.cpp | 垃圾清理线程 | |
bgworker.cpp | 后台工作线程(服务共享内存) | |
bgwriter.cpp | 后台写线程(写共享缓存) | |
cbmwriter.cppremoteservice.cpp | postmaster信号处理 | |
checkpointer.cpp | 检查点处理 | |
fencedudf.cpp | 保护模式下运行用户定义函数 | |
gaussdb_version.cpp | 版本特性控制 | |
twophasecleaner.cpp | 清理两阶段事务线程 | |
walwriter.cpp | 预写式日志写入 |
2. postmaster主流程
postmaster主流程代码如下。
/* postmaster.cpp */...int PostmasterMain(int argc, char* argv[]){InitializePostmasterGUC(); /* 初始化postmaster配置参数*/...pgaudit_agent_init(); /* 初始化审计模块*/...for (i = 0; i < MAXLISTEN; i++) /* 建立输入socket监听*/ t_thrd.postmaster_cxt.ListenSocket[i] = PGINVALID_SOCKET;.../* 建立共享内存和信号量池*/reset_shared(g_instance.attr.attr_network.PostPortNumber);.../* 初始化postmaster信号管理*/gs_signal_slots_init(GLOBAL_ALL_PROCS + EXTERN_SLOTS_NUM);...InitPostmasterDeathWatchHandle(); /* 初始化宕机监听*...pgstat_init(); /* 初始化统计数据收集子系统*/InitializeWorkloadManager(); /* 初始化工作负载管理器*/...InitUniqueSQL(); /* 初始化unique sql资源*/...autovac_init(); /* 初始化垃圾清理线程子系统*/...status = ServerLoop(); /* 启动postmaster主业务循环*/...}
(二) SQL引擎
数据库的SQL引擎是数据库重要的子系统之一,它对上负责承接应用程序发送过来的SQL语句,对下则负责指挥执行器运行执行计划。其中优化器作为SQL引擎中最重要、最复杂的模块,被称为数据库的“大脑”,优化器产生的执行计划的优劣直接决定数据库的性能。
本篇从SQL语句发送到数据库服务器开始,对SQL引擎的各个模块进行全面的介绍与源码解析,以实现对SQL语句执行的逻辑与源码更深入的理解。其响应流程如图1所示。
|图2 openGauss数据库SQL查询响应流程|
1. 查询解析——parser
SQL解析对输入的SQL语句进行词法分析、语法分析、语义分析,获得查询解析树或者逻辑计划。SQL查询语句解析的解析器(parser)阶段包括如下:
(1)词法分析: 从查询语句中识别出系统支持的关键字、标识符、操作符、终结符等,每个词确定自己固有的词性。
(2)语法分析: 根据SQL语言的标准定义语法规则,使用词法分析中产生的词去匹配语法规则,如果一个SQL语句能够匹配一个语法规则,则生成对应的语法树(Abstract Synatax Tree,AST)。
(3)语义分析: 对语法树(AST)进行检查与分析,检查AST中对应的表、列、函数、表达式是否有对应的元数据(指数据库中定义有关数据特征的数据,用来检索数据库信息)描述,基于分析结果对语法树进行扩充,输出查询树。主要检查的内容包括:
①检查关系的使用:FROM子句中出现的关系必须是该查询对应模式中的关系或视图。②检查与解析属性的使用:在SELECT句中或者WHERE子句中出现的各个属性必须是FROM子句中某个关系或视图的属性。③检查数据类型:所有属性的数据类型必须是匹配的。
词法和语法分析代码基于gram.y和scan.l中定义的规则,使用Unix工具bison和flex构建产生。其中,词法分析器在文件scan.l中定义,它负责识别标识符、SQL关键字等。对于找到的每个关键字或标识符,都会生成一个标记并将其传递给解析器。语法解析器在文件gram.y中定义,由一组语法规则和每当触发规则时执行的动作组成,基于这些动作代码构架并输出语法树。在解析过程中,如果语法正确,则进入语义分析阶段并建立查询树返回,否则将返回错误,终止解析过程。
解析器在词法和语法分析阶段仅使用有关SQL语法结构的固定规则来创建语法树。它不会在系统目录中进行任何查找,因此无法理解所请求操作的详细语义。
语法解析完成后,语义分析过程将解析器返回的语法树作为输入,并进行语义分析以了解查询所引用的表、函数和运算符。用来表示此信息的数据结构称为查询树。解析器解析过程分为原始解析与语义分析,分开的原因是,系统目录查找只能在事务内完成,并且我们不希望在收到查询字符串后立即启动事务。原始解析阶段足以识别事务控制命令(BEGIN,ROLLBACK等),然后可以正确执行这些命令而无需任何进一步分析。一旦知道我们正在处理实际查询(例如SELECT或UPDATE),就可以开始事务,这时才调用语义分析过程。
1) parser源码组织
parser源码目录为:/src/common/backend/parser。parser源码文件如表3所示。
表3 parser源码文件
parser parser.cpp 解析主程序 scan.l 词法分析,分解查询成token scansup.cpp 处理查询语句转义符 kwlookup.cpp 将关键词转化为具体的token keywords.cpp 标准关键词列表 analyze.cpp 语义分析 gram.y 语法分析,解析查询tokens并产生原始解析树 parse_agg.cpp 处理聚集操作,比如SUM(col1),AVG(col2) parse_clause.cpp 处理子句,比如WHERE,ORDER BY parse_compatibility.cpp 处理数据库兼容语法和特性支持 parse_coerce.cpp 处理表达式数据类型强制转换 parse_collate.cpp 对完成表达式添加校对信息 parse_cte.cpp 处理公共表格表达式(WITH 子句) parse_expr.cpp 处理表达式,比如col, col+3, x = 3 parse_func.cpp 处理函数,table.column和列标识符 parse_node.cpp 对各种结构创建解析节点 parse_oper.cpp 处理表达式中的操作符 parse_param.cpp 处理参数 parse_relation.cpp 支持表和列的关系处理程序 parse_target.cpp 处理查询解析的结果列表 parse_type.cpp 处理数据类型 parse_utilcmd.cpp 处理实用命令的解析分析
2) parser主流程
parser主流程代码如下。
/* parser.cpp */.../* 原始解析器,输入查询字符串,做词法和语法分析,返回原始语法解析树列表*/List* raw_parser(const char* str, List** query_string_locationlist){... /* 初始化 flex scanner */ yyscanner = scanner_init(str, &yyextra.core_yy_extra, ScanKeywords, NumScanKeywords);... /* 初始化 bison parser */ parser_init(&yyextra);
/* 解析! */ yyresult = base_yyparse(yyscanner);
/* 清理释放内存*/ scanner_finish(yyscanner);... return yyextra.parsetree; }/* analyze.cpp */.../* 分析原始语法解析树,做语义分析并输出查询树 */Query* parse_analyze( Node* parseTree, const char* sourceText, Oid* paramTypes, int numParams, bool isFirstNode, bool isCreateView){ /* 初始化解析状态和查询树 */ ParseState* pstate = make_parsestate(NULL); Query* query = NULL;... /* 将解析树转化为查询树 */ query = transformTopLevelStmt(pstate, parseTree, isFirstNode, isCreateView);... /* 释放解析状态 */ free_parsestate(pstate);... return query;}
2. SQL查询分流——traffic cop
traffic cop模块负责查询的分流,它负责区分简单和复杂的查询指令。事务控制命令(例如BEGIN和ROLLBACK)非常简单,因此不需要其它处理,而其它命令(例如SELECT和JOIN)则传递给重写器(参考第6章)。这种区分通过对简单命令执行最少的优化,并将更多的时间投入到复杂的命令上,从而减少了处理时间。简单和复杂查询指令也对应如下2类解析:
(1)软解析(简单,旧查询):当openGauss共享缓冲区中存在已提交SQL语句的已解析表示形式时,可以重复利用缓存内容执行语法和语义检查,避免查询优化的相对昂贵的操作。
(2)硬解析(复杂,新查询):如果无缓存语句可重用,或者第一次将SQL语句加载到openGauss共享缓冲区中,则会导致硬解析。同样,当一条语句在共享缓冲区中老化时,再次重新加载该语句时,还会导致另一次硬解析。因此,共享Buffer的大小也会影响解析调用的数量。
我们可以查询gs_prepared_statements来查看缓存了什么,以区分软/硬解析(它仅对当前会话可见)。此外,gs_buffercache模块提供了一种实时检查共享缓冲区高速缓存内容的方法,它甚至可以分辨出有多少数据块来自磁盘,有多少数据来自共享缓冲区。
1) traffic cop(tcop)源码组织
traffic cop(tcop)源码目录为:
/src/common/backend/tcop。traffic cop(tcop)源码文件如表4所示。
表4 traffic cop(tcop)源码文件
tcop auditfuncs.cpp 记录数据库操作审计信息 autonomous.cpp 创建可被用来执行SQL查询的自动会话 dest.cpp 与查询结果被发往的终点通信 utility.cpp 数据库通用指令控制函数 fastpath.cpp 在事务期间缓存操作函数和类型等信息 postgres.cpp 后端服务器主程序 pquery.cpp 查询处理指令 stmt_retry.cpp SQL语句错误错误码并重试
2) traffic cop主流程
traffic cop主流程代码如下。
/*postgres.cpp*/.../*原始解析器,输入查询字符串,做词法和语法分析,返回原始解析树列表*/int PostgresMain(int argc, char* argv[], const char* dbname, const char* username){... /* 整体初始化*/ t_thrd.proc_cxt.PostInit->SetDatabaseAndUser(dbname, InvalidOid, username); ... /* 自动事务的错误处理 */ if (sigsetjmp(local_sigjmp_buf, 1) != 0) { ... }.../* 错误语句的重新尝试阶段 */if (IsStmtRetryEnabled() && StmtRetryController->IsQueryRetrying()){ ... } /* 无错误查询指令循环处理*/ for (;;) {.../* 按命令类型执行处理流程*/switch(firstchar){...
case: 'Q': ... /* 简单查询 */case: 'P': ... /* 解析 */case: 'E': ... /* 执行 */ }... } ...}
3. 查询重写——rewriter
查询重写利用已有语句特征和关系代数运算来生成更高效的等价语句,在数据库优化器中扮演关键角色;尤其在复杂查询中,能够在性能上带来数量级的提升,可谓是“立竿见影”的“黑科技”。SQL语言是丰富多样的,非常的灵活,不同的开发人员依据经验的不同,手写的SQL语句也是各式各样,另外还可以通过工具自动生成。同时SQL语言是一种描述性语言,数据库的使用者只是描述了想要的结果,而不关心数据的具体获取方式,输入数据库的SQL语言很难做到是以最优形式表示的,往往隐含了一些冗余信息,这些信息可以被挖掘用来生成更加高效的SQL语句。查询重写就是把用户输入的SQL语句转换为更高效的等价SQL,查询重写遵循2个基本原则:
(1)等价性:原语句和重写后的语句,输出结果相同。
(2)高效性:重写后的语句,比原语句在执行时间和资源使用上更高效。
介绍openGauss如下几个关键的查询重写技术:
(1)常量表达式化简:常量表达式即用户输入SQL语句中包含运算结果为常量的表达式,分为算数表达式、逻辑运算表达式、函数表达式。查询重写可以对常量表达式预先计算以提升效率。例如“SELECT * FROM table WHERE a=1+1; ”语句被重写为“SELECT * FROM table WHERE a=2”语句。
(2)子查询提升:由于子查询表示的结构更清晰,符合人的阅读理解习惯,用户输入的SQL语句往往包含了大量的子查询,但是相关子查询往往需要使用嵌套循环的方法来实现,执行效率较低,因此将子查询优化为“Semi Join”的形式可以在优化规划时选择其它的执行方法,或能提高执行效率。例如“SELECT * FROM t1 WHERE t1.a in (SELECT t2.a FROM t2); ”语句可重写为“SELECT * FROM t1 LEFT SEMI JOIN t2 ON t1.a=t2.a”语句。
(3)谓词下推:谓词(Predicate),通常为SQL语句中的条件,例如“SELECT * FROM t1 WHERE t1.a=1; ”语句中的“t1.a=1”即为谓词。等价类(Equivalent-Class)是指等价的属性、实体等对象的集合,例如“WHERE t1.a=t2.a”语句中,t1.a和t2.a互相等价,组成一个等价类{t1.a,t2.a}。利用等价类推理(又称作传递闭包),我们可以生成新的谓词条件,从而达到减小数据量和最大化利用索引的目的。如图2所示,我们举一个形象的例子来说明谓词下推的威力。假设有两个表t1、t2;它们分别包含[1,2,3,..100]共100行数据,那么查询语句“SELECT * FROM t1 JOIN t2 ON t1.a=t2.a WHERE t1.a=1”的逻辑计划在经过查询重写前后的对比。
|图3 查询重写前后对比|
查询重写的主要工作在优化器中实现,除此之外,openGauss还提供了基于规则的rewrite接口,用户可以通过创建替换规则的方法对逻辑执行计划进行改写。例如视图展开功能即通过rewrite模块中的规则进行替换,而视图展开的规则是在创建视图的过程中默认创建的。
1) rewriter源码组织
rewriter源码目录为:
/src/gausskernel/optimizer/rewrite。rewriter源码文件如表5所示。
表5 rewriter源码文件
rewrite rewriteDefine.cpp 定义重写规则 rewriteHandler.cpp 重写主模块 rewriteManip.cpp 重写操作函数 rewriteRemove.cpp 重写规则移除函数 rewriteRlsPolicy.cpp 重写行粒度安全策略 rewriteSupport.cpp 重写辅助函数
2) rewriter主流程
rewriter主流程代码如下。
/* rewrite.cpp */.../* 查询重写主函数 */List* QueryRewrite(Query* parsetree){... /* 应用所有non-SELECT规则获取改写查询列表 */ querylist = RewriteQuery(parsetree, NIL); /* 对每个改写查询应用RIR规则 */ results = NIL; foreach (l, querylist) { Query* query = (Query*)lfirst(l); query = fireRIRrules(query, NIL, false); query->queryId = input_query_id; results = lappend(results, query); } /* 从重写列表确定一个重写结果 */ origCmdType = parsetree->commandType; foundOriginalQuery = false; lastInstead = NULL; foreach (l, results) {...} ... return results;}
4. 查询优化——optimizer
优化器(optimizer)的任务是创建最佳执行计划。一个给定的SQL查询(以及一个查询树)实际上可以以多种不同的方式执行,每种方式都会产生相同的结果集。如果在计算上可行,则查询优化器将检查这些可能的执行计划中的每一个,最终选择预期运行速度最快的执行计划。
在某些情况下,检查执行查询的每种可能方式都会占用大量时间和内存空间,特别是在执行涉及大量连接操作(Join)的查询时。为了在合理的时间内确定合理的(不一定是最佳的)查询计划,当查询连接数超过阈值时,openGauss使用遗传查询优化器(genetic query optimizer),通过遗传算法来做执行计划的枚举。
优化器的查询计划(plan)搜索过程实际上与称为路径(path)的数据结构一起使用,该路径只是计划的简化表示,其中仅包含确定计划所需的关键信息。确定代价最低的路径后,将构建完整的计划树以传递给执行器。这足够详细地表示了所需的执行计划,供执行者运行。在本节的其余部分,我们将忽略路径和计划之间的区别。
1) 生成查询计划
首先,优化器会生成查询中使用的每个单独关系(表)的计划。候选计划由每个关系上的可用索引确定。对关系的顺序扫描是最基本的方法,因此总是会创建顺序扫描计划。假设在关系上定义了索引(例如B树索引),并且查询属性恰好与B树索引的键匹配,则使用B树索引创建另一个基于索引的查询计划。如果还存在其它索引并且查询中的限制恰好与索引的关键字匹配,则将考虑其它计划生成更多计划。
其次,如果查询需要连接两个或多个关系,则在找到所有可行的扫描单个关系的计划之后,将考虑连接关系的计划。连接关系有3种可用的连接策略:
(1)嵌套循环连接: 对在左关系中找到的每一行,都会扫描一次右关系。此策略易于实施,但非常耗时。(但是,如果可以使用索引扫描来扫描右关系,则这可能是一个不错的策略。可以将左关系的当前行中的值用作右索引扫描的键。)
(2)合并连接: 在开始连接之前,对每个关系对连接属性进行排序。然后,并行扫描这两个关系,并组合匹配的行以形成连接行。这种连接更具吸引力,因为每个关系只需扫描一次。所需的排序可以通过明确的排序步骤来实现,也可以通过使用连接键上的索引以正确的顺序扫描关系来实现。
(3)哈希连接: 首先将正确的关系扫描并使用其连接属性作为哈希键加载到哈希表中。接下来,扫描左关系,并将找到的每一行的适当值用作哈希键,以在表中找到匹配的行。
当查询涉及2个以上的关系时,最终结果必须由构建连接树来确定。优化器检查不同的可能连接顺序以找到代价最低的连接顺序。
如果查询所使用的关系数目较少(少于启动启发式搜索阈值),那么将进行近乎穷举的搜索以找到最佳连接顺序。优化器优先考虑存在WHERE限定条件子句中的2个关系之间的连接(即存在诸如rel1.attr1 = rel2.attr2之类的限制),最后才考虑不具有Join子句的连接对。优化器会对每个连接操作生成所有可能的执行计划,然后选择(估计)代价最低的那个。当连接表数目超过geqo_threshold时,所考虑的连接顺序由geqo启发式方法确定。
完成的计划树包括对基础关系的顺序或索引扫描,以及根据需要的嵌套循环、合并、哈希连接节点和其它辅助步骤,例如排序节点或聚合函数计算节点。这些计划节点类型中的大多数具有执行选择(丢弃不满足指定布尔条件的行)和投影(基于给定列值计算派生列集,即执行标量表达式的运算)的附加功能。优化器的职责之一是将WHERE子句中的选择条件附加起来,并将所需的输出表达式安排到计划树的最适当节点上。
2) 查询计划代价估计
openGauss的优化器是基于代价的优化器,对每条SQL语句生成的多个候选的计划,优化器会计算一个执行代价,最后选择代价最小的计划。
通过统计信息,代价估算系统就可以了解一个表有多少行数据、用了多少个数据页面、某个值出现的频率等,以确定约束条件过滤出的数据占总数据量的比例,即选择率。当一个约束条件确定了选择率之后,就可以确定每个计划路径所需要处理的行数,并根据行数可以推算出所需要处理的页面数。当计划路径处理页面的时候,会产生IO代价。而当计划路径处理元组的时候(例如针对元组做表达式计算),会产生CPU代价。由于openGauss是单机数据库,无服务器节点间传输数据(元组)会产生通信的代价,因此一个计划的总体代价可以表示为:
总代价 = IO代价 + CPU代价
openGauss把所有顺序扫描一个页面的代价定义为单位1,所有其它算子的代价都归一化到这个单位1上。比如把随机扫描一个页面的代价定义为4,即认为随机扫描一个页面所需代价是顺序扫描一个页面所需代价的4倍。又比如,把CPU处理一条元组的代价为0.01,即认为CPU处理一条元组所需代价为顺序扫描一个页面所需代价的百分之一。
从另一个角度来看,openGauss将代价又分成了启动代价和执行代价,其中:
总代价 = 启动代价 + 执行代价
(1)启动代价
从SQL语句开始执行,到此算子输出第一条元组为止,所需要的代价,称为启动代价。有的算子启动代价很小,比如基表上的扫描算子,一旦开始读取数据页,就可以输出元组,因此启动代价为0。有的算子的启动代价相对较大,比如排序算子,它需要把所有下层算子的输出全部读取到,并且把这些元组排序之后,才能输出第一条元组,因此它的启动代价比较大。
(2)执行代价
从输出第一条算子开始,至查询结束,所需要的代价,称为执行代价。这个代价中又可以包含CPU代价、IO代价,执行代价的大小与算子需要处理的数据量有关,也与每个算子完成的功能有关。处理的数据量越大、算子需要完成的任务越重,则执行代价越大。
(3)总代价
如图3所示示例,查询中包含两张表,分别命名为t1、t2。t1与t2进行join,并且对c1列做聚集。
|图4 代价计算示例|
示例中涉及的代价包括:
(1)扫描t1的启动代价为0,总代价为13.13。13.13的意思是“总代价相当于顺序扫描13.13个页面”,t2表的扫描同理。
(2)此计划的Join方式为Hash Join,使用Hash Join时,必须先对一个子节点的所有数据建立Hash表,再依次使用另一个子节点的每一条元组尝试与Hash Join中的元组进行匹配。因此Hash Join的启动代价包括了建立Hash表的代价。
此计划中Hash Join的启动代价为13.29,对某个结果集建立Hash表时,必须拿到此结果集的所有数据,因此13.29比下层扫描算子的代价13.13大。
此计划中Hash Join的总代价为28.64。
(3)Join完毕之后,需要做聚集运算,此计划中的聚集使用了HashAGG算子,此算子需要对Join的结果集上以c1列作为Hash Key建立Hash表,因此它的启动代价又包含了一个建立Hash表的代价。聚集操作的启动代价为28.69,总代价为28.79。
3) optimizer源码组织
optimizer源码目录为:
/src/gausskernel/optimizer。optimizer源码文件如表6所示。
表6 optimizer源码文件
plan analyzejoins.cpp 初始化查询后的连接简化 createplan.cpp 创建查询计划 dynsmp_single.cpp SMP自适应接口函数 planner.cpp 查询优化外部接口 planrecursive_single.cpp 查询优化外部接口 planrewrite.cpp 基于代价的查询重写 setrefs.cpp 完成的查询计划树的后处理(修复子计划变量引用) initsplan.cpp 目标列表、限定条件和连接信息初始化 pgxcplan_single.cpp 简单查询的旁路执行器 planagg.cpp 聚集查询的特殊计划 planmain.cpp 计划主函数:单个查询的计划 streamplan_single.cpp 流计划相关函数 subselect.cpp 子选择和参数的计划函数 path allpaths.cpp 查找所有可能查询执行路径 clausesel.cpp 子句选择性计算 costsize.cpp 计算关系和路径代价 pathkeys.cpp 匹配并建立路径键的公用函数 pgxcpath_single.cpp 查找关系和代价的所有可能远程查询路径 streampath_single.cpp 并行处理的路径生成 tidpath.cpp 确定扫描关系TID条件并创建对应TID路径 equivclass.cpp 管理等价类 indxpath.cpp 确定可使用索引并创建对应路径 joinpath.cpp 查找执行一组join操作的所有路径 joinrels.cpp 确定需要被连接的关系 orindxpath.cpp 查找匹配OR子句集的索引路径
4) optimizer主流程
optimizer主流程代码如下。
/* planmain.cpp */.../**优化器主函数*生成基本查询的路径(最简化的查询计划)*输入参数:*root:描述需要计划的查询*tlist: 查询生成的目标列表*tuple_fraction: 被抽取的元组数量比例*limit_tuples: 抽取元组数量的数量限制*输出参数:*cheapest_path: 查询整体上代价最低的路径*sorted_path: 排好序的代价最低的数个路径*num_groups: 估计组的数量(返回1如果查询不使用group运算)*/
void query_planner(PlannerInfo* root, List* tlist, double tuple_fraction, double limit_tuples,query_pathkeys_callback qp_callback, void *qp_extra, Path** cheapest_path, Path** sorted_path, double* num_groups, List* rollup_groupclauses, List* rollup_lists){.../* 空连接树简单query 快速处理 */if (parse->jointree->fromlist == NIL) {...return;}setup_simple_rel_arrays(root); /* 获取线性版的范围表,加速读取 *//* 为基础关系建立RelOptInfo节点 */add_base_rels_to_query(root, (Node*)parse->jointree);check_scan_hint_validity(root);/* 向目标列表添加条目,占位符信息生成,最后形成连接列表 */ build_base_rel_tlists(root, tlist);find_placeholders_in_jointree(root); joinlist = deconstruct_jointree(root);reconsider_outer_join_clauses(root); /* 基于等价类重新考虑外连接*//* 对等价类生成额外的限制性子句 */ generate_base_implied_equalities(root); generate_base_implied_qualities(root);(*qp_callback) (root, qp_extra); /* 将完整合并的等价类集合转化为标准型 */fix_placeholder_input_needed_levels(root); /* 检查占位符表达式 */joinlist = remove_useless_joins(root, joinlist); /* 移除无用外连接 */add_placeholders_to_base_rels(root); /* 将占位符添加到基础关系 *//* 对每个参与查询表的大小进行估计,计算total_table_pages */total_pages = 0; for (rti = 1; rti < (unsigned int)root->simple_rel_array_size; rti++){...}root->total_table_pages = total_pages;/* 准备开始主查询计划 */final_rel = make_one_rel(root, joinlist); final_rel->consider_parallel = consider_parallel;... /* 如果有分组子句,估计结果组数量 */if (parse->groupClause) {...} /* 如果有分组子句,估计结果组数量 */else if (parse->hasAggs||root->hasHavingQual||parse->groupingSets){...} /* 非分组聚集查询读取所有元组 */else if (parse->distinctClause) {...} /* 非分组非聚集独立子句估计结果行数 */else {...} /* 平凡非分组非聚集查询,计算绝对的元组比例 *//* 计算代价整体最小路径和预排序的代价最小路径 */cheapestpath = get_cheapest_path(root, final_rel, num_groups, has_groupby);...*cheapest_path = cheapestpath; *sorted_path = sortedpath;}
5. 查询执行——executor
执行器(executor)采用优化器创建的计划,并对其进行递归处理以提取所需的行的集合。这本质上是一种需求驱动的流水线执行机制。即每次调用一个计划节点时,它都必须再传送一行,或者报告已完成传送所有行。
|图5 执行计划树示例|
如图4所示的执行计划树示例,顶部节点是Merge Join节点。在进行任何合并操作之前,必须获取2个元组(MergeJoin节点的2个子计划各返回1个元组)。因此,执行器以递归方式调用自身以处理其子计划(如从左子树的子计划开始)。Merge Join由于要做归并操作,因此它要子计划按序返回元组,从图4可以看出,它的子计划是一个Sort节点。Sort的子节点可能是Seq Scan节点,代表对表的实际读取。执行SeqScan节点会使执行程序从表中获取一行并将其返回到调用节点。Sort节点将反复调用其子节点以获得所有要排序的行。当输入完毕时(如子节点返回NULL而不是新行),Sort算子对获取的元组进行排序,它每次返回1个元组,即已排序的第1行。然后不断排序并向父节点传递剩余的排好序的元组。
Merge Join节点类似地需要获得其右侧子计划中的第1个元组,看是否可以合并。如果是,它将向其调用方返回1个连接行。在下1次调用时,或者如果它不能连接当前输入对,则立即前进到1个表或另1个表的下1行(取决于比较的结果),然后再次检查是否匹配。最终,1个或另1个子计划用尽,并且Merge Join节点返回NULL,以指示无法再形成更多的连接行。
复杂的查询可能涉及多个级别的计划节点,但是一般方法是相同的:每个节点都会在每次调用时计算并返回其下1个输出行。每个节点还负责执行优化器分配给它的任何选择或投影表达式。
执行器机制用于执行所有4种基本SQL查询类型:SELECT、INSERT、UPDATE和DELETE。
(1)对于SELECT,顶级执行程序代码仅需要将查询计划树返回的每一行发送给客户端。(2)对于INSERT,每个返回的行都插入到为INSERT指定的目标表中。这是在称为ModifyTable的特殊顶层计划节点中完成的。(1个简单的“INSERT ... VALUES”命令创建了1个简单的计划树,该树由单个Result节点组成,该节点仅计算一个结果行,并传递给ModifyTable树节点实现插入)。(3)对于UPDATE,优化器对每个计算的更新行附着所更新的列值,以及原始目标行的TID(元组ID或行ID);此数据被馈送到ModifyTable节点,并使用该信息来创建新的更新行并标记旧行已删除。(4)对于DELETE,计划实际返回的唯一列是TID,而ModifyTable节点仅使用TID访问每个目标行并将其标记为已删除。
执行器的主要处理控制流程如下:
(1)创建查询描述。
(2)查询初始化: 创建执行器状态(查询执行上下文)、执行节点初始化(创建表达式与每个元组上下文、执行表达式初始化)。
(3)查询执行: 执行处理节点(递归调用查询上下文、执行表达式,然后释放内存,重复操作)。
(4)查询完成; 执行未完成的表格修改节点。
(5)查询结束: 递归释放资源、释放查询及其子节点上下文。
(6)释放查询描述。
1) executor源码组织
executor源码目录为:
/src/gausskernel/runtime/executor。executor源码文件如表7所示。
表7 executor源码文件
executor execAmi.cpp 各种执行器访问方法 execClusterResize.cpp 集群大小调整 execCurrent.cpp 支持WHERE CURRENT OF execGrouping.cpp 支持分组、哈希和聚集操作 execJunk.cpp 伪列的支持 execMain.cpp 顶层执行器接口 execMerge.cpp 处理MERGE指令 execParallel.cpp 支持并行执行 execProcnode.cpp 分发函数按节点调用相关初始化等函数 execQual.cpp 评估资质和目标列表表达式 execScan.cpp 通用的关系扫描 execTuples.cpp 元组相关的资源管理 execUtils.cpp 多种执行相关工具函数 functions.cpp 执行SQL语言函数 instrument.cpp 计划执行工具 lightProxy.cpp 轻量级执行代理 node*.cpp 处理*相关节点操作的函数 opfusion.cppopfusion_util.cppopfusion_scan.cpp 旁路执行器:处理简单查询 spi.cpp 服务器编程接口 tqueue.cpp 并行后端之间的元组信息传输 tstoreReceiver.cpp 存储结果元组
2)executor主流程executor主流程代码为。
/* execMain.cpp */.../* 执行器启动 */void ExecutorStart(QueryDesc *queryDesc, int eflags){ gstrace_entry(GS_TRC_ID_ExecutorStart); if (ExecutorStart_hook) { (*ExecutorStart_hook)(queryDesc, eflags); } else { standard_ExecutorStart(queryDesc, eflags); } gstrace_exit(GS_TRC_ID_ExecutorStart);}/* 执行器运行 */void ExecutorRun(QueryDesc *queryDesc, ScanDirection direction, long count){... /* SQL 自调优: 查询执行完毕时,基于运行时信息分析查询计划问题 */ if (u_sess->exec_cxt.need_track_resource && queryDesc != NULL && has_track_operator &&(IS_PGXC_COORDINATOR || IS_SINGLE_NODE)) { List *issue_results = PlanAnalyzerOperator(queryDesc, queryDesc->planstate); /* 如果查询问题找到,存在系统视图 gs_wlm_session_history */ if (issue_results != NIL) { RecordQueryPlanIssues(issue_results); } } /* 查询动态特征, 操作符历史统计信息 */ if (can_operator_history_statistics) { u_sess->instr_cxt.can_record_to_table = true; ExplainNodeFinish(queryDesc->planstate, queryDesc->plannedstmt, GetCurrentTimestamp(), false); ... }}/* 执行器完成 */void ExecutorFinish(QueryDesc *queryDesc){ if (ExecutorFinish_hook) { (*ExecutorFinish_hook)(queryDesc); } else { standard_ExecutorFinish(queryDesc); }}/* 执行器结束 */void ExecutorEnd(QueryDesc *queryDesc){ if (ExecutorEnd_hook) { (*ExecutorEnd_hook)(queryDesc); } else { standard_ExecutorEnd(queryDesc); }}
复制代码