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) | 规划器 | Planner | ORDER BY/DISTINCT 聚合避免二次排序 |
| 窗口函数内部改用 ROWS 模式 | 执行器 | Performance | RANGE 模式在无必要时的帧计算加速 |
ntile() / cume_dist() / percent_rank() 优化 | 执行器 | Performance | 恒增窗口函数免重复扫描 |
| FULL / 内部右 OUTER 哈希连接并行化 | 执行器 | Planner/Parallel | 并行计划覆盖面扩大 |
| Memoize 支持叠加在 UNION ALL 上 | 规划器 | Planner | 参数化嵌套循环的缓存命中率提升 |
| 反连接可让非空输入作为内侧 | 规划器 | Planner | NOT EXISTS 类查询计划更优 |
| GIN 代价模型更准确 | 规划器 | Planner | GIN 查询的路径选择更准 |
pg_stat_io 视图 | 监控 | Monitoring | I/O 层面的可观测性(详见 VACUUM 一页) |
| 末次顺序/索引扫描统计 | 监控 | Monitoring | 识别「从未被索引扫描命中」的索引 |
聚合的执行模型回顾
理解这两项改进,需要先回顾 PostgreSQL 聚合的执行模型(细节见聚合函数基础):
- 扫描输入行(可能有并行 worker 分摊);
- 按分组键哈希或排序分组;
- 聚合状态累积:每个分组维护一个状态值,逐行更新(
string_agg的状态是累积文本,array_agg是累积数组); 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 的语法不变,行为升级
-- 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 完全一致;变化发生在执行计划里。验证并行化:
EXPLAIN (ANALYZE, VERBOSE)
SELECT string_agg(payload, '|' ORDER BY id)
FROM big_events;Gather Merge
Workers Planned: 4
...
-> Parallel Seq Scan on big_events
-> Partial Aggregate
...计划中出现 Partial Aggregate + Gather Merge(并行阶段的部分聚合再合并)即说明并行化生效;PG15 的同一查询是单个 Aggregate 节点、单进程执行。
触发预排序聚合的写法
-- 场景一:索引扫描天然有序
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 与索引第三列同序,规划器可以直接复用索引顺序。验证:
EXPLAIN (ANALYZE, BUFFERS)
SELECT ... ;GroupAggregate
Group Key: device_id
...
-> Index Scan using events_device_id_occurred_at_severity_idx on events看两点:聚合节点是 GroupAggregate(排序驱动)而非 HashAggregate;其下层没有多余的 Sort 节点——那就是省掉的那次排序。若要对比旧行为,可临时关闭:
SET enable_presorted_aggregate = off; -- 恢复 PG15 行为(强制重新排序)
-- 再次 EXPLAIN 对比
RESET enable_presorted_aggregate;窗口函数的两个执行器优化
-- 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 的这两个函数上齐备:
- Partial 聚合:每个并行 worker 独立累积自己那份输入的状态;
- 状态合并:
COMBINEFUNC把多个部分状态合并(文本拼接、数组连接); - 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 节点即为生效。
-> Incremental Sort
Sort Key: device_id, severity
Presorted Key: device_id
-> Index Scan using readings_device_time_idx on readingsPresorted Key 列出了「已经有序、直接复用」的前缀部分——这正是预排序聚合与既有索引之间的衔接点:索引给的有序性越靠近聚合需求,执行器省下的排序就越多。
预排序生效的前提:统计与代价
预排序聚合是代价模型的选择而非语法保证——统计过期或代价参数失衡时,规划器可能放弃它。排查清单:
-- 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_processes | 8 | 后台 worker 总池(逻辑复制等共用) |
max_parallel_workers | 8 | 并行查询可用的 worker 上限 |
max_parallel_workers_per_query | 2 | 单个查询最多借用的 worker 数 |
min_parallel_table_scan_size | 8MB | 表大于该值才考虑并行扫描 |
min_parallel_index_scan_size | 512kB | 索引扫描并行的门槛 |
parallel_setup_cost | 1000 | 启动 worker 的代价估算 |
parallel_tuple_cost | 0.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 解决了这两个形态:
-- 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 计数:
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 里新增了「最近一次顺序扫描 / 索引扫描的时间戳」:
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 分区查找缓存加速的是「规划期」的分区裁剪,聚合查询在分区表上的其他特性也要了解:
-- 分区表的聚合计划:各分区先做部分聚合(partial aggregation)
EXPLAIN
SELECT device_id, avg(value)
FROM readings
WHERE occurred_at >= '2026-10-01'
GROUP BY device_id;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 的预排序聚合让它们在「有合适索引」时可以零排序,除此之外还有几个经典改写:
-- 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 万行):
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;查询与计划对比:
SET max_parallel_workers_per_query = 4;
EXPLAIN (ANALYZE, TIMING)
SELECT string_agg(payload, '|' ORDER BY id) FROM big_events;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 配置,请以自家硬件实测为准。
示例二:预排序聚合消除内排序
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;EXPLAIN (ANALYZE, BUFFERS) SELECT ...;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 readingsPresorted: true 即预排序生效。把 enable_presorted_aggregate 关掉再跑一次,Buffers 与执行时间的差异就是该优化省下的部分。
示例三:FULL OUTER JOIN 对账的并行计划
-- 两份大表对账: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);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 分档报表的升级收益验证
-- 按消费金额把用户分五档(窗口函数用法见 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;验证方法(语义不变,只看性能):
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 让「索引是否被用过」第一次有了直接证据。月度治理脚本:
-- 候选:长期未被索引扫描的索引(统计自上次重置起累计)
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 后重置一次统计、观察一个完整业务周期,清单才有可信度。
示例六:给聚合查询补上「预排序友好」的索引
一个从改造到验证的完整闭环(预排序聚合的落地姿势):
-- 现状:按部门聚合最近留言,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;升级前计划:GroupAggregate(内层出现 Sort:全量重排序)-- 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;升级后计划: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 没变快」是对这批特性的常见误判。
最佳实践
- 升级 PG16 后对报表 SQL 做一轮
EXPLAIN (ANALYZE, BUFFERS)回归。 本页所有优化都是「自动触发」型,验证它们生效(或发现意外回退)的最小成本动作就是对比计划。 - 为聚合排序键补齐复合索引。 「分组键 + 排序键」的复合索引让预排序聚合稳定生效,是这一代优化里唯一需要 DBA 主动做的事。
DISTINCT聚合优先改写为可用索引的形态。 例如array_agg(DISTINCT x)配(group_key, x)索引可完全免排序;PG16 起这类改写才有「零排序」的可能。- 大
array_agg/string_agg场景显式给足并行 worker。max_parallel_workers_per_query按报表专用会话调高(普通 OLTP 会话保持默认),避免互相抢占。 - 用
pg_stat_io区分 CPU 瓶颈与 I/O 瓶颈。 并行聚合「没变快」时,先看 worker 的reads/hits:I/O 打满则加内存/调 IO,CPU 打满则加大并行度,别盲目调参。 last_idx_scan纳入索引治理。 每季度扫一次「从未被索引扫描」的索引清单,结合聚合查询的执行计划决定去留(配合索引维护监控)。- 窗口报表优先声明确定性排序键。 唯一或近似唯一的排序键让 ROWS 内部化与恒增函数优化稳定触发,同时避免 peer 组语义歧义。
- UNION ALL 视图上的逐行关联用 Memoize 验证。 分层视图 + 参数化探测是 Memoize 的甜点区;升级后确认计划出现
Memoize节点且Hits远大于Misses。 - 分区表的分区数控制在数千以内并依赖缓存改进。 PG16 的分区查找缓存缓解了高分区数的规划开销,但分区膨胀的治理(分区裁剪、默认分区清理)依然是根本。
- 对账/FULL OUTER 场景升级后重测。 并行化可能改变这类查询的执行拓扑,锁竞争与临时文件用量要重新评估(
EXPLAIN ANALYZE的Rows Removed by Filter与临时文件警告)。 - 版本升级对照实验用
enable_presorted_aggregate等开关做反证。 「关掉新特性跑一遍」是量化单条优化收益的最干净方法,比拍脑袋估准确得多。 - 报表平台沉淀「聚合查询登记表」。 把慢聚合查询、依赖索引、升级前后的计划要点登记成表,PG17/PG18 的后续升级回归就能直接复用这份清单,而不是每次从
pg_stat_statements里重新挖。
下一节
继续学习:PG16:逻辑复制增强
参考链接
- PostgreSQL 16 Release Notes
- PostgreSQL 16 官方文档:聚合函数
- PostgreSQL 16 官方文档:窗口函数
- PostgreSQL 16 官方文档:查询规划(enable_presorted_aggregate)
- PostgreSQL 16 官方文档:pg_stat_io
- PostgreSQL 16 官方文档:pg_stat_all_tables(last_seq_scan/last_idx_scan)
- PostgreSQL 16 官方文档:并行查询
- PostgreSQL 16 官方文档:增量排序背景(执行节点)
- PostgreSQL 16 官方文档:GIN 索引
- PostgreSQL 16 官方文档:分区表