ORDER BY 在聚合中
sum / count 这类聚合对输入顺序无所谓,但 string_agg / array_agg / json_agg 的输出直接等于输入行的排列顺序。而 PostgreSQL 官方文档明确写着:聚合的输入行顺序默认是未定义的——它取决于执行计划的扫描顺序、并行度、甚至 work_mem 的大小。这意味着今天返回 "A, B, C" 的查询明天可能返回 "C, A, B"。本节的结论只有一条但必须牢记:凡是结果依赖行顺序的聚合,都必须显式写聚合内部的 ORDER BY。
前置知识
阅读本节前,建议先了解:FILTER 子句
基础概念
| 概念 | 术语 | 说明 |
|---|---|---|
聚合内 ORDER BY | Aggregate Order By | 写在聚合参数括号内,决定聚合读取行的顺序 |
外层 ORDER BY | Query Order By | 写在整条 SELECT 末尾,决定输出行的顺序 |
| 未定义顺序 | Unspecified Order | 不写聚合内 ORDER BY 时 PostgreSQL 不保证任何顺序 |
| 排序敏感的聚合 | Order-Sensitive Aggregate | string_agg / array_agg / json_agg / jsonb_agg |
| 排序无关的聚合 | Order-Insensitive Aggregate | sum / avg / count / min / max / bool_and |
| 有序集聚合 | Ordered-Set Aggregate | 用 WITHIN GROUP (ORDER BY ...) 语法,如 percentile_cont |
| 反聚合函数 | Inverse Function | 窗口聚合复用排序结果时的逆运算,影响计划选择 |
| 排序溢出 | Sort Spill | 排序数据超出 work_mem 时落盘,是聚合内排序的主要代价来源 |
三种 ORDER BY 的位置与作用域完全不同,混淆它们是本节乃至整个 SQL 学习中最常见的困惑:
SELECT department,
string_agg(name, ', ' ORDER BY hire_date) ← ① 聚合内部:决定拼接顺序
FROM employees
WHERE status = 'active'
GROUP BY department
ORDER BY department; ← ② 查询末尾:决定输出行顺序
-- ↑ ①括号内、②语句末尾| 位置 | 语法 | 作用对象 | 决定什么 |
|---|---|---|---|
| 聚合参数括号内 | string_agg(x, ',' ORDER BY y) | 单个聚合 | 该聚合读取行的顺序 |
WITHIN GROUP 内 | percentile_disc(0.5) WITHIN GROUP (ORDER BY x) | 有序集聚合 | 排序后取位次,语法强制要求 |
OVER 括号内 | sum(x) OVER (ORDER BY y) | 窗口聚合 | 帧滑动的顺序 |
| 语句末尾 | ORDER BY x | 结果集 | 输出行的排列 |
哪些聚合受顺序影响:
| 聚合 | 受聚合内 ORDER BY 影响吗 | 说明 |
|---|---|---|
string_agg | 是 | 输出字符串的拼接顺序 |
array_agg | 是 | 输出数组的元素顺序 |
json_agg / jsonb_agg | 是 | 输出 JSON 数组的元素顺序 |
xmlagg | 是 | 输出 XML 的元素顺序 |
json_object_agg / jsonb_object_agg | 否 | 键值对象无序;jsonb 会重排键 |
sum / avg / count | 否(sum 浮点除外) | 数值累加与顺序无关(float 累加有微小误差) |
min / max / bool_and / bool_or | 否 | 结果与顺序无关 |
stddev / variance / corr | 否 | 统计量与顺序无关 |
最小可运行示例(连跑两次看到顺序不同):
-- 不写聚合内 ORDER BY:顺序由计划决定,不可依赖
SELECT string_agg(sku, ', ') AS 商品串
FROM (VALUES ('A001'), ('B007'), ('C010')) AS t(sku); 商品串
-------------
A001, B007, C010
(1 row)-- 显式写聚合内 ORDER BY:顺序由语法保证
SELECT string_agg(sku, ', ' ORDER BY sku) AS 商品串
FROM (VALUES ('C010'), ('A001'), ('B007')) AS t(sku); 商品串
-------------
A001, B007, C010
(1 row)第二次的输入行顺序是乱的,但因为写了 ORDER BY sku,输出仍然是排好序的。这就是显式排序的价值:把「碰巧正确」变成「语法保证正确」。
贯穿本节的示例表:
-- 示例库:demo
DROP TABLE IF EXISTS employees;
CREATE TABLE employees (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
department TEXT NOT NULL,
title TEXT NOT NULL,
salary NUMERIC(10,2) NOT NULL,
hire_date DATE NOT NULL,
status TEXT NOT NULL DEFAULT 'active' -- active / left
);
INSERT INTO employees (name, department, title, salary, hire_date, status) VALUES
('张伟', '研发', '工程师', 18000.00, '2021-03-01', 'active'),
('李娜', '研发', '前端工程师', 16000.00, '2022-07-15', 'active'),
('王强', '研发', '研发经理', 28000.00, '2019-05-20', 'active'),
('刘婷', '市场', '市场专员', 12000.00, '2023-01-09', 'active'),
('陈杰', '市场', '市场总监', 25000.00, '2018-11-01', 'active'),
('赵敏', '财务', '会计', 14000.00, '2020-06-18', 'active'),
('孙磊', '财务', '财务经理', 22000.00, '2017-09-25', 'left');语法与用法
-- 1) 单个排序键
string_agg(expr, 分隔符 ORDER BY 排序列 [ASC|DESC] [NULLS FIRST|NULLS LAST])
array_agg(expr ORDER BY 排序列 [ASC|DESC])
json_agg(expr ORDER BY 排序列)
jsonb_agg(expr ORDER BY 排序列)
-- 2) 多个排序键(逗号分隔,与普通 ORDER BY 语法一致)
array_agg(sku ORDER BY created_at DESC, id ASC)
-- 3) 同时带 DISTINCT(两者表达式必须一致,见下文)
string_agg(DISTINCT sku, ', ' ORDER BY sku)
-- 4) 与 FILTER 组合:ORDER BY 在括号内,FILTER 在括号外
string_agg(sku, ', ' ORDER BY id) FILTER (WHERE qty > 1)
-- 5) 排序表达式可以是任意表达式,不必出现在输出中
string_agg(name, ', ' ORDER BY salary DESC, id) -- 按薪资从高到低点名位置规则:ORDER BY 永远写在聚合函数的参数括号内部,紧跟在最后一个参数之后。写在括号外会被当成查询级排序,与聚合完全无关:
-- 正确:聚合内排序
SELECT department, string_agg(name, ', ' ORDER BY hire_date) FROM employees GROUP BY department;
-- 错误理解:这里的 ORDER BY 是查询级排序,字符串的拼接顺序仍未定义
SELECT department, string_agg(name, ', ') FROM employees GROUP BY department ORDER BY department;语法位置只有一条规则:ORDER BY 必须写在所有常规参数之后、参数括号之内。这是最容易写错的地方——把 ORDER BY 插在参数中间,PostgreSQL 不认识这种函数签名,会报「函数不存在」,并附上一条非常直接的 HINT:
-- 错误:ORDER BY 插在了两个参数之间
SELECT string_agg(title ORDER BY title, ',') FROM employees;ERROR: function string_agg(text) does not exist
LINE 1: SELECT string_agg(title ORDER BY title, ',') FROM employees;
^
HINT: No aggregate function matches the given name and argument types. Perhaps you misplaced ORDER BY; ORDER BY must appear after all regular arguments of the aggregate.正确写法是让 ORDER BY 排在分隔符之后:
SELECT string_agg(title, ',' ORDER BY title) FROM employees;记住这个报错的特征:报「函数不存在」不一定是函数名写错了,很可能是 ORDER BY 放错了位置。看到 HINT 里出现 misplaced ORDER BY 就能直接定位。
适用性:哪些聚合真的需要聚合内 ORDER BY
聚合内 ORDER BY 在语法上对所有聚合都合法,但对可交换聚合毫无意义——sum(x ORDER BY x) 与 sum(x) 结果相同,反而白白付出一次排序代价。
-- 语法允许,但没有任何意义
SELECT sum(salary ORDER BY salary) AS 合法但无意义,
count(salary ORDER BY salary) AS 同样无意义
FROM employees; 合法但无意义 | 同样无意义
--------------+------------
135000.00 | 7
(1 row)| 聚合 | 加聚合内 ORDER BY | 建议 |
|---|---|---|
string_agg / array_agg | 有实际意义,决定元素顺序 | 必须写 |
json_agg / jsonb_agg / xmlagg | 有实际意义,决定数组/节点顺序 | 必须写 |
sum / avg / min / max | 无意义(float 的 sum 有极小数值差异) | 不要写 |
count | 无意义 | 不要写 |
bool_and / bool_or | 无意义 | 不要写 |
stddev / variance / corr | 无意义 | 不要写 |
percentile_cont / percentile_disc | 语法错误,必须用 WITHIN GROUP | 见下节 |
count(*) | 语法错误 | count(*) 没有参数,不能带 ORDER BY |
-- 错误:count(*) 不能带聚合内 ORDER BY
-- SELECT count(*) ORDER BY salary FROM employees;
-- ERROR: syntax error at or near "FROM"排序键本身可以是任意表达式,个数不限:
-- 多个排序键(与普通 ORDER BY 一致)
string_agg(title, ', ' ORDER BY hire_date DESC, id ASC)
-- 第二个键是常量:合法,但退化成单键排序(常量不参与比较)
sum(salary ORDER BY hire_date, 1)
-- 排序键可以是任意表达式,不必出现在输出中
string_agg(name, ' > ' ORDER BY length(title), id)聚合内 ORDER BY 1 不是「按第一列排序」
与查询级 ORDER BY 1(表示输出列序号)不同,聚合内的 ORDER BY 1 是常量表达式 1,它对所有行求值都相同,等于没有排序:
SELECT string_agg(name, ',') AS 无排序,
string_agg(name, ',' ORDER BY 1) AS 也等于无排序
FROM employees; 无排序 | 也等于无排序
------------------------------------+------------------------------------
张伟,李娜,王强,刘婷,陈杰,赵敏,孙磊 | 张伟,李娜,王强,刘婷,陈杰,赵敏,孙磊
(1 row)两列完全一致。聚合内不认列序号,要排序必须写出真实的列或表达式名。
详细说明
为什么必须显式 ORDER BY:顺序「未定义」意味着什么
PostgreSQL 文档对聚合输入顺序的表述是 unspecified(未指定),而不是「按主键顺序」或「按插入顺序」。这不是文档的保守说法,而是真实可复现的现象:
-- 同一份数据、同一条 SQL,改变计划就能改变输出顺序
EXPLAIN (COSTS OFF)
SELECT department, string_agg(name, ', ') FROM employees GROUP BY department; HashAggregate
Group Key: department
-> Seq Scan on employeesHashAggregate 逐行喂给聚合,顺序就是 Seq Scan 的物理顺序。一旦优化器改选 GroupAggregate(例如加了索引、或数据量变化),顺序就变成索引顺序。开了并行查询后,多个 worker 各自聚合再合并,顺序更不可能一致。
四种会让顺序发生变化的常见诱因:
| 诱因 | 顺序如何变化 |
|---|---|
计划从 HashAggregate 变为 GroupAggregate | 从物理扫描顺序变为索引顺序 |
表做过 VACUUM FULL / CLUSTER | 行的物理位置被重排 |
开启并行查询(max_parallel_workers_per_gather) | worker 各自聚合后合并,顺序随机 |
数据量变化导致 work_mem 内排序改为外部排序 | 归并段顺序改变 |
「碰巧正确」是最危险的正确
开发环境数据量小,Seq Scan 的顺序往往与插入顺序一致,于是不写 ORDER BY 的代码测试通过。上线后数据量变大、计划改变,字符串拼接顺序悄悄变了,而下游是「按逗号切分后取第一个」这类逻辑——bug 会以「数据错乱」的形式出现在完全无关的模块里,排查成本极高。
下面用一个最小例子把「计划决定顺序」这件事做实。表里三行的物理顺序与 seq 故意不一致:
DROP TABLE IF EXISTS ev2;
CREATE TABLE ev2 (order_no TEXT, seq INT, code TEXT);
INSERT INTO ev2 VALUES
('SO-6001', 3, 'E3'), ('SO-6001', 1, 'E1'), ('SO-6001', 2, 'E2'),
('SO-6002', 2, 'E2'), ('SO-6002', 3, 'E3'), ('SO-6002', 1, 'E1');
CREATE INDEX idx_ev2 ON ev2 (order_no, seq);计划一:HashAggregate + 顺序扫描(走物理顺序):
SET enable_hashagg = on;
SET enable_seqscan = on;
EXPLAIN (ANALYZE, COSTS OFF)
SELECT order_no, string_agg(code, ',') FROM ev2 GROUP BY order_no; HashAggregate (actual time=0.016..0.017 rows=2.00 loops=1)
Group Key: order_no
Batches: 1 Memory Usage: 32kB
Buffers: shared hit=1
-> Seq Scan on ev2 (actual time=0.005..0.005 rows=6.00 loops=1)SELECT order_no, string_agg(code, ',') AS 无排序
FROM ev2 GROUP BY order_no ORDER BY order_no; order_no | 无排序
----------+----------
SO-6001 | E3,E1,E2
SO-6002 | E2,E3,E1
(2 rows)计划二:GroupAggregate + 索引扫描(走索引顺序)——数据一行没变,只是换了计划:
SET enable_hashagg = off;
SET enable_seqscan = off;
EXPLAIN (ANALYZE, COSTS OFF)
SELECT order_no, string_agg(code, ',') FROM ev2 GROUP BY order_no; GroupAggregate (actual time=0.040..0.042 rows=2.00 loops=1)
Group Key: order_no
Buffers: shared hit=1 read=1
-> Index Scan using idx_ev2 on ev2 (actual time=0.032..0.032 rows=6.00 loops=1)SELECT order_no, string_agg(code, ',') AS 无排序
FROM ev2 GROUP BY order_no ORDER BY order_no; order_no | 无排序
----------+----------
SO-6001 | E1,E2,E3
SO-6002 | E1,E2,E3
(2 rows)| 计划 | 扫描方式 | SO-6001 的输出 |
|---|---|---|
HashAggregate | Seq Scan(物理顺序) | E3,E1,E2 |
GroupAggregate | Index Scan(索引顺序) | E1,E2,E3 |
加上聚合内 ORDER BY seq 后,两种计划给出完全一致的结果:
-- 计划一:HashAggregate
SELECT order_no, string_agg(code, ',' ORDER BY seq) AS 有排序
FROM ev2 GROUP BY order_no ORDER BY order_no; order_no | 有排序
----------+----------
SO-6001 | E1,E2,E3
SO-6002 | E1,E2,E3
(2 rows)-- 计划二:GroupAggregate + Index Scan
SET enable_hashagg = off;
SET enable_seqscan = off;
SELECT order_no, string_agg(code, ',' ORDER BY seq) AS 有排序
FROM ev2 GROUP BY order_no ORDER BY order_no; order_no | 有排序
----------+----------
SO-6001 | E1,E2,E3
SO-6002 | E1,E2,E3
(2 rows)这就是「语法保证」与「计划巧合」的区别:不写 ORDER BY 时结果取决于优化器当下的选择,写了的则永远一致。
排序键的选择:为什么常常需要「业务字段 + 主键」
只写一个业务字段作为排序键,在取值重复时仍然是不确定的:
-- created_at 相同的两行,谁在前?未定义
array_agg(sku ORDER BY created_at)-- 加上主键兜底,顺序唯一确定
array_agg(sku ORDER BY created_at, id)| 排序键 | 是否确定 | 说明 |
|---|---|---|
ORDER BY created_at | 否 | 同秒(或同日期)的行顺序不定 |
ORDER BY created_at, id | 是 | id 是主键,天然唯一 |
ORDER BY id | 是 | 但语义是「插入顺序」,不是业务顺序 |
ORDER BY created_at DESC, id DESC | 是 | 方向需保持一致 |
选择排序键的原则:先在业务上确定「顺序」的含义,再用一个唯一列兜底。常见搭配:
string_agg(sku, ', ' ORDER BY sort_no, id) -- 有显式排序号
jsonb_agg(row_to_json(t) ORDER BY score DESC, id) -- 排行榜:分数降序,同分按 id
array_agg(name ORDER BY hire_date, id) -- 入职先后与 DISTINCT 共用的约束
DISTINCT 与 ORDER BY 同时出现时,排序表达式必须出现在聚合参数里,否则报错:
-- 正确:排序键就是参数本身
SELECT string_agg(DISTINCT title, ', ' ORDER BY title) FROM employees;
-- 正确:用表达式作为参数时,排序键写成同一个表达式
SELECT string_agg(DISTINCT title || ' x' || salary, ', ' ORDER BY title || ' x' || salary)
FROM employees;
-- 错误:排序键 hire_date 不在参数列表中
-- SELECT string_agg(DISTINCT title, ', ' ORDER BY hire_date) FROM employees;
-- ERROR: in an aggregate with DISTINCT, ORDER BY expressions must appear in argument list原因:去重后的行里,hire_date 没有唯一确定的取值(同一 title 可能有多条不同 hire_date 的记录),排序键无法定义。
绕过办法是先去重再聚合,用子查询把「每行一个值」和「排序键」都确定下来:
SELECT string_agg(title, ', ' ORDER BY first_seen)
FROM (
SELECT title, min(hire_date) AS first_seen
FROM employees
GROUP BY title
) s;| 写法 | 是否合法 | 说明 |
|---|---|---|
string_agg(DISTINCT title, ',' ORDER BY title) | 合法 | 排序键 = 参数 |
string_agg(DISTINCT title, ',' ORDER BY hire_date) | 报错 | 排序键不在参数列表 |
string_agg(title, ',' ORDER BY hire_date) | 合法(无 DISTINCT) | 不去重时排序键自由 |
array_agg 同样如此,排序键与参数写成同一个表达式即可:
-- 合法:排序键就是参数本身
SELECT array_agg(DISTINCT status ORDER BY status) FROM employees;
-- 合法:参数是行构造器(复合类型),排序键写成同一个行构造器
SELECT array_agg(DISTINCT (department, title) ORDER BY (department, title)) FROM employees;排序键的三个细节:NULL 位置、排序规则、可引用的列
① NULLS FIRST / NULLS LAST 在聚合内同样可用,且方向会影响默认行为——ASC 默认 NULLS LAST,DESC 默认 NULLS FIRST:
SELECT
string_agg(v::text, ',' ORDER BY v) AS 升序默认,
string_agg(v::text, ',' ORDER BY v NULLS FIRST) AS 升序Null在前,
string_agg(v::text, ',' ORDER BY v DESC) AS 降序默认
FROM (VALUES (2), (NULL), (1)) AS t(v); 升序默认 | 升序Null在前 | 降序默认
----------+--------------+----------
1,2 | 1,2 | 2,1string_agg 会跳过 NULL,所以看不出差别;换成保留 NULL 的 array_agg 就一目了然:
SELECT
array_agg(v ORDER BY v) AS 升序默认, -- NULL 在最后
array_agg(v ORDER BY v NULLS FIRST) AS 升序Null在前, -- NULL 在最前
array_agg(v ORDER BY v DESC) AS 降序默认 -- NULL 在最前
FROM (VALUES (2), (NULL), (1)) AS t(v); 升序默认 | 升序Null在前 | 降序默认
-----------+--------------+-----------
{1,2,NULL} | {NULL,1,2} | {NULL,2,1}② COLLATE 可以写在排序键上,用来覆盖数据库默认排序规则。中文数据的默认规则(zh_CN.UTF-8)按拼音序,C / POSIX 按字节序:
SELECT string_agg(title, ',' ORDER BY title COLLATE "C") FROM employees; collate_c
-----------
p,q,r③ 聚合内 ORDER BY 可以引用未被分组的列,因为它是在组内对行排序,而不是在输出结果上排序。这一点正好是它和查询级 ORDER BY 的关键差异:
-- 合法:id 既不在 SELECT 列表里,也不在 GROUP BY 里,但聚合内排序可以用它
SELECT s, string_agg(b, ',' ORDER BY id) AS 按 id 拼接
FROM ord GROUP BY s; s | 按 id 拼接
---+------------
x | p,q
y | r
(2 rows)-- 错误:查询级 ORDER BY 不能引用未分组的列
SELECT s, string_agg(b, ',') FROM ord GROUP BY s ORDER BY id;ERROR: column "ord.id" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: SELECT s, string_agg(b, ',') FROM ord GROUP BY s ORDER BY id;| 位置 | 能否引用未分组的列 |
|---|---|
聚合内 ORDER BY | 能(作用于组内的行) |
查询级 ORDER BY | 不能(作用于输出的行) |
聚合内 ORDER BY 与查询级 ORDER BY:两件事
两者互不替代,一个常见的错误是以为「外面排了序,里面就跟着有序了」:
-- 字符串拼接顺序由①决定;输出行的排列由②决定,与①无关
SELECT
department,
string_agg(name, ', ' ORDER BY hire_date) AS 按入职先后 -- ①
FROM employees
GROUP BY department
ORDER BY department; -- ② department | 按入职先后
------------+--------------------------
市场 | 陈杰, 刘婷
研发 | 王强, 张伟, 李娜
财务 | 孙磊, 赵敏
(3 rows)GROUP BY 也会影响输出行顺序(HashAggregate 下无序),所以查询级 ORDER BY 同样要显式写:
-- 没有查询级 ORDER BY 时,分组的输出顺序也是未定义的
SELECT department, count(*) FROM employees GROUP BY department;GROUP BY 的输出顺序同样未定义
很多人以为 GROUP BY 会按分组键排序输出。在 HashAggregate 计划下不会。若报表要求「分组按名称排序」,末尾必须补 ORDER BY department,或者直接依赖 GroupAggregate(不推荐,它是计划细节而非语法保证)。
聚合内 ORDER BY 与 WITHIN GROUP 的区别
WITHIN GROUP 是有序集聚合的专用语法,它的 ORDER BY 不是「可选的排序提示」,而是语义的一部分——分位数就是「排序后第几个值」:
-- 有序集聚合:括号里是参数,排序写在 WITHIN GROUP 里
SELECT
department,
percentile_disc(0.5) WITHIN GROUP (ORDER BY salary) AS 中位薪资
FROM employees
GROUP BY department;| 特性 | 聚合内 ORDER BY | WITHIN GROUP (ORDER BY ...) |
|---|---|---|
| 适用函数 | string_agg / array_agg / json_agg 等 | percentile_cont / percentile_disc / mode |
| 是否可有可无 | 可省略(但顺序未定义) | 语法强制,不可省略 |
| 对结果的影响 | 决定输出元素的顺序 | 决定取第几个值,直接影响结果 |
| 语法位置 | 参数括号内 | 参数括号外,紧跟函数名之后 |
-- 两者可以同时出现在一个查询里
SELECT
department,
string_agg(name, ', ' ORDER BY hire_date) AS 按入职顺序, -- ①
percentile_disc(0.5) WITHIN GROUP (ORDER BY salary) AS 中位薪资 -- ②
FROM employees
GROUP BY department;别把 WITHIN GROUP 写成聚合内 ORDER BY
percentile_disc(0.5 ORDER BY salary) 是语法错误。有序集聚合的排序必须写在 WITHIN GROUP (...) 里,这与 string_agg 的写法位置正好相反,也是抄错 SQL 的高发点。
聚合内 ORDER BY 与窗口 OVER (ORDER BY) 的区别
窗口聚合的 ORDER BY 决定帧如何滑动,不是「聚合内部的读取顺序」:
-- 窗口:按日期累积,第 3 行的结果是前 3 行的和
SELECT id, stat_date,
sum(amount) OVER (ORDER BY stat_date, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS 累计
FROM sales;-- 聚合:整体求和,与顺序无关;写成窗口则变成累积
SELECT sum(amount) FROM sales; -- 一个总数
SELECT sum(amount) OVER (ORDER BY stat_date) FROM sales; -- 每个日期一个累计数| 场景 | 正确写法 |
|---|---|
| 「把这组行的名字按时间拼成字符串」 | string_agg(name, ',' ORDER BY created_at) |
| 「到目前为止的累计金额」 | sum(amount) OVER (ORDER BY created_at ROWS ...) |
| 「把这组行按时间排序后取中位数」 | percentile_disc(0.5) WITHIN GROUP (ORDER BY created_at) |
性能:聚合内排序的代价与优化
聚合内 ORDER BY 意味着每个分组内部都要排序。PostgreSQL 有两种执行策略:
EXPLAIN (ANALYZE, COSTS OFF)
SELECT department, string_agg(name, ', ' ORDER BY hire_date)
FROM employees
GROUP BY department; GroupAggregate
Group Key: department
-> Sort
Sort Key: department, hire_date
-> Seq Scan on employees若数据量大、分组数多,Sort 节点可能溢出到磁盘:
Sort Method: external merge Disk: 49152kB-- 提高 work_mem 让排序留在内存(会话级,注意并发内存总量)
SET work_mem = '64MB';更彻底的优化是让数据按排序键的顺序流入聚合,此时优化器用 GroupAggregate 直接顺序读取,完全省掉 Sort 节点:
-- 索引前导列是分组键,第二列是聚合内排序键
CREATE INDEX idx_employees_dept_hired ON employees (department, hire_date);
EXPLAIN (ANALYZE, COSTS OFF)
SELECT department, string_agg(name, ', ' ORDER BY hire_date)
FROM employees
GROUP BY department; GroupAggregate
Group Key: department
-> Index Scan using idx_employees_dept_hired on employees索引列顺序是「分组键 + 聚合排序键」
索引 (department, hire_date) 让同部门的行按 hire_date 有序到达,聚合无需再排序。若把两列顺序写反((hire_date, department)),则退化为 Sort + GroupAggregate 或 HashAggregate + Sort,收益消失。多排序键时索引列要按同样的顺序与方向排列((department, hire_date DESC, id))。
| 方案 | 计划形态 | 代价 |
|---|---|---|
| 无索引 | HashAggregate(无序)或 Sort + GroupAggregate | 排序可能落盘 |
只建 (department) | 仍然需要组内排序 | 无改善 |
建 (department, hire_date) | GroupAggregate 直接读索引 | 最优,零排序 |
提高 work_mem | 排序留在内存 | 治标不治本,且占内存 |
并行查询与顺序
开启并行后,聚合通常走「两阶段聚合」:每个 worker 先局部聚合,再由 leader 合并。合并这一步不保证任何顺序:
EXPLAIN (ANALYZE, COSTS OFF)
SELECT string_agg(sku, ', ' ORDER BY id) FROM large_table; Finalize Aggregate
-> Gather
Workers Planned: 2
-> Partial Aggregate
-> Parallel Seq Scan on large_tablePartial Aggregate 各算各的,Gather 收集后再 Finalize。若写了聚合内 ORDER BY,各 worker 需要先排序(或依赖有序扫描),Finalize 再归并,顺序才是确定的;不写时合并顺序取决于 worker 完成先后。
并行下不写 ORDER BY,结果可能「每次都不一样」
并行 worker 的调度受系统负载影响,同一查询连续执行两次可能返回不同顺序的字符串。这类「偶发不一致」在排查时非常难定位,而根因通常就是少写了一处 ORDER BY。
实战示例
第 1 步:部门人员名单(按入职先后)
SELECT
department AS 部门,
count(*) AS 人数,
string_agg(name, ', ' ORDER BY hire_date, id) AS 按入职先后
FROM employees
WHERE status = 'active'
GROUP BY department
ORDER BY department; 部门 | 人数 | 按入职先后
------+------+----------------------
市场 | 2 | 陈杰, 刘婷
研发 | 3 | 王强, 张伟, 李娜
财务 | 1 | 赵敏
(3 rows)status = 'left' 的孙磊被 WHERE 排除,所以财务只剩赵敏。
第 2 步:薪资排行榜(JSON 数组 + 双键排序)
SELECT
department AS 部门,
jsonb_agg(jsonb_build_object('姓名', name, '薪资', salary)
ORDER BY salary DESC, id) AS 薪资排行
FROM employees
GROUP BY department
ORDER BY 部门; 薪资排行
--------------------------------------------------------------------------
[{"姓名": "陈杰", "薪资": 25000.00}, {"姓名": "刘婷", "薪资": 12000.00}]
[{"姓名": "王强", "薪资": 28000.00}, {"姓名": "张伟", "薪资": 18000.00}, {"姓名": "李娜", "薪资": 16000.00}]
[{"姓名": "孙磊", "薪资": 22000.00}, {"姓名": "赵敏", "薪资": 14000.00}]排序键 salary DESC, id 中,id 用于薪资相同时的兜底。注意 jsonb 会重排键的显示顺序(姓名 在 薪资 前不保证),但数组元素顺序严格遵循聚合内 ORDER BY。
第 3 步:DISTINCT + ORDER BY 的正确写法
要「每个部门列出出现过的职位(去重、按字母序)」:
-- 正确:排序键就是参数本身
SELECT department, string_agg(DISTINCT title, ' / ' ORDER BY title) AS 职位列表
FROM employees
GROUP BY department
ORDER BY department; 部门 | 职位列表
------+--------------------------------------
市场 | 市场专员 / 市场总监
研发 | 工程师 / 前端工程师 / 研发经理
财务 | 财务经理 / 会计
(3 rows)排序结果取决于数据库的排序规则(lc_collate):zh_CN.UTF-8 下按拼音序,C / POSIX 下按字节序。若报表需要固定的拼音序且数据库规则不是中文规则,应显式写 ORDER BY title COLLATE "zh_CN.UTF-8",或加一个独立的 sort_key 列。
若排序键必须是与参数不同的列(如「按首次出现时间排」),就得先确定每行的一个时间:
SELECT department,
string_agg(title, ' / ' ORDER BY first_seen) AS 职位列表
FROM (
SELECT department, title, min(hire_date) AS first_seen
FROM employees
GROUP BY department, title
) t
GROUP BY department
ORDER BY department;第 4 步:排序键参与计算但不出现在输出中
排序键可以是任意表达式,不要求被选中:
-- 按「职位名称长度」排序(短的在前),长度本身不输出
SELECT department, string_agg(name, ' > ' ORDER BY length(title), id) AS 按职位名长
FROM employees
GROUP BY department
ORDER BY department; 部门 | 按职位名长
------+--------------------------------
市场 | 刘婷 > 陈杰
研发 | 张伟 > 王强 > 李娜
财务 | 赵敏 > 孙磊
(3 rows)length(title) 相同时(市场部的两个职位都是 4 个字)由 id 决定先后。
第 5 步:验证索引带来的计划改善
-- 建索引前后各看一次计划
CREATE INDEX idx_employees_dept_hired ON employees (department, hire_date);
EXPLAIN (ANALYZE, COSTS OFF)
SELECT department, string_agg(name, ', ' ORDER BY hire_date)
FROM employees
GROUP BY department; GroupAggregate
Group Key: department
-> Index Scan using idx_employees_dept_hired on employees计划里没有 Sort 节点,说明聚合直接消费了有序索引流。对比做法:把索引改成 (hire_date, department) 后再看计划,会重新出现 Sort。
DROP INDEX idx_employees_dept_hired;
DROP TABLE employees;注意事项
- 不写聚合内
ORDER BY时顺序是未定义的,不是「按插入顺序」也不是「按主键顺序」,任何一次计划变化都可能改变输出。 ORDER BY必须写在聚合函数的参数括号内部;写在括号外是查询级排序,与聚合的读取顺序无关。DISTINCT与聚合内ORDER BY共用时,排序表达式必须出现在参数列表里,否则报in an aggregate with DISTINCT, ORDER BY expressions must appear in argument list。- 只写一个非唯一列作为排序键仍是不确定的,同值行的相对顺序未定义;用「业务列 + 主键」双键兜底。
sum/count/min/max/bool_and不受聚合内ORDER BY影响,给它们加排序只会白白付出排序代价。float类型的sum与顺序有微弱的数值关系(浮点加法不满足结合律),numeric则与顺序无关。GROUP BY的输出行顺序也是未定义的,报表若要按分组键排列,末尾必须显式写查询级ORDER BY。- 有序集聚合的排序写在
WITHIN GROUP (ORDER BY ...)里,不是参数括号内;percentile_disc(0.5 ORDER BY x)是语法错误。 - 窗口
OVER (ORDER BY ...)决定帧的滑动,不是「聚合内读取顺序」,三者不要混淆。 - 并行查询下两阶段聚合的合并顺序不确定,不写
ORDER BY的聚合结果可能每次执行都不同。 - 聚合内排序在大数据量下可能溢出到磁盘(计划中的
Sort Method: external merge),此时应优先考虑用索引消除排序,而不是一味调大work_mem。 ORDER BY后接NULLS FIRST/NULLS LAST是允许的,但要注意ASC默认NULLS LAST、DESC默认NULLS FIRST;排序键含 NULL 时不同方向的默认行为会让人意外。
最佳实践
- 凡是结果依赖行顺序的聚合,一律显式写
ORDER BY,并在代码评审中把它作为硬性检查项。 - 排序键使用「业务字段 + 唯一列」,例如
ORDER BY created_at, id;id兜底能消除所有同值歧义。 - 在 SQL 注释里写明排序键的业务含义,让后来者知道这个顺序是「按入职先后」还是「按薪资高低」:sql
string_agg(name, ', ' ORDER BY hire_date, id) -- 按入职先后,同天按 id - 不确定某个聚合是否受顺序影响时,先查文档再写;排序敏感的只有
string_agg/array_agg/json_agg/jsonb_agg/xmlagg。 - 给「分组键 + 聚合排序键」建复合索引,让聚合走
GroupAggregate直接读索引,消除排序节点。 - 索引的列顺序与方向要和
ORDER BY一致:ORDER BY hire_date DESC对应(department, hire_date DESC)。 DISTINCT场景下先想清楚排序键:排序键必须来自参数列表;若需要额外排序依据,改用子查询先去重再聚合。WITHIN GROUP与聚合内ORDER BY同时出现时把它们分行写,让两种语法位置一眼可辨,避免抄错。- 用
EXPLAIN (ANALYZE, COSTS OFF)检查是否存在多余的Sort节点,有则考虑补索引而非调work_mem。 work_mem只在确认排序无法用索引消除时调大,且优先在会话级设置,避免并发下总内存超限。- 并行场景下把
ORDER BY视为必需项:worker 完成顺序随机,缺排序的结果无法复现。 - 把「顺序敏感」查询纳入回归测试:同一查询连续执行多次并断言结果完全一致,能有效拦截漏写
ORDER BY的改动。
下一节
继续学习:COALESCE / NULLIF