【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/j7FJ4LYX 用explain 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.c→get_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.2 ❌ get_name_for_var_field尚未依赖 SubqueryScan 节点PG 8.3 ✅ Tom Lane 于 2007-02-23 引入(CVS r1.251) PG 8.4 ~ PG 9.5 ✅ 持续存在,但特定查询模式下未必触发 PG 9.6 ✅ 3fc6e2d7f引入新的触发路径(见下文)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 atrte->subqueryto 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.6 3fc6e2d7f后优化器直接消除子 ResultJoin + SRF + 常量 FALSE(你的查询) ≥ 8.3 Join 消除/约束推导等优化消除 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.c、src/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 11 2023-11-09 ❌ 已 EOL,未修复 PG 12 2024-11-14 ✅ PG 13 2025-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 个条件:
- WHERE 含常量假条件(
1<>1),整个查询被优化成无子节点的 dummy plan- 子查询无法被展平消除
- 目标列引用了子查询输出中 RECORD 类型字段的成员
(ss.x).n- EXPLAIN 需要打印 Output 列表(VERBOSE,或 PERFORMANCE——explain.cpp:414 强制
verbose=true)完整因果链(源码级):
1<>1经常量折叠成 FALSE,make_join_rel中restriction_is_constant_false()命中 →mark_dummy_rel()(joinrels.cpp:677),joinrel 得到无子路径的 dummy AppendPathcreate_append_plan对空 subpaths 生成无子节点的 Result(One-Time Filter: false,createplan.cpp:1586)。但该 Result 的 targetlist 仍保留原表达式(ss.x).n——一个指向 RTEss的 RECORD 型 Var 的 FieldSelectset_plan_references展平 rangetable 时清空了newrte->subquery = NULL(setrefs.cpp:236),最终计划里 RTE 已不带子查询 Query 树- VERBOSE 打印 "Output:" 时 →
get_rule_expr处理 FieldSelect(ruleutils.cpp:10379)→get_name_for_var_field需要下钻解析 record 字段名- 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里顺便提一下我这篇文章。