代价估算
对于优化器来说,为了能够选出最优的执行计划,需要有可以衡量执行计划的标准。一个直观的比较不同查询计划优劣的标准即执行时间,一般而言,执行时间越短的查询计划越优,反之越差。然而同一个查询计划的执行时间会随着环境变量的不同而变化,如:机器配置(CPU 的频率、内存的大小和磁盘的介质等)会对实际执行性能产生影响,那么就需要一个无量纲的值来代替时间,在GaussDB中使用代价(Cost)这一概念来判断不同执行计划的优劣。代价是一个用于评判执行计划优劣的绝对指标,是一个估算值,而每个算子的代价都有其对应的计算公式,该公式被称为代价模型。通过将统计信息作为输入,代入到代价模型中,即可计算出相应算子的代价,可抽象为如下公式:

如上述公式所示,每个算子的代价模型是对其执行逻辑的抽象,对于遇到的每一个可被作为基本计算单位量的操作(如访问页面、表达式运算等),都需要将其以代价为单位进行去量纲化的表示。以顺序扫描为例,其执行逻辑为:从文件中顺序读取页面,在没有谓词的情况下,顺序扫描的代价即扫描所有页面的代价,这里需要对顺序访问单个页面操作的代价进行定义,并作为计算顺序扫描多个页面的单位量。
为定义不同影响因素的评估基准,把一些影响执行效率的关键因素提取出来,抽象成标准值,所有路径的代价都基于标准值去计算。例如,将CPU频率参数、磁盘页面I/O方式、内存I/O方式进行标准化作为代价估算的基准,下面对磁盘I/O基准代价与CPU基准代价进行说明。
- 磁盘页面I/O的基准代价
磁盘页面的I/O方式通常包含:顺序读写和随机读写。以传统的机械硬盘为例,因为机械硬盘在读取数据的时候需要进行寻道,寻道要花费一定时间,因而随机读写就会有大量时间浪费到寻道上,顺序读写则能够节省这些寻道时间,带来I/O性能提升。另外磁盘中通常有缓存,顺序读写时磁盘可以预取数据,提升缓存命中率,相比随机读写也能带来性能的提升。
基于上述读写方式的不同,需要对顺序读写和随机读写定义不同的代价基准,在GaussDB的早期版本中,定义顺序读写的默认值是1.0,随机读写的默认值是4.0。但是随着固态硬盘的普及,固态硬盘随机读写的性能已经大大提高,该基准值已经不再准确,因此在最新的版本中,已经将随机读写的默认值调整为1.1。在不同的生产环境中这两个基准值也并非一成不变,因此GaussDB支持GUC参数根据具体环境进行修改。调整顺序读写页面基准值的GUC参数是seq_page_cost,调整随机读写页面基准值的GUC参数是random_page_cost。
此外,由于数据库本身有缓存系统,如果数据已经在缓存中就可以避免磁盘I/O的开销,这样在进行代价评估时不能简单对每个页面都计算磁盘I/O代价。所以,在进行代价评估时还需要对缓存页面数做一个预估,该值由GUC参数effective_cache_size控制。
- 元组与约束条件的CPU基准代价
数据从磁盘页面中被读取出来后,还需要以元组的形式呈现出来,因此需要依赖CPU的计算资源,这时会产生CPU的计算代价。由于元组的组织结构差异、表达式计算复杂度差异等原因,其消耗的计算资源也是不同的,因此,在GaussDB中主要区分了普通元组、索引元组以及对投影、表达式等不同数据组织形式的CPU计算代价,并且支持通过GUC参数调整这些基准代价:
控制普通元组CPU基准的GUC参数:cpu_tuple_cost,默认值为0.01。 控制索引元组CPU基准的GUC参数:cpu_index_tuple_cost,默认值为0.005。 控制表达式CPU基准的GUC参数:cpu_operator_cost,默认值为0.0025。
除了上述基准代价,GaussDB在进行代价比较时通常还要关注启动代价和总体代价。启动代价指的是SQL语句从开始执行到执行器查询获取到第一条元组的代价,而总代价则是指SQL语句从开始执行到结束的所有代价。因此,有如下公式:
总代价(total_cost)= 启动代价(startup_cost) + 运行代价(run_cost)
对于不同的扫描路径和连接路径,其启动代价通常不同,因此需要单独进行评估,以下面的场景为例,建表语句如下:
gaussdb=# CREATE TABLE t1(a INT, b INT, c INT, d INT); NOTICE: The 'DISTRIBUTE BY' clause is not specified. Using 'a' as the distribution column by default. HINT: Please use 'DISTRIBUTE BY' clause to specify suitable data distribution column. CREATE TABLE gaussdb=# CREATE INDEX idx_t1_a ON t1(a); CREATE INDEX gaussdb=# INSERT INTO t1 (a, b, c) VALUES (generate_series(1, 5000), generate_series(1, 5000), floor(random()*10)); INSERT 0 5000 gaussdb=# ANALYZE t1; ANALYZE gaussdb=# SET explain_perf_mode=normal; SET
执行相同SQL语句,生成不同的路径计划:
gaussdb=# EXPLAIN SELECT * FROM t1 WHERE t1.a >= 100 ORDER BY a; QUERY PLAN ------------------------------------------------------------------------------ Streaming (type: GATHER) (cost=4.00..252.12 rows=4900 width=16) Merge Sort Key: a Node/s: All datanodes -> Index Scan using idx_t1_a on t1 (cost=0.00..47.99 rows=4900 width=16) Index Cond: (a >= 100) (5 rows) gaussdb=# EXPLAIN SELECT /*+tablescan(t1)*/* FROM t1 WHERE t1.a >= 100 ORDER BY a; QUERY PLAN -------------------------------------------------------------------- Streaming (type: GATHER) (cost=119.99..324.19 rows=4899 width=16) Merge Sort Key: a Node/s: All datanodes -> Sort (cost=115.99..120.07 rows=4899 width=16) Sort Key: a -> Seq Scan on t1 (cost=0.57..28.84 rows=4900 width=16) Filter: (a >= 100) (7 rows) gaussdb=# DROP TABLE t1; DROP TABLE gaussdb=# RESET explain_perf_mode; RESET上述示例中,可以看到同一条SQL语句在生成不同路径计划时启动代价的差异。第一次执行时选择了索引路径,因为这条语句需要排序,索引路径底层使用B+树能够快速定位到目标数据并且可以保证有序性,其启动代价为0。第二次执行选择的是顺序扫描路径,需要遍历磁盘找到第一条有效数据,启动代价为0.57,再叠加排序操作导致其整体代价高于索引扫描路径。对于本场景中的示例在默认情况下,数据库会根据计划选择策略淘汰掉顺序扫描+排序操作的计划,这里为了便于对比使用tablescan的HINT强制选择了顺序扫描计划。
结合上述示例,顺序扫描路径的代价计算公式为:
startup_cost(启动代价) = (cpu_tuple_cost+cpu_expr_cost) *M<tuple>+seq_page_cost*M<page> run_cost(运行代价)=cpu_run_cost + disk_run_cost=(cpu_tuple_cost+cpu_expr_cost) *N<tuple>+seq_page_cost*N<page>
其中 seq_page_cost、 cpu_tuple_cost 的值来自前面基准代价的参数配置,本例中分别为:1.0和0.01,N<tuple> 和 N<page> 分别表示表中的元组总数与页面总数,可以使用以下SQL查询获取:
gaussdb=# SELECT reltuples, relpages FROM pg_class WHERE relname='t1'; reltuples | relpages -----------+---------- 5000 | 24 (1 row)cpu_expr_cost的值来自WHERE条件中的所有表达式计算代价和,本例中的表达式只包含t1.a >= 100 条件,t1表的a列是INT类型,表达式计算函数为GE(大于等于),因此可以通过如下SQL获取到其计算基准代价系数:
gaussdb=# SELECT proname, procost FROM pg_proc WHERE proname = 'int4ge'; proname | procost ---------+--------- int4ge | 1 (1 row)
表达式类型的基准代价由GUC参数cpu_operator_cost控制,本例中的默认值是0.0025。将这些信息带入公式计算得到如下结果:
run_cost(运行代价)= (cpu_tuple_cost+cpu_expr_cost) *N<tuple>+seq_page_cost*N<page> = (0.01+1x0.0025)*5000+1.0*24 = 86.5但是上面的示例中,实际代价显示的却是28.84, 这是因为用例场景的数据库有三个DN节点,可以使用如下SQL查询CN和DN的节点信息:
gaussdb=# SELECT node_name, node_type, hostis_primary FROM PGXC_NODE; node_name | node_type | hostis_primary --------------+-----------+---------------- coordinator1 | C | t datanode1 | D | t datanode2 | D | t datanode3 | D | t (4 rows)
如上查询结果所示,t1表的数据均匀分布在三个数据节点上,因此对于每个DN节点的代价需要取平均值,即:86.5/3=28.84。
启动代价的计算方式如下,因为这里选择的是顺序扫描的路径,即遍历整个数据表,根据启动代价的定义并且结合数据均匀分布假设,可以认为这5000条数据均匀的分布在24个页面上,获取到第一条有用数据的代价为:
startup_cost(启动代价)= (cpu_tuple_cost+cpu_expr_cost) *M<tuple>+seq_page_cost*M<page> = (0.01+1*0.0025) *100+1.0*(24/5000*100) = 1.25+0.48 = 1.73同理,因为数据均匀分布在3个DN节点上,因此对于一个节点而言,其启动代价为1.73/3=0.57。
因为顺序扫描本身就要扫描整个表,run_cost的部分计算实际已经包含了启动代价的值,所以可以看到总代价和执行代价是一致的。