让 PostgreSQL 聚合更聪明:用 SupportRequestSimplifyAggref 移除冗余排序

2026-09-11 21 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:12 分钟

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 标记的参数出现在聚合节点中,因此不能仅仅看到 SUMORDER 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 ... SUPPORTALTER FUNCTION ... SUPPORT 关联 support function。但内置聚合存在一个现实限制:当前社区 PostgreSQL 没有公开的 CREATE AGGREGATE ... SUPPORTALTER 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”的问题,转化成了一个可以由扩展解决的规划期改写问题。对于冗余排序,这是一次低风险且容易验证的优化;对于固定精度金额聚合,它则可能成为更深入的执行性能优化入口。


相关推荐