一条带相关子查询的 SQL,在原生 PostgreSQL 里只能串行执行;腾讯云数据库 PostgreSQL 通过改造执行流程,支持了 SubPlan的并行执行,同样的这条 SQL 获得了 4 倍以上的加速。本文从子查询的基本概念讲起,带你看看 SubPlan 并行执行背后的内核原理。
在业务系统里,类似下面这样的 SQL 非常常见------"找出库存量高于同类平均值的商品"、"找出成绩高于本班平均分的学生":

在原生 PostgreSQL 中,这类查询有一个让人头疼的特性:只要查询里出现了相关子查询,整个查询就无法并行执行,哪怕外表有几百万行、哪怕机器有几十个核,也只能单进程从头扫到尾。
我们在腾讯云数据库 PostgreSQL 内核中支持了 SubPlan 并行执行(开启tencentdb_enable_subplan_parallel 参数),让这类查询也能享受多核加速。
| 1 | 什么是子查询? |
子查询(subquery)就是嵌套在另一条 SQL 语句内部的 SELECT 语句。按照出现的位置,常见的有两种:
1.出现在 FROM 子句中,也叫派生表(derived table):

这种子查询在优化器里有被"上拉"(pull up)到外层的机会,合并成一条查询统一规划,通常不是性能瓶颈。
2.出现在 WHERE / SELECT 列表等表达式位置,PostgreSQL 在解析阶段被表示为 SubLink 节点:

第二种又按是否引用了外层查询的列分为两类:
-
**不相关子查询:**子查询完全自包含,不依赖外层的任何变量。
WHERE b > (SELECT avg(b) FROM t2) ------ 不管外层当前是哪一行,子查询的结果都一样。
-
相关子查询(correlated subquery) :子查询里引用了外层的列,比如上面例子里的 t2.a = t1.a。外层每换一行,子查询的语义就跟着变,理论上必须逐行重新计算。
相关子查询是业务 SQL 的常客,只能靠优化器阶段做子查询的拉升(转成JOIN),而对于那些不能转 JOIN 的相关子查询,由于 PostgreSQL 长期不支持带有相关子查询的 SQL 并行,经常导致比较严重的性能劣化。
| 2 | SubPlan 与 InitPlan:一对容易混淆的兄弟 |
优化器在规划子查询时,会把表达式位置的 SubLink 转换成计划树里的一个 SubPlan 执行器节点。但打开 EXPLAIN,你会看到两种不同的显示:SubPlan N 和 InitPlan N。它们虽然都由 SubPlan 节点承载,执行模型却完全不同。
InitPlan:先算一次,结果存进参数
如果子查询是不相关的,规划器会把它做成 InitPlan (初始计划):它在外层计划开始执行前只执行一次,结果被写入一个内部参数槽位(PARAM_EXEC 类型的 Param),外层表达式直接引用这个参数。

注意两个细节:InitPlan 的结果在外层 Filter 里被引用为 (InitPlan 1).col1;而且这条查询整体是并行的 ------InitPlan 自己内部可以并行,外层扫描也可以并行。也就是说,原生 PostgreSQL 对 InitPlan 与并行的兼容是没问题的。
SubPlan:逐行触发,随时重算
如果子查询是相关的 ,它就成了真正的 SubPlan :它作为外层表达式的一部分,在扫描外表时每行都要重新执行(或者每组新的相关参数到达时重算一次)。(注:PostgreSQL 还支持一种 hash 变体(hashed SubPlan):对不相关的 IN/ANY 子查询先建一张哈希表,之后逐行探测,避免反复扫描)。

这就是原生 PostgreSQL的行为:整条查询退化为纯串行------外表是普通的 Seq Scan,连 Gather 节点的影子都没有。
InitPlan 和 SubPlan 的区别如下表:

| 3 | 我们是怎么支持 SubPlan 并行的 |
原生 PG 拒绝并行的三步链条
先看内核里发生了什么。规划器判断一个表达式能否放进并行计划,靠的是 clauses.c 中的 max_parallel_hazard_walker。对相关子查询,存在一条环环相扣的"连坐"链条:
**1.相关参数被判定为并行受限。**规划子查询时,外层的 t1.a 会被换算成一个内部参数(PARAM_EXEC)传进子查询。这个参数出现在子查询的过滤条件里(t2.a = $1),而并行检查针对 Param 节点的规则是:凡是不能由 leader 算好再广播给 worker 的 PARAM_EXEC 参数,一律视为 parallel-restricted。
**2.子计划内部无法生成并行路径。**带着这个受限参数,子查询自己的扫描路径全部不满足 parallel_safe,这个 parallel_safe=false 的情况也顺延传递给了 SubPlan 节点。
**3.外层表达式被"连坐"。**外层 Filter 是 b > (SubPlan 1),并行检查遇到 SubPlan 节点时检查它的 parallel_safe 标志是 false,整个 Filter 被判为受限,于是外表的并行扫描路径全部作废,整条查询串行。
注意这里的关键:这条链条其实过度保守了。
为什么翻转 parallel_safe 是安全的?
parallel-restricted 的本意是"这个计算不能交给并行 worker"。对于参数,担心的是"参数由一个进程写入、另一个进程读取"------跨进程确实做不到。
但相关 SubPlan 的执行模型是:子计划整体嵌在外层表达式里,参数的写入(外层当前行的值)和读取(子查询里的引用)发生在同一个进程的同一个执行器状态(EState)中。并行查询中,每个 worker 会收到 Gather 之下完整计划树的一份副本,包括 SubPlan 及其参数槽位。worker 处理自己分到的外表数据块时,用自己的行值设置参数、再自己执行子计划------全程单进程内闭环,根本不存在跨进程共享。
换句话说:相关 SubPlan 天然是"进程内自治"的,把它标成 parallel_safe 在语义上是成立的,原生 PostgreSQL 只是用了一刀切的参数规则。
因此,如果我们能够把 SubPlan 的粒度划分的细一些,就会发现,有些 SubPlan 支持并行执行是完全没有问题的,PostgreSQL 现在一刀切的政策是一种懒政。
腾讯云数据库 PostreSQL 在支持 SubPlan 并行的时候,也严谨的指定了以下边界:
-
**有 CTE 就放弃:**任一上层查询带 CTE 时 hook 直接退出,保持原生行为。CTE 子计划在原生 PostgreSQL 中永远是 parallel_safe = false(CteScan 之间通过共享参数槽位协调),我们不去动它。
-
**只对真相关 SubPlan 生效:**不相关子查询原生已能与并行共存,无需干预。
-
**非 SELECT 语句不支持:**原生 PostgreSQL的顶层规划器对非 CMD_SELECT 语句直接不考虑并行路径,UPDATE ... SET x = (SELECT ...) 之类不在本特性范围内。
| 4 | SubPlan 并行的性能优势 |
测试环境
-
32 核测试机,基于 PostgreSQL 18 的内核。
-
外表 bench_t1:50 万行;内表 bench_t2:100 万行,t2(a) 上有索引。
-
查询即本文开头的那条相关子查询,数据全部预热到 shared buffer。
-
max_parallel_workers_per_gather = 4,bench_t1 强制 4 个并行 worker。
执行计划长这样
GUC 打开后,外表变成并行扫描,逐行触发的 SubPlan 保持原样、跟随计划副本进入每个 worker:

实测数据
每组跑 3 次取典型值,两份计划的结果完全一致 ,但并行 SubPlan 性能提升4.2x:

EXPLAIN ANALYZE 里的一个细节很能说明问题:

SubPlan 1 的 loops=500000------子查询的总执行次数一点没少,50 万行还是 50 万次。区别在于:串行时这 50 万次子查询全部压在一个进程上;并行后,leader 和 4 个 worker 各自认领 10 万行,各自执行自己那 10 万次子查询。总工作量不变,墙钟时间被 5 个进程摊薄了,这是接近线性加速的本质原因。
什么时候收益最大
-
**外表行数多:**子查询是逐行触发的,外表行数就是子查询执行次数,行数越多,被摊薄的工作量越大。
-
**子查询单次执行不便宜:**哪怕每次只是一次索引扫描 + 聚合,乘上百万级的执行次数也是大头。
-
反之,外表很小(比如几千行)时,worker 启动和 Gather 的固定开销可能吃掉收益------不过这无需担心,代价模型会替用户把关,不值得并行时它不会选并行计划。
| 5 | 结语 |
相关子查询是业务 SQL 中最自然的写法之一,"因为写了子查询所以无法并行"对用户来说始终是一种遗憾。SubPlan 并行的核心洞察其实很朴素:相关子查询的参数读写天然封闭在单个进程的执行器状态里,把它放行给并行 worker 是语义安全的,原生 PostgreSQL 的参数规则只是过于一刀切。
如果你也有关联子查询跑不快的困扰,不妨在自己的工作负载上试试tencentdb_enable_subplan_parallel。