Skip to content

PG16:聚合与窗口函数增强 ​

PostgreSQL 16 对「分组统计」这条主干道的改进是全方位的:执行器层面,string_agg() / array_agg() 两个高频聚合函数终于可以并行执行;规划器层面,带 ORDER BY 或 DISTINCT 的聚合现在能复用输入数据已有的排序(presorted aggregate),省掉一次昂贵的重新排序;窗口函数与哈希连接的改进则让分析型查询的整体代价曲线更平缓。与这些能力配套,PG16 还带来了全新的 I/O 监控视图 pg_stat_io,让「聚合查询到底吃掉了多少 I/O」第一次有了系统级的观测手段。以上能力均新增于 PG16,以官方 release notes 为准。

先声明两个常见的误传,避免以讹传讹:其一,「PG16 允许 string_agg/array_agg 的 ORDER BY 使用多个参数」的说法并不存在于官方 release notes——PG16 确凿的是「这两个函数可并行化」与「聚合可使用预排序数据」两条;其二,random() 在 PG16 没有任何改进条目,网络上的相关说法均不可靠。本页只写核实过的条目,每一条都能在官方 release notes 里找到对应记录。

对于写报表、跑批与数据管道的工程师来说,这一页的价值在于「同样的 SQL,升级到 PG16 后哪里会变快、为什么变快、怎么验证变快」。每个特性我们都给出触发条件与验证方法——优化器改进只有被正确触发才有价值。

前置知识

建议先阅读:PG16:SQL/JSON 构造与检查

基础概念 ​

PG16 聚合/查询相关改进总览 ​

下表是本页覆盖的 PG16 条目及其官方出处位置:

特性层面官方条目位置对业务的直接价值
string_agg() / array_agg() 并行化执行器Performance大表拼接/收集的墙钟时间随并行度下降
聚合使用预排序数据(presorted aggregate)规划器PlannerORDER BY/DISTINCT 聚合避免二次排序
窗口函数内部改用 ROWS 模式执行器PerformanceRANGE 模式在无必要时的帧计算加速
ntile() / cume_dist() / percent_rank() 优化执行器Performance恒增窗口函数免重复扫描
FULL / 内部右 OUTER 哈希连接并行化执行器Planner/Parallel并行计划覆盖面扩大
Memoize 支持叠加在 UNION ALL 上规划器Planner参数化嵌套循环的缓存命中率提升
反连接可让非空输入作为内侧规划器PlannerNOT EXISTS 类查询计划更优
GIN 代价模型更准确规划器PlannerGIN 查询的路径选择更准
pg_stat_io 视图监控MonitoringI/O 层面的可观测性(详见 VACUUM 一页)
末次顺序/索引扫描统计监控Monitoring识别「从未被索引扫描命中」的索引

聚合的执行模型回顾 ​

理解这两项改进,需要先回顾 PostgreSQL 聚合的执行模型(细节见聚合函数基础):

  1. 扫描输入行(可能有并行 worker 分摊);
  2. 按分组键哈希或排序分组;
  3. 聚合状态累积:每个分组维护一个状态值,逐行更新(string_agg 的状态是累积文本,array_agg 是累积数组);
  4. FINALFUNC 输出结果。

并行化的难点在状态合并:普通聚合要求 COMBINEFUNC 才能把多个 worker 的部分状态合并。string_agg / array_agg 长期没有这个能力,PG16 补齐——这正是「并行化」三个字的全部含义。

预排序聚合(presorted aggregate)是什么 ​

带 ORDER BY 的聚合(如 string_agg(x ORDER BY y))与带 DISTINCT 的聚合,传统上会在每个分组的输入行上重新排序。PG16 起,规划器会检查:如果输入数据「恰好已经按所需顺序排列」(比如来自索引扫描、或外层已经有同序的排序节点),就直接使用该顺序,省掉内层排序;即使顺序只对齐了排序键的前缀,也能用增量排序(incremental sort)补齐剩余部分。开关是新增的 GUC enable_presorted_aggregate(默认 on)。

GroupAggregate 与 HashAggregate 的选择 ​

