目 录CONTENT

文章目录

【GaussDB】Plan Trace 深度分析-CPlan 与 GPlan 谓词推入差异的完整复现与解读

DarkAthena
2026-06-23 / 0 评论 / 0 点赞 / 6 阅读 / 0 字

GaussDB Plan Trace 深度分析

CPlan 与 GPlan 谓词推入差异的完整复现与解读

基于 GaussDB Kernel 506.0.0.SPC0100 实际测试 | 2026 年 6 月
这可能是你能看到的第一篇公开的、包含真实解读GaussDB Plan Trace的文章。


目录

  • 一、背景与问题现象
  • 二、测试用例设计
  • 三、Plan Trace 获取方法
  • 四、CPlan 与 GPlan 执行计划对比
  • 五、Plan Trace 深度解读——基数估算阶段
  • 六、路径生成与参数化路径——核心差异解析
  • 七、Hint 对谓词推入的影响
  • 八、成本模型详解
  • 九、CPlan 到 GPlan 的自动切换机制
  • 十、查询重写阶段分析
  • 十一、Join 搜索顺序分析
  • 十二、完整可复现步骤与总结
  • 十三、总结与建议

一、背景与问题现象

1.1 问题来源

先看一个比较有意思的案例。在某次 GaussDB 性能排查中,一条 PBE(Prepare-Bind-Execute)模式的 INSERT ... SELECT 语句,前 5 次执行使用 CPlan(Custom Plan),执行计划中 T3 表的扫描只使用了 3 个索引条件;但第 6 次执行时,优化器自动切换到 GPlan(Generic Plan),T3 的索引条件突然多了一个 join 条件的推入,成本从 660 骤降到 270。

也就是说,同一条 SQL,仅仅因为执行次数触发了 CPlan 到 GPlan 的切换,执行计划就发生了质的变化。更关键的是,GPlan 的执行计划明显更优——谓词推入让 T3 的索引扫描更精确,减少了不必要的行过滤。

问题来了:为什么 CPlan 不做谓词推入?GPlan 又是怎么做到的?这个问题涉及到 GaussDB 优化器中 PBE 场景下参数化路径(Parameterized Path)的选择逻辑,值得深入分析。

1.2 现象描述

以下是简化后的 SQL 结构(字段名和对象名已脱敏):

PREPARE ptdemo_trace AS
INSERT INTO fee_calculation_result
  (biz_date, market_code, clearing_group,
   contract_code, side_flag, hedge_type,
   discount_pct, fee_rate, fee_per_lot)
SELECT $1, t1.market_code, t1.clearing_group,
       t2.contract_code,
       t1.side_flag, t1.hedge_type,
       t1.discount_pct, t1.fee_rate, t1.fee_per_lot
FROM trade_position_record t1,
     contract_specification t2,
     active_contract_snapshot t3
WHERE t1.market_code = t2.market_code
  AND t1.clearing_group = t2.clearing_group
  AND t1.market_code = t3.market_code
  AND t1.clearing_group = t3.clearing_group
  AND t1.asset_code = t2.asset_code
  AND t1.series_code = t2.base_series
  AND t1.biz_date = t2.biz_date
  AND t2.contract_code = t3.contract_code
  AND t3.lifecycle_stage IN ('0','1','2')
  AND t1.market_code = $2
  AND t1.asset_code = $3
  AND t1.clearing_group = $4
  AND t1.series_code = $5
  AND t1.cycle_tag = $6
  AND t1.biz_date = $7
  AND t3.biz_date = $8;

这条 SQL 涉及三表 Join(t1-t2-t3),所有表都有索引,连接方式被 Hint 限制为 NestLoop(关闭了 Hash Join 和 Merge Join)。核心差异在于 t2 和 t3 之间的连接条件 t2.contract_code = t3.contract_code 的处理方式:

模式T3 索引条件 (Index Cond)连接条件处理
CPlanbiz_date, market_code, clearing_group(3 个)t2.contract_code = t3.contract_code 作为 Join Filter
GPlanbiz_date, market_code, clearing_group, contract_code(4 个)t2.contract_code = t3.contract_code 推入 Index Cond

1.3 测试环境

项目
数据库版本GaussDB Kernel 506.0.0.SPC0100 (build e324981f)
构建日期2025-04-27
操作系统kylin v10 sp3 x86_64
连接端口8000
Schemaptdemo

二、测试用例设计

2.1 表结构设计

为了完整复现这个现象,我在 GaussDB 中创建了全新的测试 Schema(ptdemo),4 张表完全使用新的命名,避免与原始生产环境中的对象名产生任何关联。

2.1.1 trade_position_record(交易持仓记录,对应原始 t1)

CREATE TABLE ptdemo.trade_position_record (
  id            SERIAL PRIMARY KEY,
  biz_date      VARCHAR(8)   NOT NULL,
  market_code   VARCHAR(16)  NOT NULL,
  clearing_group VARCHAR(16) NOT NULL,
  asset_code    VARCHAR(16)  NOT NULL,
  series_code   VARCHAR(16)  NOT NULL,
  cycle_tag     NUMERIC      NOT NULL,
  side_flag     CHAR(1),
  hedge_type    VARCHAR(4),
  discount_pct  NUMERIC(8,4),
  fee_rate      NUMERIC(12,8),
  fee_per_lot   NUMERIC(12,4)
);

CREATE INDEX idx_tpr_main ON ptdemo.trade_position_record
  (biz_date, market_code, clearing_group,
   asset_code, series_code, cycle_tag);

2.1.2 contract_specification(合约规格,对应原始 t2)

CREATE TABLE ptdemo.contract_specification (
  id            SERIAL PRIMARY KEY,
  biz_date      VARCHAR(8)   NOT NULL,
  market_code   VARCHAR(16)  NOT NULL,
  clearing_group VARCHAR(16) NOT NULL,
  asset_code    VARCHAR(16)  NOT NULL,
  base_series   VARCHAR(16)  NOT NULL,
  contract_code VARCHAR(32)  NOT NULL
);

CREATE INDEX idx_cs_baseseries ON ptdemo.contract_specification
  (biz_date, base_series);

2.1.3 active_contract_snapshot(活跃合约快照,对应原始 t3)

