PostgreSQL 19 为聚合函数开放了一个新的 planner support hook:SupportRequestSimplifyAggref。它允许扩展在规划阶段改写聚合表达式,而不需要修改 PostgreSQL 核心代码。
这个能力看似只是一个新的扩展接口,实际用途却很直接:清理应用生成器留下的冗余 ORDER BY,或者针对数据的固定精度和小数位,为 SUM(numeric) 替换更快的实现。
一个常见但昂贵的冗余排序
考虑下面两个查询:
SELECT sum(amount ORDER BY amount)
FROM invoice;
SELECT sum(amount)
FROM invoice;
对于普通的 SUM 来说,输入值的顺序不会改变结果。可是第一条查询明确要求聚合前排序,执行计划可能因此出现一个大范围的 Sort 节点。
可以用下面的 SQL 建立一个可复现实验。运行前请根据机器资源调整行数:
CREATE TABLE invoice AS
SELECT ((g % 100000)::numeric / 100)::numeric(12, 2) AS amount
FROM generate_series(1, 10000000) AS g;
ANALYZE invoice;
EXPLAIN (ANALYZE, BUFFERS)
SELECT sum(amount ORDER BY amount)
FROM invoice;
EXPLAIN (ANALYZE, BUFFERS)
SELECT sum(amount)
FROM invoice;
在数据量较大、排序无法完全留在内存时,排序可能消耗总执行时间的相当一部分。更重要的是,这种 SQL 往往不是开发者手写的,而是 ORM、报表工具或动态查询生成器产生的。修改所有上游生成逻辑通常比修改数据库扩展更困难。
新 hook 能做什么
PostgreSQL 的 planner support function 本质上是一个 C 函数。规划器传入一个 request node,函数可以返回改写后的节点;如果当前场景不适用,则返回 NULL。
对于聚合,关键请求类型是 SupportRequestSimplifyAggref。规划器会传入一个 Aggref 节点,扩展可以返回一个新的聚合节点替换它。规则有三条:
- 不要修改规划器传入的原始节点;
- 改写后返回一个新节点;
- 不满足优化条件时尽早返回
NULL。
下面是一个精简的、便于改造的 C 代码骨架。它展示的是核心思路,不是完整可直接安装的扩展文件;真正的扩展还需要加入 PG_MODULE_MAGIC、函数声明、Makefile 和版本兼容处理。
#include "postgres.h"
#include "catalog/pg_aggregate.h"
#include "catalog/pg_proc.h"
#include "nodes/nodeFuncs.h"
#include "nodes/pathnodes.h"
#include "nodes/plannodes.h"
#include "nodes/supportnodes.h"
#include "optimizer/optimizer.h"
#include "utils/lsyscache.h"
PG_FUNCTION_INFO_V1(sum_order_support);
Datum
sum_order_support(PG_FUNCTION_ARGS)
{
Node *rawreq = (Node *) PG_GETARG_POINTER(0);
if (rawreq == NULL)
PG_RETURN_POINTER(NULL);
if (IsA(rawreq, SupportRequestSimplifyAggref))
{
SupportRequestSimplifyAggref *req =
(SupportRequestSimplifyAggref *) rawreq;
Aggref *oldagg = req->aggref;
Aggref *newagg;
/* 当前示例只处理普通聚合。 */
if (oldagg->aggkind != AGGKIND_NORMAL)
PG_RETURN_POINTER(NULL);
/* DISTINCT 仍然需要去重,不能简单删除排序。 */
if (oldagg->aggdistinct != NIL)
PG_RETURN_POINTER(NULL);
/* 没有 ORDER BY 时无需改写。 */
if (oldagg->aggorder == NIL)
PG_RETURN_POINTER(NULL);
/* SUM 的简单示例只接受一个输入参数。 */
if (list_length(oldagg->args) != 1)
PG_RETURN_POINTER(NULL);
/* 复制节点,不要修改规划器传入的原节点。 */
newagg = copyObject(oldagg);
newagg->aggorder = NIL;
/* 删除排序后,清除排序分组标记。 */
newagg->args = list_copy(newagg->args);
foreach (ListCell *lc, newagg->args)
{
TargetEntry *tle = (TargetEntry *) lfirst(lc);
tle->ressortgroupref = 0;
}
PG_RETURN_POINTER(newagg);
}
PG_RETURN_POINTER(NULL);
}
生产实现需要更严格地检查聚合函数 OID、输入类型、resjunk 参数以及其他节点结构。特别要注意下面这种查询:
SELECT sum(x ORDER BY y)
FROM t;
如果排序列 y 并不等于被求和的表达式 x,删除排序可能改变语义。排序表达式可能作为带有 resjunk 标记的参数出现在聚合节点中,因此不能仅仅看到 SUM 和 ORDER BY 就直接改写。
为什么要拒绝 DISTINCT 和特殊聚合
SUM(DISTINCT amount) 的排序并非冗余:聚合需要先完成去重。除非 PostgreSQL 后续能用哈希方式完成聚合内部的 DISTINCT,否则扩展不应删除这类排序。
同样,ordered-set aggregate 和 hypothetical-set aggregate 具有明确的顺序语义。一个稳妥的 fast path 应该尽早排除:
if (oldagg->aggkind != AGGKIND_NORMAL)
return NULL;
if (oldagg->aggdistinct != NIL)
return NULL;
if (oldagg->aggorder == NIL)
return NULL;
这类“无法帮助就尽快退出”的写法很重要。support function 会在规划阶段被调用,错误匹配可能导致计划树不一致,甚至在后续检查时让查询失败。优化扩展首先应该保证安全,其次才是覆盖更多表达式。
把 support function 挂到聚合上
普通函数可以通过 CREATE FUNCTION ... SUPPORT 或 ALTER FUNCTION ... SUPPORT 关联 support function。但内置聚合存在一个现实限制:当前社区 PostgreSQL 没有公开的 CREATE AGGREGATE ... SUPPORT 或 ALTER AGGREGATE ... SUPPORT DDL。
因此,示例扩展需要暂时修改系统目录,把自定义 prosupport 函数关联到内置的 sum(numeric)。这不是常规应用操作,只适合实验、受控环境或等待正式 DDL 支持的原型验证。
概念上,attach 操作需要更新 pg_proc.prosupport,并在 pg_depend 中建立依赖关系。示意 SQL 如下:
-- 假设扩展函数名为 agg_support.sum_order_support
-- 实际 OID 请通过系统目录查询,不要硬编码。
UPDATE pg_proc AS p
SET prosupport = 'agg_support.sum_order_support'::regproc
WHERE p.oid = 'pg_catalog.sum(numeric)'::regprocedure;
INSERT INTO pg_depend
(classid, objid, objsubid, refclassid, refobjid, refobjsubid, deptype)
SELECT
'pg_proc'::regclass,
'pg_catalog.sum(numeric)'::regprocedure,
0,
'pg_proc'::regclass,
'agg_support.sum_order_support'::regproc,
0,
'n'
WHERE NOT EXISTS (
SELECT 1
FROM pg_depend
WHERE classid = 'pg_proc'::regclass
AND objid = 'pg_catalog.sum(numeric)'::regprocedure
AND refobjid = 'agg_support.sum_order_support'::regproc
AND deptype = 'n'
);
依赖关系不是装饰。若扩展被删除但 sum(numeric) 仍保留一个指向已不存在函数的 prosupport OID,之后包含 sum(numeric) 的查询可能在规划阶段报 cache lookup failed for function。因此,扩展应提供对称的 detach 操作:先清空 prosupport,删除依赖记录,再删除扩展。
UPDATE pg_proc
SET prosupport = 0
WHERE oid = 'pg_catalog.sum(numeric)'::regprocedure;
DELETE FROM pg_depend
WHERE classid = 'pg_proc'::regclass
AND objid = 'pg_catalog.sum(numeric)'::regprocedure
AND refobjid = 'agg_support.sum_order_support'::regproc
AND deptype = 'n';
DROP EXTENSION agg_support;
在生产环境中,不建议把这套目录操作当作长期部署接口。它依赖内部目录结构,也可能受到版本升级、备份恢复和扩展卸载顺序的影响。更理想的方案是等待聚合 support DDL 正式进入核心,或者使用经过严格测试的扩展封装这些操作。
如何验证改写确实发生
安装扩展并完成 attach 后,重新查看计划:
EXPLAIN (ANALYZE, BUFFERS)
SELECT sum(amount ORDER BY amount)
FROM invoice;
验证重点不是查询文本发生了变化,而是计划中是否消失了 Sort 节点,以及执行时间是否接近下面这条没有排序的查询:
EXPLAIN (ANALYZE, BUFFERS)
SELECT sum(amount)
FROM invoice;
在示例场景中,千万行数据的查询耗时可以从约 5.7 秒降到约 3.7 秒,收益来自排序节点真正从执行计划中消失,而不仅仅是执行器“更快地完成了排序”。实际数字会受到硬件、work_mem、数据分布和并行计划的影响,应以目标环境的 EXPLAIN (ANALYZE, BUFFERS) 为准。
从删除排序到专用 numeric 聚合
删除冗余 ORDER BY 只是最保守的一类改写。更进一步的方向是针对业务数据的实际约束优化 SUM(numeric)。
ERP 系统中的金额列经常声明为固定精度和固定 scale,例如:
CREATE TABLE payment (
id bigint GENERATED ALWAYS AS IDENTITY,
amount numeric(12, 2) NOT NULL
);
通用版 numeric 聚合必须处理各种精度和小数位,状态对象和计算路径相对复杂。如果扩展能够证明输入始终落在较小、固定的精度范围内,就可以考虑使用更紧凑的累加状态,最后再转换为 PostgreSQL 的 numeric 结果类型。
但“可以证明”是关键。扩展不能仅凭列名或一次采样就假定范围安全。需要结合类型 typmod、约束、函数语义和溢出检查;否则优化可能把正确的金额结果变成溢出或精度错误。
部署前检查清单
- 只在 PostgreSQL 19 或更高版本上使用
SupportRequestSimplifyAggref; - support function 遇到未知 request 类型时返回
NULL; - 永远复制原始
Aggref,不要原地修改; - 排除 DISTINCT、ordered-set、hypothetical-set 以及不确定的
resjunk参数; - 为目录级关联建立正确的
pg_depend; - 在扩展卸载、升级、
pg_dump/恢复和pg_upgrade场景中验证 attach 状态; - 使用真实数据和真实
work_mem测量收益; - 对专用 numeric 聚合增加溢出、精度和回归测试。
SupportRequestSimplifyAggref 的价值在于,它把“只能修改 SQL 生成器”或“只能维护 PostgreSQL fork”的问题,转化成了一个可以由扩展解决的规划期改写问题。对于冗余排序,这是一次低风险且容易验证的优化;对于固定精度金额聚合,它则可能成为更深入的执行性能优化入口。