预排序聚合的效果与「分组策略」直接相关,先回顾两种分组节点的取舍:

维度GroupAggregate(排序分组)HashAggregate(哈希分组)
输入要求按分组键有序无要求
内存占用低(流式输出)高(哈希表常驻 work_mem)
适用分组基数大基数 / 已有索引小到中基数
ORDER BY 聚合天然配合预排序需要额外内层排序
超出内存时顺序不变,可能外排转 GroupAggregate 或溢写

规划器的选择由代价模型决定。PG16 的预排序聚合让「有序输入」的获取成本下降(复用索引或增量排序即可),从而让更多查询从 HashAggregate 走向 GroupAggregate——内存峰值更低,且 ORDER BY 聚合免掉内层排序。反过来,分组基数极小(几个分组)时哈希路径依旧占优,两不耽误。

语法与用法 ​

string_agg / array_agg 的语法不变,行为升级 ​

sql
-- string_agg:分隔符 + 聚合内排序
SELECT dept,
       string_agg(name, ', ' ORDER BY name) AS members
FROM employees
GROUP BY dept;

-- array_agg:收集成数组
SELECT dept,
       array_agg(id ORDER BY joined_at) AS member_ids
FROM employees
GROUP BY dept;

语法与 PG15 完全一致;变化发生在执行计划里。验证并行化:

sql
EXPLAIN (ANALYZE, VERBOSE)
SELECT string_agg(payload, '|' ORDER BY id)
FROM big_events;
text
Gather Merge
  Workers Planned: 4
  ...
    ->  Parallel Seq Scan on big_events
          ->  Partial Aggregate
                ...

计划中出现 Partial Aggregate + Gather Merge(并行阶段的部分聚合再合并)即说明并行化生效;PG15 的同一查询是单个 Aggregate 节点、单进程执行。

触发预排序聚合的写法 ​

sql
-- 场景一:索引扫描天然有序
CREATE INDEX ON events (device_id, occurred_at, severity);

SELECT device_id,
       string_agg(severity, ',' ORDER BY severity) AS seq
FROM events
WHERE device_id BETWEEN 1 AND 100
GROUP BY device_id;

ORDER BY severity 与索引第三列同序,规划器可以直接复用索引顺序。验证:

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT ... ;
text
GroupAggregate
  Group Key: device_id
  ...
  ->  Index Scan using events_device_id_occurred_at_severity_idx on events

看两点:聚合节点是 GroupAggregate(排序驱动)而非 HashAggregate;其下层没有多余的 Sort 节点——那就是省掉的那次排序。若要对比旧行为,可临时关闭:

sql
SET enable_presorted_aggregate = off;   -- 恢复 PG15 行为(强制重新排序)
-- 再次 EXPLAIN 对比
RESET enable_presorted_aggregate;

窗口函数的两个执行器优化 ​

