PostgreSQL码农集散地

PG 30年子查询脚气被治愈

本期播客

PostgreSQL 30年顽疾终被治愈:子查询终于能用GROUP BY了

SQL标准的本质,是让查询语言的表达能力追上人类思维的自然逻辑。

你曾经是否遇到过这样的场景——写了一个看起来很合理的SQL:

SELECT
    department,  
    (SELECTCOUNT(*) FROM employees e   
WHERE e.salary > AVG(f.salary)) as high_earners  
FROM employees f  
GROUPBY department;  

然后被PostgreSQL无情地驳回:

ERROR:  subquery uses ungrouped column "f.salary" from outer query  

你抓破头皮:明明AVG(f.salary)是在外层GROUP BY department的聚合内计算的,为什么子查询里引用它就报错了?

2026年3月,Tom Lane提交的415100aa补丁,终结了这个困扰PostgreSQL用户近30年的语法限制。 从此,子查询中可以自由引用外层查询的分组表达式,也能正确使用GROUPING()函数了。

第一性原理:为什么子查询里的分组引用会报错?

让我们回到SQL解析的本质。当你写一个带有GROUP BY的查询:

SELECT a + b, COUNT(*) FROM tab GROUPBY a + b;  

PostgreSQL的解析器需要确保:所有不在聚合函数中的列,要么出现在GROUP BY列表中,要么函数依赖于GROUP BY列。

这个规则本身是合理的。但问题出在子查询中:

SELECT
    (SELECT x FROM bar WHERE y = (foo.a + foo.b))  -- 这里引用了外层表达式  
FROM foo  
GROUPBY a + b;  

在解析子查询时,PostgreSQL会“钻”到最底层的列引用(比如foo.a和foo.b),发现它们没有直接出现在外层GROUP BY列表中(外层GROUP BY的是a + b这个整体表达式),于是报错。

技术本质:子查询中的变量(Var)有varlevelsup字段标识它来自外层,而外层的分组表达式已经被“提升”了,两者无法通过简单的equal()函数匹配。这个问题,Tom Lane在1996年(!)的代码注释中就承认了,但一直没找到性价比高的解决方案。

PostgreSQL 2026 年度大戏来了, 扫海报中的二维码报名, 选择早鸟或通票(都含午餐和周边礼品), 可私信我要优惠码, 数量有限先到先得!

图片

破局者:一次巧妙的“偏移量”思维转换

Tom Lane这次提交的补丁,核心洞察是一个漂亮的思维转换:

与其在每次子查询匹配时对子表达式做复杂的递归调整,不如只对GROUP BY列表做一次“层级提升”,然后用普通的equal()比较。

旧的方案要么需要写一个能忽略层级差异的equal()函数(代码量爆炸),要么需要递归调整每个子表达式(指数级性能开销)。新方案的做法是:

  1. 对于每一层子查询,将外层的GROUP BY列表中的表达式做一次IncrementVarSublevelsUp(增加其层级引用)
  2. 然后用调整后的列表与子查询中的表达式进行普通匹配
  3. 每个子查询深度只做一次,复杂度可控

性能数据:Tom Lane的微基准测试显示,这个通用匹配路径比纯列(Var)匹配路径慢约50%。但注意:这是解析阶段的50%——从0.1毫秒到0.15毫秒的差别,对于实际查询执行时间(可能是秒级)来说几乎无感。而且,由于这个特性本就是小众场景,为了正确性付出这点代价完全值得。

附赠修复:GROUPING()在子查询中也正常工作了

在修复这个问题的过程中,Tom Lane还发现了一个相关的bug:GROUPING()函数在子查询中无法正确处理JOIN别名列(join alias vars)。

-- 以前会失败  
SELECT department,   
       (SELECTGROUPING(department) FROM dual)   
FROM employees GROUPBY department;  

这是因为flatten_join_alias_vars函数在子查询中无法正确处理变量层级。补丁引入了新的入口点flatten_join_alias_for_parser(),允许指定层级偏移量,彻底解决了这个问题。

权威案例:这能改变什么?

案例1:复杂报表的简化

某零售公司的分析师需要生成一份报表,包含每个产品类别的销售统计,以及该类别中销售额高于类别平均值的商品数。

旧写法(只能通过CTE或临时表迂回):

WITH class_avg AS (  
SELECTcategory, AVG(amount) as avg_amount  
FROM sales GROUPBYcategory
)  
SELECT c.category,   
       (SELECTCOUNT(*) FROM sales s   
WHERE s.category = c.category   
AND s.amount > c.avg_amount) as above_avg_count  
FROM class_avg c;  

新写法(直接、清晰):