CREATE TABLE ptdemo.active_contract_snapshot (
  id              SERIAL PRIMARY KEY,
  biz_date        VARCHAR(8)   NOT NULL,
  market_code     VARCHAR(16)  NOT NULL,
  clearing_group  VARCHAR(16) NOT NULL,
  contract_code   VARCHAR(32)  NOT NULL,
  lifecycle_stage VARCHAR(1)   NOT NULL
);

CREATE INDEX idx_acs_main ON ptdemo.active_contract_snapshot
  (biz_date, market_code, clearing_group,
   contract_code);

2.1.4 fee_calculation_result(费用计算结果,目标表)

CREATE TABLE ptdemo.fee_calculation_result (
  id              SERIAL PRIMARY KEY,
  biz_date        VARCHAR(8),
  market_code     VARCHAR(16),
  clearing_group  VARCHAR(16),
  contract_code   VARCHAR(32),
  side_flag       CHAR(1),
  hedge_type      VARCHAR(4),
  discount_pct    NUMERIC(8,4),
  fee_rate        NUMERIC(12,8),
  fee_per_lot     NUMERIC(12,4)
);

2.2 数据量与分布

数据量按照原始场景的比例关系设置,足以复现谓词推入差异即可,没必要搞几百万行。

INSERT INTO ptdemo.trade_position_record (biz_date, market_code, clearing_group, asset_code, series_code, volume, fee_rate, fee_per_lot, cycle_tag, contract_type, side_flag, hedge_type, discount_pct)
SELECT 
    CASE WHEN (i % 670 = 0) THEN '20250616' ELSE '20250615' END,
    CASE (i % 3) WHEN 0 THEN 'MARKET_1' WHEN 1 THEN 'MARKET_3' ELSE 'MARKET_2' END,
    'GROUP' || LPAD((i % 47 + 1)::text, 4, '0'),
    'ASSET' || LPAD((i % 74 + 1)::text, 4, '0'),
    'SERIES' || LPAD((i % 1079 + 1)::text, 5, '0'),
    (i % 1000),
    0.00001234,
    1.50,
    1,
    'OPT',
    CASE (i % 2) WHEN 0 THEN 'BUY' ELSE 'SELL' END,
    CASE (i % 3) WHEN 0 THEN 'SPEC' WHEN 1 THEN 'HEDGE' ELSE 'ARB' END,
    0.95
FROM generate_series(1, 100000) as i;

INSERT INTO ptdemo.contract_specification (biz_date, market_code, clearing_group, asset_code, contract_code, base_series, lifecycle_stage)
SELECT 
    CASE WHEN (i % 670 = 0) THEN '20250616' ELSE '20250615' END,
    CASE (i % 3) WHEN 0 THEN 'MARKET_1' WHEN 1 THEN 'MARKET_3' ELSE 'MARKET_2' END,
    'GROUP' || LPAD((i % 47 + 1)::text, 4, '0'),
    'ASSET' || LPAD((i % 74 + 1)::text, 4, '0'),
    'CONTRACT' || LPAD((i % 10000 + 1)::text, 6, '0'),
    'SERIES' || LPAD((i % 1079 + 1)::text, 5, '0'),
    CASE (i % 3) WHEN 0 THEN '0' WHEN 1 THEN '1' ELSE '2' END
FROM generate_series(1, 500000) as i;

INSERT INTO ptdemo.active_contract_snapshot (biz_date, market_code, clearing_group, contract_code, lifecycle_stage, settlement_status)
SELECT 
    CASE WHEN (i % 100 = 0) THEN '20250616' ELSE '20250615' END,
    CASE (i % 3) WHEN 0 THEN 'MARKET_1' WHEN 1 THEN 'MARKET_3' ELSE 'MARKET_2' END,
    'GROUP' || LPAD((i % 47 + 1)::text, 4, '0'),
    'CONTRACT' || LPAD((i % 10000 + 1)::text, 6, '0'),
    CASE (i % 3) WHEN 0 THEN '0' WHEN 1 THEN '1' ELSE '2' END,
    'SETTLED'
FROM generate_series(1, 10000) as i;
表名行数说明
trade_position_record10000010 万行,主表
contract_specification500,00050 万行,合约规格
active_contract_snapshot10,0001 万行,活跃合约
fee_calculation_result0空表,INSERT 目标

数据生成使用 generate_series 批量插入,market_code 有 3 个值(MARKET_1/MARKET_2/MARKET_3),clearing_group 约 47 个值,asset_code 约 84 个值。这样保证了 MCV(Most Common Values)统计信息有实际意义,优化器能够利用这些统计数据做选择性估算。

2.3 关键 GUC 参数

测试中显式设置了以下参数,用于控制计划缓存模式并关闭 Hash/Merge Join,强制使用 NestLoop:

-- CPlan 模式
SET plan_cache_mode = force_custom_plan;  -- 值为 2
SET enable_hashjoin = off;
SET enable_mergejoin = off;
SET enable_nestloop = on;
SET enable_plan_trace = on;

-- GPlan 模式
SET plan_cache_mode = force_generic_plan;  -- 值为 1
-- 其余参数不变

这里特意通过 plan_cache_mode 强制控制 CPlan/GPlan 切换,而不是等待 6 次自动切换,原因后面会解释。


三、Plan Trace 获取方法

3.1 什么是 Plan Trace

GaussDB 的 Plan Trace 是优化器在生成执行计划过程中留下的详细日志,记录了从查询重写、基数估算、路径生成到最终计划选择的每一个决策点。与 EXPLAIN 输出的执行计划不同,Plan Trace 记录的是优化器"思考"的过程,而不是最终的结论。

打个比方:EXPLAIN 告诉你优化器选了什么,Plan Trace 告诉你优化器为什么这么选,以及它还考虑过哪些备选方案。

3.2 获取 Plan Trace 的正确方式

正确的获取步骤如下:

-- 步骤 1:开启 Plan Trace
SET enable_plan_trace = on;

-- 步骤 2:正常执行 SQL(同一 session 中)
PREPARE ptdemo_trace AS
  INSERT INTO ... SELECT ... FROM ...;
EXECUTE ptdemo_trace('20250616', 'MARKET_1', 'ASSET0001',
                     'GROUP0001', 'SERIES00001', 1,
                     '20250615', '20250616');

-- 步骤 3:查看 Plan Trace(需先开启 x 展开模式)
\x
SELECT * FROM gs_my_plan_trace
  ORDER BY modifydate DESC LIMIT 1;