sql
-- RANGE 模式(默认帧)在「无必要」时内部走 ROWS 模式
SELECT user_id, amount,
       sum(amount) OVER (ORDER BY created_at
                         RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running
FROM payments;

-- 恒增窗口函数的优化
SELECT user_id,
       ntile(4)          OVER (ORDER BY amount) AS quartile,
       cume_dist()       OVER (ORDER BY amount) AS cd,
       percent_rank()    OVER (ORDER BY amount) AS pr
FROM scores;

两条语法的语义与 PG15 完全一致,收益在执行层:前者避免 RANGE 帧模式对 peer(同排序键值)组的重复扫描;后者利用排序键单调递增的性质跳过重复计算。窗口函数的语义本身见窗口排序与聚合函数。

详细说明 ​

string_agg / array_agg 并行化的机理 ​

并行的三个环节在 PG16 的这两个函数上齐备:

  1. Partial 聚合:每个并行 worker 独立累积自己那份输入的状态;
  2. 状态合并:COMBINEFUNC 把多个部分状态合并(文本拼接、数组连接);
  3. Gather 收口:leader 合并后输出最终结果。

触发条件与所有限制:

  • 查询本身要有并行计划(表大小超过 parallel_threshold 类判定、max_parallel_workers_per_query 足够、没有 parallel unsafe 的调用);
  • string_agg 的 ORDER BY 在并行下依然正确——部分结果的合并会保持排序语义;
  • 单行代价极低的查询不值得并行( Gather 同步开销可能反噬);并行收益主要出现在「输入几十万行以上」的聚合。

值得注意的是 array_agg 并行化对「收集型报表」的影响最大:PG15 上百万行 array_agg 是单进程内存密集操作,PG16 可以分散到多个 worker 的 work_mem 中。

预排序聚合的适用矩阵 ​

输入顺序来源ORDER BY 聚合DISTINCT 聚合
B-tree 索引全序匹配直接复用直接复用
索引前缀匹配(增量排序补齐)复用 + incremental sort复用 + incremental sort
外层已有同序 Sort 节点复用复用
哈希分组(HashAggregate)不适用(无序)不适用
并行扫描(worker 间无序)每 worker 内复用每 worker 内复用

三条实践推论:

  • 分组键与排序键能共享索引前缀时,考虑把索引扩展到「分组键 + 排序键」,让预排序聚合稳定生效;
  • DISTINCT 聚合(如 count(DISTINCT x)、array_agg(DISTINCT y))在 PG15 以前几乎必然内排序,PG16 起配合索引可以完全免排序;
  • 该优化对 GROUP BY 的分组策略有影响:预排序可用时规划器更倾向 GroupAggregate 而不是 HashAggregate,内存峰值更低(不用建哈希表)。

增量排序与预排序聚合的配合 ​

当输入只按排序键前缀有序时(例如索引是 (device_id, occurred_at) 而聚合要求 ORDER BY severity),执行器使用增量排序:先按 device_id 块定位,块内再对 severity 排序——排序总量从「全表」降为「每个分组块」。代价模型会自动比较「完全排序」与「增量排序」,EXPLAIN 里看到 Incremental Sort 节点即为生效。

text
->  Incremental Sort
      Sort Key: device_id, severity
      Presorted Key: device_id
      ->  Index Scan using readings_device_time_idx on readings

Presorted Key 列出了「已经有序、直接复用」的前缀部分——这正是预排序聚合与既有索引之间的衔接点:索引给的有序性越靠近聚合需求,执行器省下的排序就越多。

预排序生效的前提:统计与代价 ​

预排序聚合是代价模型的选择而非语法保证——统计过期或代价参数失衡时,规划器可能放弃它。排查清单:

sql
-- 1) 确认支撑索引仍在被扫描(last_idx_scan 不为 NULL 且新鲜)
SELECT indexrelname, idx_scan, last_idx_scan
FROM pg_stat_all_indexes
WHERE relname = 'visits';

-- 2) 表统计新鲜度
SELECT relname, last_analyze, last_autoanalyze, n_mod_since_analyze
FROM pg_stat_user_tables
WHERE relname IN ('readings', 'visits');

-- 3) 强制对照:关掉哈希聚合,看规划器是否回到有序路径
SET enable_hashagg = off;
EXPLAIN SELECT dept, count(DISTINCT user_id) FROM visits GROUP BY dept;
RESET enable_hashagg;

enable_hashagg = off 是排查「为什么没走预排序」的标准对照实验:关掉后规划器被迫走 GroupAggregate,若计划立刻出现「索引扫描 + 无 Sort 节点」,说明有序路径可行但被代价模型拒绝——通常指向统计过期或 random_page_cost 失真,而不是特性本身的问题。

窗口函数 ROWS 内部化 ​

RANGE 帧模式(窗口默认 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)的语义要求处理「peer 行」(排序键相等的行)——同一 peer 组的聚合结果相同。PG15 及以前的实现需要在每个 peer 组边界重新检查帧边界;PG16 起,当帧模式被证明与 ROWS 模式等价(排序键在帧范围内唯一、或帧不涉及 peer 语义差异)时,内部直接按 ROWS 模式计算。

验证方式:EXPLAIN (ANALYZE, TIMING) 对比升级前后的 WindowAgg 节点耗时;语义完全不变,无需改写 SQL。

ntile / cume_dist / percent_rank 的优化 ​

