目 录CONTENT

文章目录

【GaussDB】一个原生PG延续十七年的BUG被继承,SQL执行不报错,explain报错

DarkAthena
2026-08-21 / 0 评论 / 0 点赞 / 3 阅读 / 0 字

【GaussDB】一个原生PG延续十七年的BUG被继承,SQL执行不报错,explain报错

背景

其实这个问题最早我是在几年前(2023年11月),客户现场openGauss的一个发行版上发现的,当时有一个数据库连接驱动,会去执行一个这样的SQL

select parameter_mode, parameter_name, pg_type.oid 
from information_schema.parameters 
join pg_catalog.pg_type 
on pg_type.typname = udt_name 
where upper(specific_name) = upper('%') 
and upper(specific_schema) = upper('%') 
order by ordinal_position; 

当参数为值为null时,会报错:

ERROR: failed to find plan for subquery ss

分析与临时解决

当时这套应用已经上线生产环境了,在生产环境上没有报错,测试环境上报错。客户对比两个环境的数据库参数,发现有个参数不一样,track_stmt_stat_level 在生产环境是OFF,L0,在测试环境是OFF,L1

当时我和我们内核研发一起定位,分析出了原因,是因为information_schema.parameters这个视图里有个子查询,由于任何值=null恒为false,因此子查询实际不需要再产生执行计划,可以被直接裁剪掉,但由于子查询的一个嵌套列在子查询外面被引用作为目标列,反解析目标列名称的时候发现没有这个子查询的计划,就报错了。

原本的视图长这样

CREATE OR REPLACE VIEW information_schema.parameters
AS SELECT current_database()::information_schema.sql_identifier AS specific_catalog, 
    ss.n_nspname::information_schema.sql_identifier AS specific_schema, 
    ((ss.proname::text || '_'::text) || ss.p_oid::text)::information_schema.sql_identifier AS specific_name, 
    (ss.x).n::information_schema.cardinal_number AS ordinal_position, 
        CASE
            WHEN ss.proargmodes IS NULL THEN 'IN'::text
            WHEN ss.proargmodes[(ss.x).n] = 'i'::"char" THEN 'IN'::text
            WHEN ss.proargmodes[(ss.x).n] = 'o'::"char" THEN 'OUT'::text
            WHEN ss.proargmodes[(ss.x).n] = 'b'::"char" THEN 'INOUT'::text
            WHEN ss.proargmodes[(ss.x).n] = 'v'::"char" THEN 'IN'::text
            WHEN ss.proargmodes[(ss.x).n] = 't'::"char" THEN 'OUT'::text
            ELSE NULL::text
        END::information_schema.character_data AS parameter_mode, 
    'NO'::character varying::information_schema.yes_or_no AS is_result, 
    'NO'::character varying::information_schema.yes_or_no AS as_locator, 
    NULLIF(ss.proargnames[(ss.x).n], NULL::text)::information_schema.sql_identifier AS parameter_name, 
        CASE
            WHEN t.typelem <> 0::oid AND t.typlen = (-1) THEN 'ARRAY'::text
            WHEN nt.nspname = 'pg_catalog'::name THEN format_type(t.oid, NULL::integer)
            ELSE 'USER-DEFINED'::text
        END::information_schema.character_data AS data_type, 
    NULL::integer::information_schema.cardinal_number AS character_maximum_length, 
    NULL::integer::information_schema.cardinal_number AS character_octet_length, 
    NULL::character varying::information_schema.sql_identifier AS character_set_catalog, 
    NULL::character varying::information_schema.sql_identifier AS character_set_schema, 
    NULL::character varying::information_schema.sql_identifier AS character_set_name, 
    NULL::character varying::information_schema.sql_identifier AS collation_catalog, 
    NULL::character varying::information_schema.sql_identifier AS collation_schema, 
    NULL::character varying::information_schema.sql_identifier AS collation_name, 
    NULL::integer::information_schema.cardinal_number AS numeric_precision, 
    NULL::integer::information_schema.cardinal_number AS numeric_precision_radix, 
    NULL::integer::information_schema.cardinal_number AS numeric_scale, 
    NULL::integer::information_schema.cardinal_number AS datetime_precision, 
    NULL::character varying::information_schema.character_data AS interval_type, 
    NULL::integer::information_schema.cardinal_number AS interval_precision, 
    current_database()::information_schema.sql_identifier AS udt_catalog, 
    nt.nspname::information_schema.sql_identifier AS udt_schema, 
    t.typname::information_schema.sql_identifier AS udt_name, 
    NULL::character varying::information_schema.sql_identifier AS scope_catalog, 
    NULL::character varying::information_schema.sql_identifier AS scope_schema, 
    NULL::character varying::information_schema.sql_identifier AS scope_name, 
    NULL::integer::information_schema.cardinal_number AS maximum_cardinality, 
    (ss.x).n::information_schema.sql_identifier AS dtd_identifier
   FROM pg_type t, pg_namespace nt, 
    ( SELECT n.nspname AS n_nspname, p.proname, p.oid AS p_oid, p.proargnames, 
            p.proargmodes, 
            information_schema._pg_expandarray(COALESCE(p.proallargtypes, p.proargtypes::oid[])) AS x
           FROM pg_namespace n, pg_proc p
          WHERE n.oid = p.pronamespace AND (pg_has_role(p.proowner, 'USAGE'::text) OR has_function_privilege(p.oid, 'EXECUTE'::text))) ss
  WHERE t.oid = (ss.x).x AND t.typnamespace = nt.oid;