有几个注意点:

  • PREPARE 和 EXECUTE 必须在同一个 session 中执行,否则 Prepare 的句柄在 session 间不保留。
  • \x 必须先开启,否则 plan_trace 字段的展开内容会被截断,看不到完整的 trace 信息。
  • gs_my_plan_trace 视图中,plan_trace 字段包含了完整的优化器决策过程,包括 key_guc、query_rewrite、optpath_set_base_rel_sizes 等多个阶段。

3.3 Plan Trace 的核心阶段

一份完整的 Plan Trace 包含以下关键阶段(按执行顺序排列):

阶段标识含义
[key_guc]记录本次查询涉及的关键 GUC 参数值
[query_block_1]查询块信息,包含完整的 SQL 文本
[query_rewrite]查询重写阶段,记录各重写规则的执行情况
[optpath_set_base_rel_sizes]基数估算阶段,计算各表的行数估算
[optpath_set_rel_pathlist]路径生成阶段,为每张表生成可用的访问路径
[optpath_make_rel_from_joinlist]Join 搜索阶段,确定表连接顺序和方式
[optpath_add_paths_to_joinrel]为 Join 关系添加路径,计算 Join 成本

本文分析的重点是路径生成阶段和 Join 搜索阶段——这两个阶段决定了谓词是否被推入。


四、CPlan 与 GPlan 执行计划对比

4.1 CPlan 执行计划

先看 CPlan 的执行计划(plan_cache_mode = force_custom_plan):

Insert on fee_calculation_result (cost=0.00..660.35 rows=1 width=54)
  ->  Nested Loop (cost=0.00..660.35 rows=1 width=54)
        Join Filter: ((t2.contract_code)::text = (t3.contract_code)::text)
        ->  Nested Loop (cost=0.00..658.05 rows=1 width=54)
              ->  Index Scan using idx_tpr_main on trade_position_record t1
                    (cost=0.00..2.29 rows=1 width=70)
                    Index Cond: (biz_date = *** AND market_code = ***
                      AND clearing_group = *** AND series_code = ***
                      AND asset_code = *** AND cycle_tag = ***)
              ->  Index Scan using idx_cs_baseseries on contract_specification t2
                    (cost=0.00..655.74 rows=1 width=60)
                    Index Cond: (biz_date = *** AND base_series = ***)
                    Filter: (asset_code = *** AND clearing_group = ***
                      AND market_code = ***)
        ->  Index Scan using idx_acs_main on active_contract_snapshot t3
              (cost=0.00..2.29 rows=1 width=29)
              Index Cond: (biz_date = *** AND market_code = ***
                AND clearing_group = ***)
              Filter: (lifecycle_stage = ANY(***))

注意看 T3(active_contract_snapshot)的 Index Cond:只有 biz_date、market_code、clearing_group 三个条件。t2.contract_code = t3.contract_code 这个 join 条件没有出现在 Index Cond 中,而是作为外层 Nested Loop 的 Join Filter 存在。

也就是说,T3 的索引扫描只能过滤到 market_code + clearing_group + biz_date 这个粒度,然后对返回的每一行再做 Join Filter 判断。如果这个粒度下返回的行数比较多,Join Filter 的过滤成本就不容忽视。

4.2 GPlan 执行计划

再看 GPlan(plan_cache_mode = force_generic_plan):

Insert on fee_calculation_result (cost=0.00..270.78 rows=1 width=54)
  ->  Nested Loop (cost=0.00..270.78 rows=1 width=54)
        ->  Nested Loop (cost=0.00..268.48 rows=1 width=54)
              ->  Index Scan using idx_tpr_main on trade_position_record t1
                    (cost=0.00..2.29 rows=1 width=70)
                    Index Cond: (biz_date = ($7) AND market_code = ($2)
                      AND clearing_group = ($4) AND series_code = ($5)
                      AND asset_code = ($3) AND cycle_tag = $6)
              ->  Index Scan using idx_cs_baseseries on contract_specification t2
                    (cost=0.00..266.17 rows=1 width=60)
                    Index Cond: (biz_date = ($7) AND base_series = ($5))
                    Filter: (asset_code = ($3) AND clearing_group = ($4)
                      AND market_code = ($2))
        ->  Index Scan using idx_acs_main on active_contract_snapshot t3
              (cost=0.00..2.30 rows=1 width=29)
              Index Cond: (biz_date = ($8) AND market_code = ($2)
                AND clearing_group = ($4)
                AND contract_code = (t2.contract_code))
              Filter: (lifecycle_stage = ANY(***))

关键差异来了:T3 的 Index Cond 中多了 contract_code = (t2.contract_code)。这个条件就是 join 条件 t2.contract_code = t3.contract_code 被推入到了 T3 的索引扫描中。

不仅如此,整个执行计划中没有任何 Join Filter。所有连接条件都被推入到了内层表的索引扫描中。这意味着每次 T3 的索引扫描都能精确地定位到特定的 contract_code,而不是扫描一个范围后再过滤。

4.3 核心差异对比

对比项CPlanGPlan
T3 Index Cond 数量3 个条件4 个条件
join 条件处理Join Filter推入 Index Cond
T3 扫描选择性粗粒度(market+group+date)精确(多了 contract_code)
总成本660.35270.78
成本差异基准降低约 59%

从 660 到 270,成本下降了将近 60%。在实际生产环境中,如果数据量更大、T3 的基数更高,这个差异会被进一步放大。

4.4 还有个有趣的现象

细心的读者可能已经注意到了:CPlan 中参数值被替换为 ***(脱敏显示),而 GPlan 中保留了 $2$3 等参数占位符。这不是显示问题,而是反映了 CPlan 和 GPlan 的本质区别:

  • CPlan 在每次执行时,会先将参数的实际值代入 SQL 中,再进行优化。优化器看到的是具体的值(如 MARKET_1GROUP0001),因此可以精确匹配 MCV 统计信息。
  • GPlan 不代入参数值,优化器看到的是 $2$3 等占位符。估算选择性时,只能使用 NDV(Number of Distinct Values)和均匀分布假设,而无法利用 MCV。

按理说,CPlan 有更精确的统计信息,应该生成更好的计划。但恰恰在这个案例中,CPlan 反而没有做谓词推入。为什么会这样?答案在 Plan Trace 里。


五、Plan Trace 深度解读——基数估算阶段

5.0 CPlan 与 GPlan 的 Plan Trace 完整内容