这三个窗口函数的输出只依赖「排序键的位置」且随输入单调变化。PG16 起执行器利用单调性跳过重复求值:ntile(4) 不再为每行重新计算桶号,cume_dist() / percent_rank() 的分母在窗口分区内只计算一次。典型收益场景是「百万行按分数分四档」的报表查询,WindowAgg 节点的 CPU 时间可下降一个台阶。

并行查询参数与聚合 ​

并行聚合的触发与容量由一组 GUC 控制(默认值以 PG16 为准):

参数默认值作用
max_worker_processes8后台 worker 总池(逻辑复制等共用)
max_parallel_workers8并行查询可用的 worker 上限
max_parallel_workers_per_query2单个查询最多借用的 worker 数
min_parallel_table_scan_size8MB表大于该值才考虑并行扫描
min_parallel_index_scan_size512kB索引扫描并行的门槛
parallel_setup_cost1000启动 worker 的代价估算
parallel_tuple_cost0.1元组跨进程传递的代价估算

报表会话的常见调法:SET max_parallel_workers_per_query = 4;(会话级)给重聚合更大的并行预算,全局保持默认让 OLTP 不受影响。验证并行「真的在干活」:EXPLAIN ANALYZE 的 Workers Launched 应等于 Workers Planned;不足时通常是 worker 池被占满(对照 max_parallel_workers)或数据量没过门槛。

FULL 与内部右 OUTER 哈希连接的并行化(新增于 PG16) ​

FULL OUTER JOIN 与「内侧为右表的 OUTER 连接」此前无法生成并行哈希连接计划,因为并行下的匹配语义(未匹配行的双侧输出)难以分摊。PG16 解决了这两个形态:

sql
-- FULL OUTER JOIN 现在可以走 Parallel Hash Full Join
SELECT a.id, b.id
FROM big_a a FULL OUTER JOIN big_b b USING (key);

-- RIGHT OUTER 哈希连接的内侧并行
SELECT a.id, b.id
FROM big_a a RIGHT OUTER JOIN big_b b USING (key);

EXPLAIN 中出现 Parallel Hash Full Join / Parallel Hash Right Join 即生效。对数仓场景的直接意义:星型模型里的维表全外连接、对账类查询不再被排除在并行计划之外。注意 LEFT OUTER JOIN 的并行哈希在更早版本已支持。

Memoize 叠加 UNION ALL 与反连接改进(新增于 PG16) ​

  • Memoize + UNION ALL:Memoize 是 PG14 引入的「参数化嵌套循环的参数结果缓存」;PG16 让它也能覆盖 UNION ALL 子计划,即参数化探测 UNION ALL 的各分支时结果可被缓存。典型受益查询:视图分层(视图里 UNION ALL 多个来源表)+ 外层按主键逐行关联。
  • 反连接(anti-join)内侧选择:PG16 允许把「非空输入」作为反连接的内侧关系,生成更少的探测代价;NOT EXISTS / NOT IN(子查询非空语义明确时)类查询在特定形状下计划更优。

两者都是规划器自动决策,无需改写 SQL;验证方式是 EXPLAIN 对比升级前后的计划形状与 Buffers 读数。

GIN 代价模型与分区查找缓存(新增于 PG16) ​

  • GIN 代价更准:PG16 改进了 GIN 索引访问路径的代价估算,让规划器在「GIN 索引扫描 vs 其他路径」之间做出更合理的选择。此前 GIN 路径经常被系统性低估或高估,导致该用不用、不该用乱用;全文检索与 JSONB 数组查询(见GIN 索引)的路由质量提升。
  • RANGE/LIST 分区查找缓存:按 RANGE/LIST 分区的表在做分区裁剪时,PG16 缓存查找结果,重复探测同一分区边界的开销下降;高分区数(数千分区)的时序表受益最明显,时序建模见时序数据模式。

pg_stat_io:I/O 维度的可观测性(新增于 PG16) ​

聚合与并行查询的性能瓶颈常常在 I/O,而 PG16 之前没有统一的 I/O 视角。pg_stat_io 按 backend 类型、操作对象与上下文记录读/命中/写/扩展/回写/fsync 计数:

sql
SELECT backend_type, object, context,
       reads, hits, writes, extends, evictions, op_bytes
FROM pg_stat_io
WHERE backend_type LIKE '%worker%';

并行 worker 的 I/O 首次按 backend_type = 'parallel worker' 单列,验证「并行聚合是否把 I/O 打满」有了直接证据。该视图的字段解读、与 track_io_timing 的配合以及典型排障套路,统一放在VACUUM 与性能改进一页展开(两页共用一节内容,此处不重复)。

末次扫描统计与冷索引识别(新增于 PG16) ​

PG16 在 pg_stat_all_tables / pg_stat_all_indexes 里新增了「最近一次顺序扫描 / 索引扫描的时间戳」:

sql
SELECT relname,
       seq_scan, last_seq_scan,
       idx_scan, last_idx_scan
FROM pg_stat_all_tables
WHERE schemaname = 'public'
ORDER BY last_idx_scan NULLS FIRST;

聚合查询的索引调优以此为依据:last_idx_scan 长期为 NULL 或久远的索引,是「聚合查询没走预期索引」或「索引纯冗余」的直接证据。配合预排序聚合使用:如果 string_agg ... ORDER BY 一直没吃到预排序,查它依赖的索引是否根本没被扫描过。

分区表上的聚合 ​

RANGE/LIST 分区查找缓存加速的是「规划期」的分区裁剪,聚合查询在分区表上的其他特性也要了解:

sql
-- 分区表的聚合计划:各分区先做部分聚合(partial aggregation)
EXPLAIN
SELECT device_id, avg(value)
FROM readings
WHERE occurred_at >= '2026-10-01'
GROUP BY device_id;
text
Finalize GroupAggregate
  ->  Gather
        ->  Partial GroupAggregate        -- 各 worker 先产出部分结果
              ->  Append
                    ->  Index Scan on readings_2026_10 ...
                    ->  Index Scan on readings_2026_09 ...

三条实践提示:

  • 裁剪条件要落在分区键上:occurred_at 直接过滤时只有近期分区参与聚合,缓存改进进一步降低重复查询的规划开销;
  • Partial Aggregate 跨分区分摊:并行 + 分区的组合让大聚合的 CPU 时间按 worker 摊薄,Finalize 阶段合并部分结果;
  • 分区数不是越多越好:PG16 缓解了数千分区的规划开销,但每个分区的元数据、统计与锁开销仍在;聚合频繁的时序表按「月分区」起步即可(建模范式见时序数据处理模式)。

DISTINCT 聚合的改写工具箱 ​

count(DISTINCT ...) 与 array_agg(DISTINCT ...) 是报表里最贵的模式之一。PG16 的预排序聚合让它们在「有合适索引」时可以零排序,除此之外还有几个经典改写:

sql
-- 1) 先子查询去重再聚合(分组基数低时更省)
SELECT dept, count(*) AS distinct_users
FROM (SELECT dept, user_id FROM visits GROUP BY dept, user_id) d
GROUP BY dept;

-- 2) 用 pg_stats 的 n_distinct 评估去重列的基数
SELECT attname, n_distinct
FROM pg_stats
WHERE tablename = 'visits' AND schemaname = 'public';

n_distinct 是规划器对列基数的估计(-0.5 表示约 50% 唯一);count(DISTINCT) 慢的表如果 n_distinct 很小,说明大量重复值,「子查询去重 + 普通聚合」通常立竿见影。基数很大且必须精确时,回头检查 (分组键, 去重列) 复合索引是否就位——那是预排序聚合生效的前提。

语义与兼容性总结 ​

本页所有优化的共同特征:语义零变化,纯执行层提速。给出一张「升级影响面」清单:

优化需要改 SQL?需要改配置?收益条件
string_agg/array_agg 并行否并行参数大表 + 并行可行
预排序聚合否(要索引配套)无有序输入可得
窗口 ROWS 内部化否无RANGE 帧大分区
ntile / percent_rank否无大分区
FULL / 右 OUTER 并行否无大表连接
Memoize + UNION ALL否无参数重叠的重复探测
GIN 代价修正否无GIN 查询
分区查找缓存否无数千分区

