更新时间:2026-07-28 GMT+08:00
分享

代价估算

对于优化器来说,为了能够选出最优的执行计划,需要有可以衡量执行计划的标准。一个直观的比较不同查询计划优劣的标准即执行时间,一般而言,执行时间越短的查询计划越优,反之越差。然而同一个查询计划的执行时间会随着环境变量的不同而变化,如:机器配置(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的部分计算实际已经包含了启动代价的值,所以可以看到总代价和执行代价是一致的。

相关文档