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()函数(代码量爆炸),要么需要递归调整每个子表达式(指数级性能开销)。新方案的做法是:
对于每一层子查询,将外层的GROUP BY列表中的表达式做一次 IncrementVarSublevelsUp(增加其层级引用)然后用调整后的列表与子查询中的表达式进行普通匹配 每个子查询深度只做一次,复杂度可控
性能数据: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的真正威力,在于它能让你的思维直接映射为查询——现在这个映射又多了一块拼图。