结论:PG16 的聚合优化是「升级即得」的一类,运维动作集中在升级后的计划回归与索引配套,而非应用改造——这与 SQL/JSON 标准函数一页「语法新增」的迁移形态形成对照。

实战示例 ​

示例一:升级前后对比 string_agg 并行收益 ​

数据准备(500 万行):

sql
CREATE TABLE big_events (
    id       bigint GENERATED ALWAYS AS IDENTITY,
    device   int,
    payload  text,
    occurred timestamptz DEFAULT now()
);
INSERT INTO big_events (device, payload)
SELECT (random() * 1000)::int, md5(g::text)
FROM generate_series(1, 5000000) g;
ANALYZE big_events;

查询与计划对比:

sql
SET max_parallel_workers_per_query = 4;
EXPLAIN (ANALYZE, TIMING)
SELECT string_agg(payload, '|' ORDER BY id) FROM big_events;
text
PG15:Aggregate  (actual time=18342.1..18342.2 rows=1 loops=1)
      ->  Sort ...
            ->  Seq Scan on big_events ...

PG16:Gather Merge  (actual time=5121.8..5122.0 rows=1 loops=1)
      Workers Planned: 4
      Workers Launched: 4
        ->  Partial GroupAggregate  ...
              ->  Sort ...
                    ->  Parallel Seq Scan on big_events ...

同机对比中并行化把单进程聚合的执行时间按并行度摊薄;具体收益取决于行宽与 CPU 配置,请以自家硬件实测为准。

示例二:预排序聚合消除内排序 ​

sql
CREATE TABLE readings (
    device_id  int,
    occurred_at timestamptz,
    value      numeric
);
-- 时序数据按设备聚簇写入,天然按 device_id, occurred_at 有序
CREATE INDEX ON readings (device_id, occurred_at);

SELECT device_id,
       string_agg(value::text, ',' ORDER BY occurred_at) AS series
FROM readings
WHERE occurred_at >= now() - interval '1 day'
GROUP BY device_id;
sql
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
text
GroupAggregate  (actual time=... rows=120 loops=1)
  Group Key: device_id
  Buffers: shared hit=...
  ->  Incremental Sort ...
        Sort Key: device_id, occurred_at
        Presorted: true
        ->  Index Scan using readings_device_id_occurred_at_idx on readings

Presorted: true 即预排序生效。把 enable_presorted_aggregate 关掉再跑一次,Buffers 与执行时间的差异就是该优化省下的部分。

示例三:FULL OUTER JOIN 对账的并行计划 ​

sql
-- 两份大表对账:PG16 前无法并行,PG16 可并行
EXPLAIN (ANALYZE)
SELECT coalesce(a.k, b.k) AS key,
       a.v IS NULL AS missing_in_a,
       b.v IS NULL AS missing_in_b
FROM ledger_a a
FULL OUTER JOIN ledger_b b USING (k);
text
Gather  (actual time=...)
  Workers Planned: 2
  Workers Launched: 2
    ->  Parallel Hash Full Join  ...
          Hash Cond: (a.k = b.k)

升级验证清单:Workers Launched 是否达到预期、parallel worker 在 pg_stat_io 中的 reads 是否同步上升(说明并行真的在干活而不是空转)。

示例四:ntile 分档报表的升级收益验证 ​

sql
-- 按消费金额把用户分五档(窗口函数用法见 functions/aggregate/order-by)
SELECT user_id,
       amount,
       ntile(5) OVER (ORDER BY amount DESC) AS quintile,
       percent_rank() OVER (ORDER BY amount DESC) AS pct_rank
FROM (SELECT user_id, sum(amount) AS amount
      FROM payments
      GROUP BY user_id) t
ORDER BY quintile, amount DESC
LIMIT 20;

验证方法(语义不变,只看性能):

text
PG15:WindowAgg  (actual time=4120.1..4390.5 rows=1000000 loops=1)
PG16:WindowAgg  (actual time=4032.2..4180.6 rows=1000000 loops=1)