由于输出内容太长,三百多KB,文章里就不直接贴完整内容了,完整内容另见以下两个链接:
plan_trace_cplan.txt
plan_trace_gplan.txt

5.1 CPlan 与 GPlan 的 key_guc 差异

先看两者 Plan Trace 开头的 [key_guc] 段,这是最直接的差异:

GUC 参数CPlanGPlan
plan_cache_mode2 (force_custom_plan)1 (force_generic_plan)
enable_hashjoin0 (off)0 (off)
enable_mergejoin0 (off)0 (off)
enable_nestloop1 (on)1 (on)
random_page_cost4.0004.000
default_statistics_target100100
enable_pbe_optimization00

除了 plan_cache_mode,其余参数完全一致。这说明参数差异不是根因,真正的差异来自优化器在处理参数值时的不同策略。

5.2 CPlan 的基数估算——利用 MCV

在 CPlan 模式下,参数值已经被替换为具体值(如 MARKET_1GROUP0001),优化器在 [adt_var_eq_const] 阶段可以精确匹配 MCV 统计信息。以 t1.market_code 为例:

[adt_var_eq_const]
col_base_info: trade_position_record, market_code, =
has_analyze_stats: true
has_mcv: true
matched_mcv: true, mcv:0.335500
end method: adt_var_eq_const, return: 0.335500

matched_mcv: true 表示参数值 MARKET_1 在 market_code 列的 MCV 列表中找到了匹配,选择率为 0.3355(即 MARKET_1 约占 33.55% 的行)。这就是 CPlan 的优势——精确的选择性估算。

对比 t1.series_code 的情况:

[adt_var_eq_const]
col_base_info: trade_position_record, series_code, =
has_analyze_stats: true
has_mcv: true
matched_mcv: false
calculate selec: selec = 1.0 - sumcommon - stanullfrac,
  0.908333,0.091667,0.000000
The origin distinct value is 1079.000000.
After using poisson model with ntuples=113571.000000
  and ration=0.011967
  The new distinct value is 772.802894
estimated_selec_gt_the_least_selec: true,
  use the least common selec: 0.001167
end method: adt_var_eq_const, return: 0.001167

matched_mcv: false——参数值 SERIES00001 不在 MCV 列表中,优化器退回到 Poisson 模型估算,最终使用了最小公共选择率 0.001167。

5.3 GPlan 的基数估算——使用 NDV

GPlan 看不到具体的参数值,只能使用 NDV(去重值数量)和均匀分布假设。对于等值条件,选择性估算公式为:

selec = \frac{1}{NDV}

GPlan 同样经历了 [adt_var_eq_const] 阶段,但由于参数是占位符 $n,无法匹配 MCV,一律使用 NDV 估算。这导致 GPlan 的选择性估算往往不如 CPlan 精确——但也正是因为这种"模糊性",GPlan 反而更倾向于使用参数化路径(Parameterized Path),从而实现了谓词推入。

5.4 基数估算汇总

以下汇总三张表在两种模式下的行数估算结果:

实际行数CPlan 估算GPlan 估算
trade_position_record113,57111
contract_specification500,0001 (446 from idx)1 (446 from idx)
active_contract_snapshot10,00011

有趣的是,两种模式下三张表的估算行数都是 1(即精确匹配到单行),差异主要体现在连接阶段的路径选择上。CPlan 中 contract_specification 的索引扫描估算返回 446 行(Index Scan tuples=446),因为 idx_cs_baseseries 索引只包含 (biz_date, base_series) 两列,其余过滤条件只能作为 Filter。


六、路径生成与参数化路径——核心差异解析

6.1 什么是参数化路径

要理解 CPlan 和 GPlan 的差异,必须先理解"参数化路径"(Parameterized Path)这个概念。

在 NestLoop Join 中,外层表的每一行都会驱动内层表的扫描。如果内层表的索引扫描条件中包含外层表的列(即 join 条件),那么内层表的索引扫描就可以"参数化"——每次扫描时,使用外层表当前行的实际值作为索引条件的一部分。这就是参数化路径。

回到我们的案例,T3 的索引 idx_acs_main 包含 (biz_date, market_code, clearing_group, contract_code) 四列。如果 t2.contract_code 能被推入 T3 的索引条件,那么 T3 的索引扫描就从"范围扫描 + 过滤"变成了"精确点查",效率大幅提升。

能不能推入,取决于优化器是否生成了参数化路径(hasparam=1),以及在 Join 阶段是否选择了这条参数化路径。

6.2 T3 的路径生成对比

在 [optpath_set_rel_pathlist] 阶段,优化器为 T3(active_contract_snapshot)生成了多条候选路径。关键差异在于参数化路径的命运:

6.2.1 CPlan:参数化路径未被接受

-- CPlan 中 T3 的参数化路径 (pathid=9)
IndexScan(5:active_contract_snapshot )
  pathid=00000009 hasparam=1 indexname=idx_acs_main
  rows=1 multiple=1.000000 tuples=1.00
  selec=0.00000001 ml=1 iscost=1 lossy=0 uidx=0
  cost=0.00..2.30

-- 对比:非参数化路径 (pathid=8)
IndexScan(5:active_contract_snapshot )
  pathid=00000008 hasparam=0 indexname=idx_acs_main
  rows=1 multiple=1.000000 tuples=1.00
  selec=0.00007066 ml=1 iscost=1 lossy=0 uidx=0
  cost=0.00..2.29

-- 优化器的判断:
A new path is not accepted with cost = 0.000000 .. 2.296250
  old pathid=00000008
  Cost = Equal | PathKeys = OldBetter | BMS = OldBetter

在 CPlan 中,T3 的参数化路径(pathid=9)和非参数化路径(pathid=8)的成本几乎一样(2.30 vs 2.29)。但优化器比较后认为,非参数化路径的 PathKeys 和 BMS(BitmapSet)更优,因此拒绝了参数化路径。

也就是说,CPlan 认为不需要参数化路径——因为 CPlan 已经知道具体的参数值,它"自信"地认为使用已知的 biz_date + market_code + clearing_group 三个条件就足够精确了。

6.2.2 GPlan:参数化路径被接受

-- GPlan 中 T3 的参数化路径 (pathid=9)
IndexScan(5:active_contract_snapshot )
  pathid=00000009 hasparam=1 indexname=idx_acs_main
  rows=1 multiple=1.000000 tuples=1.00
  selec=0.00000035 ml=1 iscost=1 lossy=0 uidx=0
  cost=0.00..2.30

