本文首发时间 2026-08-21。
背景
这个问题最早是 2023 年 11 月在客户现场的 openGauss 发行版上发现的。当时有一个数据库连接驱动,会去执行一条查询 information_schema.parameters 视图的 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 时,这条 SQL 会报错:
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_name | 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_scale | datetime_precision | interval_type | interval_precision | udt_catalog | udt_schema | udt_name | scope_catalog | scope_schema | 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,都是直接执行不报错、 plain EXPLAIN 不报错、 EXPLAIN ANALYZE 不报错,只在 explain performance 时报错。由于当时项目里有优先级更高的问题,这个有规避方案的问题就被暂时搁置了,后续也没持续跟踪。
GaussDB / openGauss / PostgreSQL
几年后的今天(2026 年 8 月 18 日),我和客户讨论 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 思考过程中,我看到它联想到了原生 PostgreSQL 似乎有类似问题。虽然 explain performance 是 openGauss 独有的,原生 PostgreSQL 不支持,但原生 PG 支持 verbose ,那么 explain verbose 会不会也有这个问题?这里要提一句,kimi k3 没有搞混 openGauss 和 PostgreSQL,在没使用网络搜索的情况下连 performance 这类语法差异细节都注意到了,这点值得好评。
于是我没等 AI 分析完,直接用 dbfiddle 在线逐个 PG 版本验证了一遍:从 PG12 开始就不报错了,PG9.3 到 PG11 的报错和 openGauss 一模一样。这大概率就是一个已知的原生 PG 缺陷,后续版本已经修掉了。
随后我开了个临时免费的 mimo claw,让 AI 去找这个问题在原生 PG 里是哪个 commit 被修复的。AI 分析过程中,我「土法」搜索了一下,发现 PG 社区近年还在讨论这个问题。2024 年,有人在 PG16.3 上执行类似 SQL 也报了同样的错——BUG #18576:
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 是同一个。下面是第三轮对话产出的完整报告。
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 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.6 |
3fc6e2d7f 后优化器直接消除子 Result |
| Join + SRF + 常量 FALSE(你的查询) |
≥ 8.3 |
Join 消除/约束推导等优化消除 SubqueryScan |
information_schema.element_types(Bug #18576) |
≥ 9.6 |
同 Tom 用例机制 |
你的查询在 PG9.3 就报错,是因为 join 场景下优化器通过不同于 3fc6e2d7f 的路径(可能是 join 消除或约束推导)去掉了 SubqueryScan 节点。这条路径从 PG8.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 策略彻底解决了问题。PG11 因已 EOL 未收到修复,PG12 及以上版本均已修复。也就是说,这个修复并不是在 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 AppendPath
create_append_plan 对空 subpaths 生成无子节点的 Result( One-Time Filter: false ,createplan.cpp:1586)。但该 Result 的 targetlist 仍保留原表达式 (ss.x).n ——一个指向 RTE ss 的 RECORD 型 Var 的 FieldSelect
set_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 里顺便提一下本文。这类底层缺陷排查起来确实费神,有空的话也可以到云栈社区翻翻同类的避坑记录。
参考链接
[1] https://dbfiddle.uk/j7FJ4LYX
[2] https://postgrespro.ru/list/thread-id/2708652