ntile/percent_rank 的收益与窗口分区大小成正比:单分区百万行级的报表提升可感知;几十行的小分区本来就快,差异可忽略。这也是本页所有执行器优化的共性——收益出现在数据量大的聚合场景,小查询的执行计划完全不受影响。

示例五:冷索引治理的例行脚本 ​

last_idx_scan 让「索引是否被用过」第一次有了直接证据。月度治理脚本:

sql
-- 候选:长期未被索引扫描的索引(统计自上次重置起累计)
SELECT schemaname,
       relname AS table_name,
       indexrelname,
       idx_scan,
       last_idx_scan,
       pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_all_indexes
WHERE idx_scan < 10
  AND (last_idx_scan IS NULL OR last_idx_scan < now() - interval '30 days')
  AND schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20;

处置流程:先交叉核对「主键 / 唯一约束 / 外键支撑索引」排除候选(它们不体现在 idx_scan 里也必须保留),再把剩余候选置为 INVALID(DROP INDEX CONCURRENTLY 的前置观察步骤)或直接删除。统计是「自上次 pg_stat_reset()」口径,升级 PG16 后重置一次统计、观察一个完整业务周期,清单才有可信度。

示例六:给聚合查询补上「预排序友好」的索引 ​

一个从改造到验证的完整闭环(预排序聚合的落地姿势):

sql
-- 现状:按部门聚合最近留言,DISTINCT 去重走哈希
EXPLAIN ANALYZE
SELECT dept, count(DISTINCT user_id) AS users,
       string_agg(msg, ' | ' ORDER BY created_at) AS feed
FROM visits
WHERE created_at >= now() - interval '1 day'
GROUP BY dept;
text
升级前计划:GroupAggregate(内层出现 Sort:全量重排序)
sql
-- 1) 为 ORDER BY 聚合建配套索引:分组键前缀 + 排序键后缀
CREATE INDEX CONCURRENTLY ON visits (dept, created_at);

-- 2) SQL 文本完全一致地重跑
EXPLAIN ANALYZE
SELECT dept, count(DISTINCT user_id) AS users,
       string_agg(msg, ' | ' ORDER BY created_at) AS feed
FROM visits
WHERE created_at >= now() - interval '1 day'
GROUP BY dept;
text
升级后计划:GroupAggregate
        ->  Incremental Sort   (内层全量 Sort 消失)

三个要点:索引列序遵循「分组键 → 聚合排序键」,DISTINCT 列的预排序则依赖 (分组键, 去重列);CONCURRENTLY 避免大表建索引锁写;验证时保持 SQL 文本一致,因为预排序是计划选择而不是改写。收益规模与「内层排序的数据量」成正比,日均千万行的活跃表上,这一步通常值回票价。

注意事项 ​

并行聚合吃 CPU 与 work_mem。 每个 worker 的部分状态独立占内存,string_agg 在高基数分组下的部分状态可能远超单进程形态;并行计划的总内存要按「worker 数 × 单份状态」评估,work_mem 按每 worker 每节点分配。

预排序聚合可能改变分组策略。 生效时规划器倾向 GroupAggregate(低内存、要求有序输入),失效时回退 HashAggregate(高内存、无序输入);同一查询在「索引存在/不存在」两种环境下的内存画像可能截然不同,容量评估要覆盖两种计划。

enable_presorted_aggregate 是会话级开关。 用于 A/B 验证与紧急兜底(优化器误判时关掉它恢复旧计划),不要在全局配置里长期关闭——那是放弃整个优化。

并行计划不总是更快。 数据量小、行数少、聚合状态大的查询,并行 Gather 的同步开销可能反噬;parallel_setup_cost / parallel_tuple_cost 维持默认即可,不要靠调低它们「逼出」并行计划。

窗口优化无需迁移,但 ORDER BY 列要留意。 ROWS 内部化与恒增函数优化都以「排序键稳定」为前提;窗口 ORDER BY 引用易变表达式(如带随机函数)会阻碍优化生效。