A new path is accepted with cost = 0.000000 .. 2.296250
  rows=1.000000

GPlan 中,同样的参数化路径被接受了。原因是 GPlan 不知道参数的具体值,它需要更多的条件才能确保选择性。于是参数化路径成为了最优选择。

6.3 三表 Join 的路径选择

路径生成阶段的差异,在三表 Join 阶段被进一步放大。

6.3.1 GPlan 的三表 Join(成本低)

-- 三表 joinrel: (b 3 4 5)
-- 使用参数化路径 pathid=9 的 NestLoop
NestLoop(3:trade_position_record 4:contract_specification
  5:active_contract_snapshot)
  pathid=00000036 hasparam=0
  cost=0.00..270.78
  trace_id=#2##5##15##9##36#
  -- ^^^ 注意这里的 #9 是参数化路径!

-- 对比:使用非参数化路径 pathid=8 的 NestLoop
NestLoop(...)
  pathid=00000035 cost=0.00..288.51
  trace_id=#2##5##15##8##35#
  -- ^^^ #8 是非参数化路径,成本更高

GPlan 中,使用参数化路径(#9)的三表 Join 成本为 270.78,明显优于非参数化路径(#8)的 288.51。优化器果断选择了参数化路径。

成本的差异来自 T2 的索引扫描:在 GPlan 中,T2(contract_specification)使用 idx_cs_baseseries 索引估算返回 446 行,这意味着外层 Nested Loop 会驱动 T3 扫描 446 次。每次 T3 扫描如果能用 contract_code 精确定位(参数化路径),就避免了在大范围结果集上做 Join Filter。

6.3.2 CPlan 的三表 Join(成本高)

-- 三表 joinrel: (b 3 4 5)
-- 使用非参数化路径 pathid=8 的 NestLoop
NestLoop(3:trade_position_record 4:contract_specification
  5:active_contract_snapshot)
  pathid=00000037 hasparam=0
  cost=0.00..660.35
  trace_id=#2##5##15##8##37#
  -- ^^^ #8 是非参数化路径

-- 参数化路径路径未进入候选
-- cost_b 从 655.74 飙升到 660.35

CPlan 中,由于参数化路径在单表阶段就被拒绝了,三表 Join 只能使用非参数化路径(#8),成本高达 660.35。T2 的索引扫描估算同样返回 446 行,但 T3 每次扫描只能用 3 个条件,无法精确到 contract_code。

这里的关键在于 CPlan 中 T2 的成本估算。CPlan 使用了具体的参数值,T2 通过 idx_cs_baseseries 扫描的成本估算为 655.74(比 GPlan 的 266.17 高得多)。这是因为 CPlan 的选择性估算方式不同,导致了完全不同的 T2 扫描成本,进而影响了 Join 阶段的决策。

6.4 差异根因总结

回到之前的问题:为什么 CPlan 不做谓词推入?现在可以给出一个清晰的回答了:

  • CPlan 在路径生成阶段,为 T3 生成了参数化路径(hasparam=1),但由于 CPlan 已经用具体值做了精确的选择性估算,非参数化路径的成本已经足够低,优化器认为没有必要使用参数化路径,于是将其拒绝。
  • GPlan 不知道具体参数值,选择性估算依赖 NDV 和均匀分布,因此更倾向于使用参数化路径来弥补选择性估算的不足。参数化路径被保留,并在三表 Join 阶段被选中。
  • 讽刺的是,CPlan 拥有更精确的统计信息,反而做出了"更差"的计划选择。GPlan 用"粗略"的估算,反而选出了更优的计划。这其实不矛盾——精确的估算让 CPlan 过于"自信",忽略了参数化路径在 NestLoop 场景下的优势。

七、Hint 对谓词推入的影响

7.1 predpushforce Hint 的作用

测试SQL 中包含一个 Hint:/*+ Set(rewrite_rule predpushforce) */。这个 Hint 的作用是强制启用谓词推入重写规则。

我分别测试了带 Hint 和不带 Hint 两种场景,结果如下:

模式HintT3 Index Cond总成本
CPlan有 predpushforce3 个条件660.35
CPlan无 predpushforce3 个条件660.35
GPlan有 predpushforce4 个条件270.78
GPlan无 predpushforce4 个条件270.78

有意思的是,在这个案例中,predpushforce Hint 对执行计划没有任何影响。无论是 CPlan 还是 GPlan,加不加 Hint,结果都一样。

7.2 为什么 Hint 没起作用

查看 Plan Trace 的 [query_rewrite] 阶段,可以看到无论是否有 predpushforce Hint,查询重写阶段的输出都完全相同——所有重写规则执行后,SQL 文本没有任何变化。

这说明 predpushforce Hint 作用于查询重写阶段的谓词推入重写规则,但在这个案例中,谓词推入的差异不是发生在查询重写阶段,而是发生在路径生成和 Join 搜索阶段。优化器是否推入谓词,取决于路径选择的决策,而不是查询重写规则。

因此,predpushforce Hint 无法改变 CPlan 不做谓词推入的事实。这是一个容易被忽视的误区——很多人以为加个 Hint 就能解决所有谓词推入问题,但实际上 Hint 的作用范围是有限的。

7.3 Query Rewrite 阶段的 Hint 差异

虽然 predpushforce 没有影响最终结果,但它在 Plan Trace 的 SQL 文本中是可见的。对比两者 [query_block_1] 中的 SQL:

  • 有 Hint 的 CPlan:INSERT /*+ Set(enable_hashjoin off) Set(enable_nestloop on) Set(rewrite_rule predpushforce)*/ INTO ...
  • 无 Hint 的 GPlan:INSERT /*+ Set(enable_hashjoin off) Set(enable_nestloop on)*/ INTO ...

Hint 被正确解析并附加到了 SQL 文本中,但并没有在查询重写阶段产生实际效果。这也从侧面说明,谓词推入在这个场景下是由路径选择逻辑控制的,而不是查询重写逻辑。


八、成本模型详解

8.1 NestLoop Join 的成本计算

GaussDB 的 NestLoop Join 成本公式可以简化为:

Cost_{NL} = Cost_{outer} + N_{outer} \times Cost_{inner\_rescan} + C_{filter}

其中:

  • Cost_{outer}:外层表的总扫描成本
  • N_{outer}:外层表估算返回的行数
  • Cost_{inner\_rescan}:内层表每次重新扫描的成本(rescan)
  • C_{filter}:Join Filter 的 CPU 过滤成本

在 Plan Trace 中,每个 NestLoop 的成本计算过程记录在 [optcost_initial_cost_nestloop] 和 [optcost_final_cost_nestloop] 中。以 GPlan 中 T1-T2 的 Join 为例:

[optcost_initial_cost_nestloop]
  inner_pathid,5 outer_pathid,2
  inner_start_cost,0.000000 inner_total_cost,266.170327
  outer_start_cost,0.000000 outer_total_cost,2.295000
  outer_path_rows,1.000000
  cal: inner_rescan_start_cost,0.000000
  cal: inner_rescan_total_cost,266.170327
  cal: inner_run_cost = inner_total_cost - inner_start_cost
    266.170327
  cal: startup_cost += outer_start_cost + inner_start_cost
    0.000000
  cal: run_cost += outer_total_cost - outer_start_cost
    2.295000
  cal: run_cost += inner_run_cost 268.465327
  Initial nestloop cost: startup_cost: 0.000000,
    total_cost: 268.465327

可以看到,NestLoop 的初始成本就是外层成本 + 内层成本。由于外层 T1 只返回 1 行(精确匹配 6 个索引列),内层 T2 只需扫描一次,所以 T1-T2 Join 的成本为 2.30 + 266.17 = 268.48。

8.2 CPlan 与 GPlan 的 T2 成本差异

最显著的差异在 T2(contract_specification)的索引扫描成本上:

模式索引选择率估算行数扫描成本
CPlan0.000893446655.74
GPlan0.000893446266.17

索引选择率和估算行数完全相同,但扫描成本差距巨大(655.74 vs 266.17)。这是因为两者的 ml(Macrolanczos 相关性因子)和 pages_fetched(预取页数)不同。

具体来说,CPlan 的 pages_fetched=429,而 GPlan 的 pages_fetched 少得多。这是因为 CPlan 使用了具体的参数值,优化器对数据物理分布的估算更加"保守"(或者说更悲观),认为需要读取更多的数据页。

-- CPlan: T2 btcostestimate
cal: tuples_fetched=ml,429.000000
cal: page_io_cost = (correlation^2) * (min_IO_cost - max_IO_cost)
  + max_IO_cost,640.157549,0.747873
  run_cost += page_io_cost, 647.935047,640.157549
-- total_cost = 655.74

-- GPlan: T2 btcostestimate
-- 类似计算但 pages_fetched 更低
-- total_cost = 266.17

CPlan 中 T2 的高扫描成本(655.74)直接导致了三表 Join 的总成本居高不下(660.35)。而 GPlan 中 T2 的低扫描成本(266.17)为参数化路径的采纳创造了条件——当 T2 成本已经足够低时,T3 使用参数化路径的边际收益就更容易体现出来。

8.3 为什么成本差异如此之大

这里的核心问题是:同样的索引、同样的选择率,为什么 CPlan 的 IO 成本估算比 GPlan 高这么多?

我只能猜想,这可能跟 CPlan 和 GPlan 在 adt_genericcostestimate 阶段的 ml(Macrolanczos)估算差异有关。ml 因子基于列的物理存储相关性(correlation)计算,而 CPlan 在使用具体参数值时,可能影响了 correlation 的使用方式,导致 pages_fetched 的估算偏高。

具体到源码层面的原因,目前没有更详细的文档介绍。Plan Trace 中记录了 correlation 值(CPlan 约 0.747873,GPlan 类似),但 ml 的最终计算过程在 trace 中没有展开。这个只能留待后续进一步分析。


九、CPlan 到 GPlan 的自动切换机制

9.1 plan_cache_mode 参数

GaussDB 通过 plan_cache_mode 参数控制 PBE 场景下的计划选择策略:

含义
force_custom_plan (2)始终使用 CPlan,每次执行都重新生成计划
force_generic_plan (1)始终使用 GPlan,使用参数化通用计划
auto (0, 默认)前 5 次使用 CPlan,第 6 次评估是否切换到 GPlan

在默认的 auto 模式下,GaussDB 会在前 5 次执行时使用 CPlan,第 6 次起评估是否切换到 GPlan。评估的依据是历史执行计划的"稳定性"——如果多次 CPlan 的执行计划差异不大,就认为参数对计划影响较小,可以安全地切换到 GPlan 以节省计划生成开销。(其实还有APLAN,放进来就太复杂了,容易绕晕,本文暂不考虑)

9.2 自动切换的触发条件

auto 模式下,第 6 次执行时,GaussDB 会检查计划缓存中的已有计划。如果满足以下条件,就会触发 GPlan 生成:

  • 执行次数达到 5 次(硬阈值)
  • 已有的 CPlan 执行计划之间没有显著差异
  • 参数值的分布相对稳定

一旦切换到 GPlan,后续执行会复用 GPlan,直到计划缓存被清空或 SQL 结构发生变化。

这就是原始案例中"前 5 次 CPlan 没有谓词推入,第 6 次 GPlan 谓词推入"的完整机制。不是 SQL 变了,不是统计数据变了,而是计划选择策略变了。

9.3 自动切换的潜在风险

这个机制本身是合理的——在参数对计划影响不大的场景下,GPlan 可以减少计划生成的 CPU 开销。但在我们的案例中,CPlan 和 GPlan 的执行计划差异巨大(成本相差 59%),自动切换可能导致性能突然下降或提升。

如果 CPlan 的计划总是比 GPlan 好(或者反过来),那自动切换就完全没有意义,不如直接固定一种模式。问题恰恰在于,不同的 SQL、不同的数据分布,CPlan 和 GPlan 哪个更优是不确定的。GaussDB 的 auto 模式本质上是一种"赌博"——赌 GPlan 不会比 CPlan 差太多。

因此,对于性能敏感的场景,建议显式设置 plan_cache_mode,而不是依赖自动切换。如果你通过 Plan Trace 分析发现 CPlan 和 GPlan 的计划差异较大,更应该固定使用较优的那种模式。


十、查询重写阶段分析

10.1 查询重写的各个阶段

Plan Trace 中的 [query_rewrite] 段记录了查询重写的完整过程。GaussDB 的查询重写包含以下阶段(按执行顺序):

阶段含义
Before rewrite原始 SQL 文本
After const params replace常量参数替换
After CTE substitutionCTE 替换
After replace empty join tree空 Join 树替换
After multi count distinct rewrite多 COUNT(DISTINCT) 重写
After window function CSE窗口函数公共子表达式消除
After sublink pullup having子链接上拉(HAVING 相关)
After sublink outer join elimination子链接外连接消除
After sublink pullup子链接上拉
After order by reduceORDER BY 化简
After soft constraint removal软约束消除
After or extensionOR 扩展
After inline set returing functions内联集合返回函数
After lazyagg延迟聚合
After simple subquery pull up简单子查询上拉
After group by push downGROUP BY 下推
After simple union all flatten简单 UNION ALL 扁平化
After preprocess rownumROWNUM 预处理
After preprocess expressions表达式预处理
After having qual rewriteHAVING 条件重写
After join eliminationJoin 消除
After merge into false qual conversionMERGE INTO 虚假条件转换

10.2 CPlan 与 GPlan 的重写差异

对比 CPlan 和 GPlan 的 [query_rewrite] 阶段,发现一个非常有趣的事实:所有阶段的输出都完全相同。无论是否有 predpushforce Hint,无论 CPlan 还是 GPlan,查询重写阶段的输出都没有变化。

这意味着,谓词推入的差异不是查询重写导致的。查询重写阶段只是对 SQL 文本做语法层面的变换,而谓词是否推入是在后续的路径生成和 Join 搜索阶段决定的。

这也解释了为什么 predpushforce Hint 在这个场景下无效——该 Hint 作用于查询重写阶段的 predpushforce 重写规则,但查询重写阶段并没有改变 SQL 的结构(因为 SQL 本身已经是"最简"形式了,没有可以推入的子查询或视图)。真正的谓词推入是由路径选择逻辑在运行时决定的。

10.3 Hint 在 SQL 中的呈现

虽然 Hint 不影响重写结果,但它在 Plan Trace 中是可见的。有 predpushforce Hint 的 SQL 在 [query_block_1] 和所有重写阶段中都会携带 Set(rewrite_rule predpushforce) 标记。这可以帮助我们确认 Hint 是否被正确解析。

无 Hint 的 GPlan SQL 中只有 Set(enable_hashjoin off) Set(enable_nestloop on) 两个 Hint,少了 predpushforce。但最终结果相同,再次证明了这个 Hint 在此场景下不起作用。


十一、Join 搜索顺序分析

11.1 动态规划 Join 搜索

GaussDB 使用动态规划算法搜索最优的 Join 顺序。在 Plan Trace 中,这个过程记录在 [optpath_dp_join_search] 阶段。对于三表 Join(T1、T2、T3),优化器会枚举所有可能的 Join 顺序和连接方式。

11.2 两表 Join 候选

优化器首先枚举所有两表组合:

  • T1-T2(trade_position_record + contract_specification)
  • T1-T3(trade_position_record + active_contract_snapshot)
  • T2-T3(contract_specification + active_contract_snapshot)

由于只允许 NestLoop,每种组合只有一种连接方式。优化器计算每种组合的最低成本路径。以 CPlan 为例,T1-T2 的最低成本路径为 658.05(使用 T1 索引扫描 + T2 索引扫描的 NestLoop)。

11.3 三表 Join 的路径枚举

在三表 Join 阶段,优化器考虑了以下 Join 顺序:

  • (T1-T2)-T3:先 Join T1 和 T2,再与 T3 Join
  • (T1-T3)-T2:先 Join T1 和 T3,再与 T2 Join
  • (T2-T3)-T1:先 Join T2 和 T3,再与 T1 Join

对于每种 Join 顺序,优化器会枚举内外层关系。以 (T1-T2)-T3 为例:

  • 外层为 (T1-T2),内层为 T3:这是最终被选择的方案
  • 外层为 T3,内层为 (T1-T2):成本通常更高

在 CPlan 中,(T1-T2)-T3 的最低成本为 660.35。在 GPlan 中,同样的 Join 顺序最低成本为 270.78。

成本的差异主要来自:

  • T2 的索引扫描成本:CPlan 655.74 vs GPlan 266.17(相差约 390)
  • T3 是否使用参数化路径:CPlan 的参数化路径在单表阶段被拒绝,GPlan 保留了参数化路径

这两项差异叠加,导致了总成本 660 vs 270 的巨大差距。

11.4 Join 顺序与参数化路径的关系

参数化路径的生效有一个前提条件:被推入的列必须来自"已经可用的"外层表。在我们的案例中,t2.contract_code 被推入 T3 的 Index Cond,前提是 T2 已经在 T3 的外层执行完毕。

如果 Join 顺序是 (T1-T3)-T2,那么 T3 的外层是 T1,T1 没有 contract_code 列,自然无法推入。只有 (T1-T2)-T3 的顺序,T3 的外层是 (T1-T2) Join 结果,其中包含 T2 的 contract_code,才能推入。

这解释了为什么 Join 顺序的选择如此重要——它决定了哪些参数化路径是可用的。优化器的动态规划算法会枚举所有顺序,但在 CPlan 模式下,即使 (T1-T2)-T3 的顺序是最优的,T3 的参数化路径也因为在单表阶段被拒绝而无法使用。


十二、完整可复现步骤

12.1 环境准备

-- 1. 创建 Schema
CREATE SCHEMA ptdemo;

-- 2. 创建表(见第二章表结构)
-- 3. 创建索引(见第二章索引定义)
-- 4. 插入测试数据(见第二章数据量与分布)
-- 5. 执行 ANALYZE 收集统计信息
ANALYZE ptdemo.trade_position_record;
ANALYZE ptdemo.contract_specification;
ANALYZE ptdemo.active_contract_snapshot;

12.2 复现 CPlan 现象

-- 同一 session 中执行
\x
SET enable_plan_trace = on;
SET plan_cache_mode = force_custom_plan;
SET enable_hashjoin = off;
SET enable_mergejoin = off;
SET enable_nestloop = on;

PREPARE ptdemo_trace_c AS
  INSERT INTO ptdemo.fee_calculation_result
    (biz_date, market_code, clearing_group,
     contract_code, side_flag, hedge_type,
     discount_pct, fee_rate, fee_per_lot)
  SELECT $1, t1.market_code, t1.clearing_group,
         t2.contract_code,
         t1.side_flag, t1.hedge_type,
         t1.discount_pct, t1.fee_rate, t1.fee_per_lot
  FROM ptdemo.trade_position_record t1,
       ptdemo.contract_specification t2,
       ptdemo.active_contract_snapshot t3
  WHERE t1.market_code = t2.market_code
    AND t1.clearing_group = t2.clearing_group
    AND t1.market_code = t3.market_code
    AND t1.clearing_group = t3.clearing_group
    AND t1.asset_code = t2.asset_code
    AND t1.series_code = t2.base_series
    AND t1.biz_date = t2.biz_date
    AND t2.contract_code = t3.contract_code
    AND t3.lifecycle_stage IN ('0','1','2')
    AND t1.market_code = $2
    AND t1.asset_code = $3
    AND t1.clearing_group = $4
    AND t1.series_code = $5
    AND t1.cycle_tag = $6
    AND t1.biz_date = $7
    AND t3.biz_date = $8;

EXECUTE ptdemo_trace_c('20250616','MARKET_1','ASSET0001',
  'GROUP0001','SERIES00001',1,'20250615','20250616');

-- 查看 Plan Trace
SELECT plan_trace FROM gs_my_plan_trace
  ORDER BY modifydate DESC LIMIT 1;

12.3 复现 GPlan 现象

-- 切换到 GPlan 模式(需重新 PREPARE)
DEALLOCATE ALL;
SET plan_cache_mode = force_generic_plan;
SET enable_plan_trace = on;

PREPARE ptdemo_trace_g AS
  -- (同上 SQL)

EXECUTE ptdemo_trace_g('20250616','MARKET_1','ASSET0001',
  'GROUP0001','SERIES00001',1,'20250615','20250616');

SELECT plan_trace FROM gs_my_plan_trace
  ORDER BY modifydate DESC LIMIT 1;

执行后,对比两份 Plan Trace 中 T3(active_contract_snapshot)的 Index Cond 和总成本,即可确认 CPlan 无谓词推入(cost=660)、GPlan 有谓词推入(cost=270)的现象。


十三、总结与建议

13.1 现象总结

经过完整的 Plan Trace 分析,CPlan 与 GPlan 谓词推入差异的根因可以概括为以下链条:

  1. CPlan 在路径生成阶段,由于已知具体参数值,对 T3 生成的参数化路径(hasparam=1)被判定为"不优于"非参数化路径,从而被拒绝。
  2. GPlan 不知道具体参数值,更依赖参数化路径来弥补选择性估算的不足,因此保留了 T3 的参数化路径。
  3. 在三表 Join 阶段,GPlan 选择了参数化路径,将 t2.contract_code = t3.contract_code 推入 T3 的 Index Cond,避免了 Join Filter,成本从 660 降到 270。
  4. CPlan 的 T2 索引扫描成本估算偏高(655.74 vs 266.17),进一步放大了与 GPlan 的差距。
  5. predpushforce Hint 对此场景无效,因为差异发生在路径选择阶段而非查询重写阶段。

13.2 实用建议

回到开头的那个案例,如果你的生产环境中也遇到了类似的 CPlan/GPlan 计划差异问题,以下建议供参考:

建议说明
固定 plan_cache_mode如果通过 Plan Trace 分析发现 CPlan 和 GPlan 计划差异较大,显式设置 force_custom_plan 或 force_generic_plan,避免自动切换带来不确定性(或者使用use_cplan/use_glan的hint)
善用 Plan Trace 排查遇到复杂性能问题不要只看 EXPLAIN,用 gs_my_plan_trace 查看优化器的完整决策过程,特别是路径生成和 Join 搜索阶段
注意参数化路径NestLoop 场景下,参数化路径是性能的关键。检查索引是否覆盖了 Join 条件列,确保参数化路径能够被生成和使用
Hint 不是万能的predpushforce Hint 只作用于查询重写阶段。如果差异来自路径选择,需要通过调整索引结构或 GUC 参数来影响
关注统计信息质量ANALYZE 的质量和频率直接影响 CPlan 的估算准确性。统计信息越精确,CPlan 越有可能做出正确决策

13.3 Plan Trace 分析方法论

最后总结一下 Plan Trace 的分析方法论。面对一份陌生的 Plan Trace,建议按以下顺序分析:

  1. 先看 [key_guc]:确认影响计划的关键参数值。
  2. 再看执行计划(plan 字段):快速了解最终的计划结构和成本。
  3. 然后看 [query_rewrite]:确认是否有重写规则改变了 SQL 结构。
  4. 重点看 [optpath_set_rel_pathlist]:逐表分析路径生成过程,特别关注 hasparam(是否参数化)和 cost。
  5. 接着看 [optpath_add_paths_to_joinrel]:分析 Join 阶段的路径选择,找出成本最低的路径和它使用的 trace_id 链。
  6. 最后回到成本公式:理解每个 cost 数字的来源,判断优化器的估算是否合理。

按这个方法论,Plan Trace 就不再是一堆晦涩的日志,而是一份清晰的"优化器决策报告"。


以上内容均为AI自动测试自动生成,先对原始plantrace文本使用本地离线模型进行脱敏处理,然后使用wps灵犀claw进行执行测试、分析、报告编写。

客户原始场景是在一个事务中,先往一个表插入大量当天的数据,然后使用多表关联的结果insert到另一个表,由于前者插入后没有进行统计信息收集,统计信息认为没有这天的数据,而多表关联的sql又带了当天的过滤条件,因此cplan的nestloop时,没有把索引的所有可用字段进行推入,导致了实际执行时进行了大量的Filter,性能非常差。而gplan并不知道条件值是多少,索引的所有字段都推入进去了。所以在此场景下反直觉地出现了gplan表现比cplan更佳。AI自动生成的测试用例和原始场景有点儿区别,但是的确出现了cplan和gplan的谓词推入差异。

13.4 Plan Trace 的意义

Plan Trace能更精细地分析执行计划,这个自不用再提,在现在这个AI AGENT时代,对于一款非开源数据库而言,尽管这个Plan Trace的输出对于人类阅读非常不友好,但AI是能读的,完全可以想象AI AGENT能借助这个Plan Trace来进行自动化的SQL性能分析和优化,这会比只看explain的输出要更准确。

0
  1. 支付宝打赏

    qrcode alipay
  2. 微信打赏

    qrcode weixin
博主关闭了所有页面的评论