SELECTcategory,  
       (SELECTCOUNT(*) FROM sales s   
WHERE s.amount > AVG(s2.amount)) as above_avg_count  
FROM sales s2  
GROUPBYcategory;  

价值:减少一层嵌套,逻辑更直观,维护成本更低。

案例2:分组内百分比计算

计算每个部门的薪资总额,以及薪资高于部门平均的员工人数占比:

SELECT
    department,  
SUM(salary) as total_salary,  
    (SELECTCOUNT(*) FROM employees e2   
WHERE e2.department = e1.department   
AND e2.salary > AVG(e1.salary)) * 1.0 / COUNT(*) as high_earner_ratio  
FROM employees e1  
GROUPBY department;  

以前这种查询要么写成三个层级的嵌套,要么借助PL/pgSQL函数。现在一行搞定。

第一性原理的边界:什么时候这个优化无效?

这个补丁的前提假设是:你能明确写出外层GROUP BY表达式,并且子查询中的引用在语义上等价。当这个前提崩塌时,我们需要回归基础。

崩塌场景1:聚合嵌套的语义歧义

考虑这个查询:

SELECT
    department,  
    (SELECTAVG(salary) > AVG(e1.salary) FROM ...)  
FROM employees e1  
GROUPBY department;  

子查询中的AVG(e1.salary)到底想表达什么?是希望引用外层当前分组的平均值,还是想在整个子查询上下文中重新计算?这种语义模糊的查询,即使语法上允许,结果也可能不符合预期。

应对策略:用CTE明确表达计算顺序,或者使用窗口函数。

崩塌场景2:外层GROUP BY包含易变函数

SELECT
    date_trunc('hour', created_at) ashour,  
    (SELECTCOUNT(*) FROMlogs l2   
WHERE l2.created_at > MAX(l1.created_at))  -- 引用聚合  
FROMlogs l1  
GROUPBY date_trunc('hour', created_at);  

如果created_at有索引,这个查询的性能可能还OK。但如果date_trunc('hour', created_at)不是函数稳定的(比如用了random()),那么分组本身就失去了意义。

应对策略:确保GROUP BY表达式是确定性的。

崩塌场景3:性能敏感的超大规模聚合

Tom Lane在提交信息中特意提到:这个功能是小众场景。如果你的系统每天处理数亿行数据的聚合查询,并且对解析时间都极度敏感(比如实时流处理),那么引入子查询中的复杂引用可能会增加解析开销。

应对策略:对于核心性能路径,仍然坚持用CTE或临时表预先计算,保持查询计划的稳定性和可预测性。

DBA的迁移指南

1. 更新你的SQL知识库

从现在开始,可以告诉开发团队:子查询中可以引用外层GROUP BY表达式了,也可以使用GROUPING()函数了。更新你们的SQL规范文档。

2. 识别可重构的查询

查找代码库中那些用了两层CTE或者临时表来解决“子查询引用分组值”的查询,尝试用新语法简化:

-- 搜索关键词  
SELECT ... FROM pg_stats WHEREquery ~* 'WITH .* AS .*SELECT .*FROM .*GROUP BY';  

3. 测试边缘情况

虽然Tom Lane的补丁经过了严格测试,但对于你们业务中特殊的复杂查询,建议在测试环境验证:

-- 测试分组表达式引用  
SELECT
    (a + b) as sum_ab,  
    (SELECTcount(*) FROM tab2 WHERE x > AVG(t1.c))   
FROM tab1 t1  
GROUPBY a + b;  

-- 测试GROUPING函数  
SELECT
    a, b,  
    (SELECTGROUPING(a, b) FROM dual)  
FROM tab  
GROUPBYGROUPINGSETS ((a), (b));  

4. 监控解析性能

对于极端的批量查询场景,可以对比新旧版本的解析时间:

\timing on  
-- 运行你的复杂聚合查询  
-- 观察"Parse"阶段的时间  

如果发现解析时间异常增加,可以考虑用EXPLAIN (PARSER)分析(PG19可能新增的选项)。

未来展望

这个补丁打开了更多可能性:

  • 递归CTE中的分组引用:递归查询中引用外层分组值
  • 窗口函数与分组的混合:更灵活的混合使用场景
  • 查询优化器利用这些信息:生成更优的执行计划

结语

Tom Lane用一个巧妙的算法,终结了一个存在30年的语法限制。这再次证明了PostgreSQL社区的价值:不放过任何一个语法死角,即使只有少数用户会用到。

对于DBA而言,这意味着SQL语言的表达能力又向前迈进了一步。从此,当你需要写复杂的分组查询时,可以更接近问题本身的逻辑,而不是为了迁就数据库的限制而绕路。

SQL的真正威力,在于它能让你的思维直接映射为查询——现在这个映射又多了一块拼图。