关键就在 (ss.x).n 的引用和 ss 这个子查询,我当时的紧急方案是修改这个视图,解了一层嵌套,把(ss.x).n转化为 ss.n如下:

CREATE OR REPLACE VIEW information_schema.parameters
AS SELECT current_database()::information_schema.sql_identifier AS specific_catalog, 
    ss.n_nspname::information_schema.sql_identifier AS specific_schema, 
    ((ss.proname::text || '_'::text) || ss.p_oid::text)::information_schema.sql_identifier AS specific_name, 
    ss.n::information_schema.cardinal_number AS ordinal_position, 
        CASE
            WHEN ss.proargmodes IS NULL THEN 'IN'::text
            WHEN ss.proargmodes[ss.n] = 'i'::"char" THEN 'IN'::text
            WHEN ss.proargmodes[ss.n] = 'o'::"char" THEN 'OUT'::text
            WHEN ss.proargmodes[ss.n] = 'b'::"char" THEN 'INOUT'::text
            WHEN ss.proargmodes[ss.n] = 'v'::"char" THEN 'IN'::text
            WHEN ss.proargmodes[ss.n] = 't'::"char" THEN 'OUT'::text
            ELSE NULL::text
        END::information_schema.character_data AS parameter_mode, 
    'NO'::character varying::information_schema.yes_or_no AS is_result, 
    'NO'::character varying::information_schema.yes_or_no AS as_locator, 
    NULLIF(ss.proargnames[ss.n], NULL::text)::information_schema.sql_identifier AS parameter_name, 
        CASE
            WHEN t.typelem <> 0::oid AND t.typlen = (-1) THEN 'ARRAY'::text
            WHEN nt.nspname = 'pg_catalog'::name THEN format_type(t.oid, NULL::integer)
            ELSE 'USER-DEFINED'::text
        END::information_schema.character_data AS data_type, 
    NULL::integer::information_schema.cardinal_number AS character_maximum_length, 
    NULL::integer::information_schema.cardinal_number AS character_octet_length, 
    NULL::character varying::information_schema.sql_identifier AS character_set_catalog, 
    NULL::character varying::information_schema.sql_identifier AS character_set_schema, 
    NULL::character varying::information_schema.sql_identifier AS character_set_name, 
    NULL::character varying::information_schema.sql_identifier AS collation_catalog, 
    NULL::character varying::information_schema.sql_identifier AS collation_schema, 
    NULL::character varying::information_schema.sql_identifier AS collation_name, 
    NULL::integer::information_schema.cardinal_number AS numeric_precision, 
    NULL::integer::information_schema.cardinal_number AS numeric_precision_radix, 
    NULL::integer::information_schema.cardinal_number AS numeric_scale, 
    NULL::integer::information_schema.cardinal_number AS datetime_precision, 
    NULL::character varying::information_schema.character_data AS interval_type, 
    NULL::integer::information_schema.cardinal_number AS interval_precision, 
    current_database()::information_schema.sql_identifier AS udt_catalog, 
    nt.nspname::information_schema.sql_identifier AS udt_schema, 
    t.typname::information_schema.sql_identifier AS udt_name, 
    NULL::character varying::information_schema.sql_identifier AS scope_catalog, 
    NULL::character varying::information_schema.sql_identifier AS scope_schema, 
    NULL::character varying::information_schema.sql_identifier AS scope_name, 
    NULL::integer::information_schema.cardinal_number AS maximum_cardinality, 
    ss.n::information_schema.sql_identifier AS dtd_identifier
   FROM pg_type t, pg_namespace nt, 
    ( SELECT n.nspname AS n_nspname, p.proname, p.oid AS p_oid, p.proargnames, 
            p.proargmodes, 
            information_schema._pg_expandarray(COALESCE(p.proallargtypes, p.proargtypes::oid[])) AS x,
            (information_schema._pg_expandarray(COALESCE(p.proallargtypes, p.proargtypes::oid[]))).n as n
           FROM pg_namespace n, pg_proc p
          WHERE n.oid = p.pronamespace AND (pg_has_role(p.proowner, 'USAGE'::text) OR has_function_privilege(p.oid, 'EXECUTE'::text))) ss
  WHERE t.oid = (ss.x).x AND t.typnamespace = nt.oid;