pg_stat_io 需要 stats 重置意识。 视图计数自统计收集器启用起累计,对比「升级前后」要固定观测窗口(升级后调用 pg_stat_reset() 附近的节奏),否则旧数据会稀释新行为的信号。

GIN 代价改进可能「换」计划而不是「变快」。 代价模型更准后,某些原先误选 GIN 的查询会改走其他路径(总时间下降),也有原先误选 Seq Scan 的会改走 GIN——升级后关键查询要重新 EXPLAIN ANALYZE 回归,而不是默认一切变快。

FULL OUTER 并行对 join key 分布敏感。 键倾斜(少数键占多数行)时并行哈希连接的某个 worker 可能成为长尾;对账类查询的倾斜键要事先识别。

COUNT(DISTINCT) 的内存上限没有变。 哈希去重的中间状态受 work_mem 限制,超限后溢写落盘——PG16 的优化减少的是排序与重复计算,不是去重的内存需求;基数亿级的精确去重请评估预聚合表或专用扩展。

报表时段避开统计真空。 ANALYZE 滞后的表上,并行度与分组策略的代价估算都会失真;大批量导入后先 ANALYZE 再跑报表回归,否则「升级收益」与「统计过期」两个变量会搅在一起。

执行器优化的收益随数据量缩放。 本页任何一条优化在千行级表上都测不出差异,基准测试要用千万行级的数据集;拿小表「证明 PG16 没变快」是对这批特性的常见误判。

最佳实践 ​

  1. 升级 PG16 后对报表 SQL 做一轮 EXPLAIN (ANALYZE, BUFFERS) 回归。 本页所有优化都是「自动触发」型,验证它们生效(或发现意外回退)的最小成本动作就是对比计划。
  2. 为聚合排序键补齐复合索引。 「分组键 + 排序键」的复合索引让预排序聚合稳定生效,是这一代优化里唯一需要 DBA 主动做的事。
  3. DISTINCT 聚合优先改写为可用索引的形态。 例如 array_agg(DISTINCT x) 配 (group_key, x) 索引可完全免排序;PG16 起这类改写才有「零排序」的可能。
  4. 大 array_agg/string_agg 场景显式给足并行 worker。 max_parallel_workers_per_query 按报表专用会话调高(普通 OLTP 会话保持默认),避免互相抢占。
  5. 用 pg_stat_io 区分 CPU 瓶颈与 I/O 瓶颈。 并行聚合「没变快」时,先看 worker 的 reads/hits:I/O 打满则加内存/调 IO,CPU 打满则加大并行度,别盲目调参。
  6. last_idx_scan 纳入索引治理。 每季度扫一次「从未被索引扫描」的索引清单,结合聚合查询的执行计划决定去留(配合索引维护监控)。
  7. 窗口报表优先声明确定性排序键。 唯一或近似唯一的排序键让 ROWS 内部化与恒增函数优化稳定触发,同时避免 peer 组语义歧义。
  8. UNION ALL 视图上的逐行关联用 Memoize 验证。 分层视图 + 参数化探测是 Memoize 的甜点区;升级后确认计划出现 Memoize 节点且 Hits 远大于 Misses。
  9. 分区表的分区数控制在数千以内并依赖缓存改进。 PG16 的分区查找缓存缓解了高分区数的规划开销,但分区膨胀的治理(分区裁剪、默认分区清理)依然是根本。
  10. 对账/FULL OUTER 场景升级后重测。 并行化可能改变这类查询的执行拓扑,锁竞争与临时文件用量要重新评估(EXPLAIN ANALYZE 的 Rows Removed by Filter 与临时文件警告)。
  11. 版本升级对照实验用 enable_presorted_aggregate 等开关做反证。 「关掉新特性跑一遍」是量化单条优化收益的最干净方法,比拍脑袋估准确得多。
  12. 报表平台沉淀「聚合查询登记表」。 把慢聚合查询、依赖索引、升级前后的计划要点登记成表,PG17/PG18 的后续升级回归就能直接复用这份清单,而不是每次从 pg_stat_statements 里重新挖。

下一节

继续学习:PG16:逻辑复制增强

参考链接 ​