简化用例

然后我简化出来了两个用例,不需要调整参数即可复现

MogDB=# select * from information_schema.parameters where specific_name = null ;
 specific_catalog | specific_schema | specific_name | ordinal_position | parameter_mode | is_result | as_locator | parameter_n
ame | data_type | character_maximum_length | character_octet_length | character_set_catalog | character_set_schema | character
_set_name | collation_catalog | collation_schema | collation_name | numeric_precision | numeric_precision_radix | numeric_scal
e | datetime_precision | interval_type | interval_precision | udt_catalog | udt_schema | udt_name | scope_catalog | scope_sche
ma | scope_name | maximum_cardinality | dtd_identifier
------------------+-----------------+---------------+------------------+----------------+-----------+------------+------------
----+-----------+--------------------------+------------------------+-----------------------+----------------------+----------
----------+-------------------+------------------+----------------+-------------------+-------------------------+-------------
--+--------------------+---------------+--------------------+-------------+------------+----------+---------------+-----------
---+------------+---------------------+----------------
(0 rows)

MogDB=# explain select * from information_schema.parameters where specific_name = null ;
                QUERY PLAN
------------------------------------------
 Result  (cost=0.00..0.06 rows=1 width=0)
   One-Time Filter: false
(2 rows)

MogDB=# explain analyze select * from information_schema.parameters where specific_name = null ;
                                     QUERY PLAN
------------------------------------------------------------------------------------
 Result  (cost=0.00..0.06 rows=1 width=0) (actual time=0.009..0.009 rows=0 loops=1)
   One-Time Filter: false
 Total runtime: 1.893 ms
(3 rows)

MogDB=# explain performance select * from information_schema.parameters where specific_name = null ;
ERROR:  failed to find plan for subquery ss
MogDB=#

explain performance 
SELECT  (ss.x).n AS ordinal_position
    FROM pg_type t,
         (SELECT information_schema._pg_expandarray(p.proallargtypes) AS x
          FROM  pg_proc p
          WHERE proname='aclexplode') AS ss
    WHERE t.oid = (ss.x).x and 1<>1;

像上面这两个SQL,都是执行不报错、explain 不报错、explain analyze 不报错,只在 explain performance 报错。

然后由于当时项目中有优先级更高的问题,这个有规避方案的问题就暂且搁置了,后续我也没有持续跟踪。

GaussDB/openGauss/postgresql

几年后的今天(20260818),我和客户在讨论GaussDB为什么不在statement_history记录更准确的细化到每步的开销,而是只记了个参考的。我说记录更详细对性能的影响更大,而且explain performance和实际执行,走的逻辑其实是有区别的,正好举出了本文的例子,对于同一个SQL,直接查询不报错,explain performance报错。

提到这,我就顺便在GaussDB最新的507版本上测试了一下,发现该问题在507版本上依然存在:

gaussdb=# EXPLAIN PERFOrMANCE
SELECT  (ss.x).n AS ordinal_position
    FROM pg_type t,
         (SELECT information_schema._pg_expandarray(p.proallargtypes) AS x
          FROM  pg_proc p
          WHERE proname='aclexplode') AS ss
    WHERE t.oid = (ss.x).x and 1<>1;
ERROR:  failed to find plan for subquery ss
gaussdb=# SELECT  (ss.x).n AS ordinal_position
gaussdb-#     FROM pg_type t,
gaussdb-#          (SELECT information_schema._pg_expandarray(p.proallargtypes) AS x
gaussdb(#           FROM  pg_proc p
gaussdb(#           WHERE proname='aclexplode') AS ss
gaussdb-#     WHERE t.oid = (ss.x).x and 1<>1;
 ordinal_position 
------------------
(0 rows)

然后又顺便在最新的openGauss 7.0上测了下,发现问题也还在,就打算让AI基于openGauss源码分析一下这个问题。

这次我用了copilotcli接了opencode go的kimi k3,输出贼慢。

AI思考过程中,我看到AI联想到了原生PG似乎有类似问题,但是explain performance是openGauss独有的,原生PG不支持,但支持 verbose,那么会不会explain verbose 也有这个问题?(kimi k3不会搞混openGauss和postgresql,在未使用网络搜索的情况下,performance这种语法差异细节都注意到了,这点好评)

于是我没等AI分析完,直接去 https://dbfiddle.uk/j7FJ4LYXexplain verbose的方式,在线逐个PG版本进行验证,发现从PG12开始就不报错了,PG9.3到PG11的报错和openGauss一模一样。

这大概率就是一个已知的原生PG的BUG了,后面版本进行了修复。

于是我开了个临时免费的mimo claw,让AI去找下这个问题在原生PG里是哪个commit被修复的。

AI分析过程中,我"土法"搜索了一下,发现了PG社区上这个问题近年来还在被讨论。2024年,有人在PG16.3上执行一个类似的SQL也报错了
BUG #18576: Using EXPLAIN (VERBOSE) in information_schema.element_types returns ERROR: failed to find plan for

Bug reference: 18576
Logged by: Vasya B
Email address: vasiliy.boytsov@gmail.com
PostgreSQL version: 16.3
Operating system: Ubuntu 24.04
Description:

From a clean DB, one can execute:
EXPLAIN (VERBOSE) SELECT FROM information_schema.element_types WHERE
object_type = 'TABLE';
Which returns:
ERROR: failed to find plan for subquery ss
While the expected result was a working query.
W/O VERBOSE this query works.

然后mimo claw的分析结果也出来了,和我土法搜索得到的BUG是同一个(mimo有点偷懒,下面这个报告是第三轮对话的结果)

好,现在信息完整了。以下是最终报告。


PostgreSQL EXPLAIN (VERBOSE) 报错 failed to find plan for subquery 完整分析

一、问题复现

EXPLAIN (VERBOSE)
SELECT (ss.x).n AS ordinal_position
FROM pg_type t,
    (SELECT information_schema._pg_expandarray(p.proallargtypes) AS x
     FROM pg_proc p
     WHERE proname='aclexplode') AS ss
WHERE t.oid = (ss.x).x AND 1<>1;

在多个 PG 版本上执行均报错:

ERROR:  failed to find plan for subquery ss

二、错误代码位置

src/backend/utils/adt/ruleutils.cget_name_for_var_field() 函数。

当 EXPLAIN 尝试反解 (ss.x).n 这种"对子查询 RECORD 类型输出的字段引用"时,需要找到子查询的执行计划来确定字段的真实类型。核心逻辑如下:

/*
* We're deparsing a Plan tree so we don't have complete
* RTE entries (in particular, rte->subquery is NULL). But
* the only place we'd see a Var directly referencing a
* SUBQUERY RTE is in a SubqueryScan plan node, and we can
* look into the child plan's tlist instead.
*/
if (!dpns->inner_plan)          /* PG 9.3 用 inner_planstate */
   elog(ERROR, "failed to find plan for subquery %s",
        rte->eref->aliasname);

设计假设:引用 SUBQUERY RTE 的 Var 一定出现在 SubqueryScan 计划节点中。当优化器以任何方式消除了 SubqueryScan 节点,inner_plan 为 NULL,触发报错。

三、错误引入时间线

版本是否存在该 elog说明
PG 8.2get_name_for_var_field 尚未依赖 SubqueryScan 节点
PG 8.3Tom Lane 于 2007-02-23 引入(CVS r1.251)
PG 8.4 ~ PG 9.5持续存在,但特定查询模式下未必触发
PG 9.63fc6e2d7f 引入新的触发路径(见下文)
PG 10 ~ PG 11同上
PG 12+✅→修复2024-08-09 修复并 back-patch

引入 commit(2007-02-23,Tom Lane,开发版本 8.3):

Now that plans have flat rangetable lists, it's a lot easier to get EXPLAIN to drill down into subplan targetlists... Along the way, fix an EXPLAIN bug I introduced by suppressing subqueries from execution-time range tables: get_name_for_var_field() assumed it could look at rte->subquery to find out the real type of a RECORD var. That doesn't work anymore, but instead we can look at the input plan of the SubqueryScan plan node.

这次重构把 get_name_for_var_field() 从"读取 RTE 的 subquery 字段"改为"读取 SubqueryScan 子计划的 targetlist"。当 SubqueryScan 不存在时,就产生了这个错误。

四、后续恶化的触发路径

commit 3fc6e2d7f5b652b417fa6937c34de2438d60fa9f(2016-03-07,Tom Lane,PG 9.6)

Make the upper part of the planner work by generating and comparing Paths.

这是一个大规模的优化器重构。在此之前,常量 FALSE 条件下的子查询会被优化为 Result -> Result(子 Result 保留了原始 targetlist),get_name_for_var_field 仍能从中获取字段信息。重构之后,优化器直接去掉了子 Result 节点,只保留一个空的父 Result,导致该函数在更多场景下触发报错。

Tom Lane 对简化用例的 bisection 结果:9.5 正常,9.6 开始失败。

五、不同查询模式触发同一错误

该错误可以通过不同的优化路径触发:

查询模式触发版本触发机制
简单 SRF + 常量 FALSE(Tom 的测试用例)≥ 9.63fc6e2d7f 后优化器直接消除子 Result
Join + SRF + 常量 FALSE(你的查询)≥ 8.3Join 消除/约束推导等优化消除 SubqueryScan
information_schema.element_types(Bug #18576)≥ 9.6同 Tom 用例机制

你的查询在 PG 9.3 就报错,是因为 join 场景下优化器通过不同于 3fc6e2d7f 的路径(可能是 join 消除或约束推导)去掉了 SubqueryScan 节点。这条路径从 PG 8.3 起就存在。

六、修复

commit 120dd033761dbd70ad6267ff7255430bc8a989ed(2024-08-09)

  • 作者:Richard Guo & Tom Lane
  • Bug:#18576
  • 修改文件:src/backend/utils/adt/ruleutils.csrc/backend/optimizer/plan/createplan.c

修复逻辑:当发现子查询已被优化为空的 Result 节点(无 inner_plan)时,不再 elog(ERROR),而是 fallback 返回 "fN"(N 为字段序号)作为字段名:

if (!dpns->inner_plan)
{
   char *dummy_name = palloc(32);
   Assert(IsA(dpns->plan, Result));
   snprintf(dummy_name, 32, "f%d", fieldno);
   return dummy_name;
}

Back-patch 情况

版本EOL 时间是否收到修复
PG 112023-11-09❌ 已 EOL,未修复
PG 122024-11-14
PG 132025-11-13
PG 14+更晚

七、总结

该错误的本质是 ruleutils.c 中一个存在了 17 年的设计缺陷(2007—2024):get_name_for_var_field() 假设引用子查询的 Var 一定有对应的 SubqueryScan 计划节点,但优化器可以在多种场景下消除该节点。2016 年的优化器重构(3fc6e2d7f)扩大了触发面,2024 年的修复(120dd033761dbd70ad6267ff7255430bc8a989ed)通过 fallback 策略彻底解决了问题。PG 11 因已 EOL 未收到修复,PG 12 及以上版本均已修复。
原来并不是在PG12上进行的修复,而是2024年8月份修的,只是这个修复回合到PG12上去了。

kimi k3的openGauss源码分析结果也出来了

分析完成。所有环节均已在这台运行实例(同版本 3700f950)上复现验证。

结论:这是 EXPLAIN 反解析(deparse)的一个缺陷,不是查询本身的问题

触发需同时满足 4 个条件:

  1. WHERE 含常量假条件(1<>1),整个查询被优化成无子节点的 dummy plan
  2. 子查询无法被展平消除
  3. 目标列引用了子查询输出中 RECORD 类型字段的成员 (ss.x).n
  4. EXPLAIN 需要打印 Output 列表(VERBOSE,或 PERFORMANCE——explain.cpp:414 强制 verbose=true

完整因果链(源码级):

  1. 1<>1 经常量折叠成 FALSE,make_join_relrestriction_is_constant_false() 命中 → mark_dummy_rel()(joinrels.cpp:677),joinrel 得到无子路径的 dummy AppendPath
  2. create_append_plan 对空 subpaths 生成无子节点的 ResultOne-Time Filter: false,createplan.cpp:1586)。但该 Result 的 targetlist 仍保留原表达式 (ss.x).n——一个指向 RTE ss 的 RECORD 型 Var 的 FieldSelect
  3. set_plan_references 展平 rangetable 时清空了 newrte->subquery = NULL(setrefs.cpp:236),最终计划里 RTE 已不带子查询 Query 树
  4. VERBOSE 打印 "Output:" 时 → get_rule_expr 处理 FieldSelect(ruleutils.cpp:10379)→ get_name_for_var_field 需要下钻解析 record 字段名
  5. RTE_SUBQUERY 分支中 rte->subquery == NULL,只能走"plan-tree"分支,期望通过 SubqueryScan 节点的 inner_planstate 找子计划——但计划树里根本没有 SubqueryScan 节点(顶层是无子节点 Result)→ ruleutils.cpp:9402 报错

验证过的边界情况:

场景结果
原查询直接执行 / plain EXPLAIN / EXPLAIN ANALYZE正常(不反解析 Output)
EXPLAIN VERBOSE / PERFORMANCE报错
去掉 1<>1 或改 1=1正常(SubqueryScan 存在)
只输出 ss.x(不取 .n 字段)+ 常假正常(走 get_variable 简单路径)
子查询可展平时(如 case C)正常(Var 被内联,不再引用 RTE ss)

本质:优化器可以生成彻底消除 SubqueryScan 节点的 dummy plan,但反解析器的 plan-tree 回退分支假设"凡引用 RTE_SUBQUERY 必有对应 SubqueryScan 节点",两者不匹配。修复方向:ruleutils.cpp:9402 处在 inner_planstate == NULL 时,对无法解析的 record 字段降级处理(如用 rte->eref 中的别名兜底),而非直接 ereport。

临时规避:用 plain EXPLAIN 或 EXPLAIN ANALYZE;或去掉常量假条件;或避免在可能被整体短路的查询中对子查询 record 列取字段。

总结

简单来说,这个BUG的表现就是,特定的一些SQL,直接执行不报错,但explain performance 或者explain verbose会报错,或者track_stmt_stat_level第二个值开到L1时再执行SQL会报错。

当年遇到的一个小问题,如今回过头来细查,竟然翻出了个在postgresql上持续17年的BUG。要说准确点,其实我这个BUG场景是没有被其他人报告的:2007年被 Tom Lane 引入,2016年又被 Tom Lane 做了另一个BUG路径的错误修复,引来了2024年 Richard Guo 的再次修复,巧合之下把我遇到这个的场景也修复了。

回想起来,我发现这个问题时是2023年,当时正在做一个非常重要的项目,非常忙,文章都写得少了(全年只发布10篇),也没往原生PG上想,要不然这个问题至少报告人就是我了。有意思的是,这个修复人 Richard Guo(郭峰) 是个中国人,是PG的 Major Contributor 之一。

至于openGauss里这个BUG我要不要去修,我暂时没心情。如果有谁看到了我这篇文章想去修的话,可以在issue里顺便提一下我这篇文章。

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