Skip to content

STRING_AGG / ARRAY_AGG / JSONB_AGG ​

count / sum 把多行压成一个数,而集合聚合(Collection Aggregate)把多行压成一个集合值:数组、字符串或 JSON 数组。它们是把「一对多」关系折叠进单列、直接喂给前端的关键手段。但这里有两个几乎人人都踩过的坑:聚合结果的顺序默认不确定(外层 ORDER BY 管不到聚合内部),以及各函数对 NULL 的处理并不一致——string_agg 跳过 NULL,array_agg 和 jsonb_agg 却会把 NULL 原样保留。本节把选型、保序、去重与嵌套讲透。

前置知识

阅读本节前,建议先了解:COUNT / SUM / AVG / MAX / MIN

基础概念 ​

概念术语说明
集合聚合Collection Aggregate把多行折叠为数组 / 字符串 / JSON 的聚合函数
折叠Fold多行 → 单个集合值的过程
保序Order Preservation聚合内要确定性顺序,必须写 ORDER BY
聚合内排序Aggregate ORDER BY写在函数括号内、参数之后的排序子句
嵌套构造Nested Buildjsonb_agg(jsonb_build_object(...)) 组装对象数组
展开Unnest用 unnest() 把数组还原成多行
集合聚合内存work_mem折叠大结果集时的内存上限,超出会落盘
XML 聚合xmlagg把多行拼成 XML 片段序列

方向对照:本节讲的全是「多行 → 单值」,与展开类函数方向相反。

函数输出类型典型用途NULL 是否保留
string_agg(expr, delim)text / bytea商品名拼成一行跳过
array_agg(expr)anyarray折叠成数组供后续集合运算保留
json_agg(expr)json组内明细序列化保留(输出 JSON null)
jsonb_agg(expr)jsonb同上,可索引、可比较保留(输出 JSON null)
json_object_agg(k, v)json折叠成键值对象值为 NULL → JSON null
jsonb_object_agg(k, v)jsonb同上,二进制形态值为 NULL → JSON null
xmlagg(expr)xml拼 XML 报表保留
text
折叠方向示意:

  行 1 ─┐
  行 2 ─┼─ array_agg ─▶ {a,b,c}
  行 3 ─┘

  行 1 ─┐
  行 2 ─┼─ string_agg ─▶ a,b,c
  行 3 ─┘

  行 1 ─┐
  行 2 ─┼─ jsonb_agg ─▶ [{"no":"A"},{"no":"B"}]
  行 3 ─┘

一句话记忆:要拼接文本用 string_agg(顺带丢 NULL);要保留结构化数据用 jsonb_agg(NULL 变 null);要后续做集合运算用 array_agg(也保留 NULL)。

语法与用法 ​

sql
-- 1) 字符串聚合
string_agg(value text, delimiter text [ORDER BY ...]) → text
string_agg(value bytea, delimiter bytea [ORDER BY ...]) → bytea

-- 2) 数组聚合
array_agg(expression anynonarray [ORDER BY ...]) → anyarray
array_agg(expression anyarray  [ORDER BY ...]) → anyarray   -- 数组的数组

-- 3) JSON 聚合
json_agg(expression anyelement [ORDER BY ...]) → json
jsonb_agg(expression anyelement [ORDER BY ...]) → jsonb
json_object_agg(key anyelement, value anyelement [ORDER BY ...]) → json
jsonb_object_agg(key anyelement, value anyelement [ORDER BY ...]) → jsonb

-- 4) XML 聚合
xmlagg(expression xml [ORDER BY ...]) → xml

-- 5) 通用的「只聚合部分行」修饰(与集合聚合正交)
string_agg(value, ',' ORDER BY ...) FILTER (WHERE 条件)

注意 string_agg 的分隔符是必填参数(不像 MySQL 的 group_concat 有默认值),且第一个参数必须是 text / bytea,数字要显式 ::text。

准备本节示例数据:

sql
DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders;

CREATE TABLE orders (
  id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  order_no   TEXT        NOT NULL UNIQUE,
  customer   TEXT        NOT NULL,
  channel    TEXT,                          -- 允许 NULL:线下导入的单据没有渠道
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
  id       BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
  sku      TEXT   NOT NULL,
  name     TEXT,                            -- 允许 NULL:尚未补录品名
  qty      INTEGER NOT NULL CHECK (qty > 0),
  price    NUMERIC(10, 2) NOT NULL
);

INSERT INTO orders (order_no, customer, channel, created_at) VALUES
  ('SO-1001', 'alice', 'app',    TIMESTAMPTZ '2026-02-01 09:00:00+08'),
  ('SO-1002', 'bob',   'web',    TIMESTAMPTZ '2026-02-02 10:30:00+08'),
  ('SO-1003', 'carol', NULL,     TIMESTAMPTZ '2026-02-03 14:20:00+08');

INSERT INTO order_items (order_id, sku, name, qty, price) VALUES
  (1, 'CN-ELEC-0001', '无线鼠标', 1,  89.00),
  (1, 'CN-ELEC-0002', '机械键盘', 2, 399.00),
  (1, 'CN-HOME-0003', NULL,       1,  25.50),
  (2, 'US-HOME-0004', '收纳箱',   4,  25.50),
  (2, 'CN-ELEC-0005', '显示器',   1, 899.00),
  (3, 'CN-BOOK-0006', 'SQL 进阶', 3,  68.00);

最小可运行示例:

sql
SELECT
  o.order_no,
  string_agg(i.name, ', ')                        AS 品名串,
  array_agg(i.sku)                                AS SKU数组,
  jsonb_agg(i.sku)                                AS SKU的JSON,
  count(*)                                        AS 明细数
FROM orders o
JOIN order_items i ON i.order_id = o.id
GROUP BY o.order_no
ORDER BY o.order_no;
text
 order_no |     品名串      |                  SKU数组                   |                 SKU的JSON
----------+-----------------+--------------------------------------------+--------------------------------------------
 SO-1001  | 无线鼠标, 机械键盘 | {CN-ELEC-0001,CN-ELEC-0002,CN-HOME-0003}   | ["CN-ELEC-0001", "CN-ELEC-0002", "CN-HOME-0003"]
 SO-1002  | 显示器, 收纳箱    | {US-HOME-0004,CN-ELEC-0005}                | ["US-HOME-0004", "CN-ELEC-0005"]
 SO-1003  | SQL 进阶         | {CN-BOOK-0006}                             | ["CN-BOOK-0006"]
(3 rows)

注意 SO-1001 的 品名串:三条明细里有一条 name IS NULL,string_agg 把它丢掉了,拼接结果只剩两个品名,且分隔符只有一处——这正是「跳过 NULL」而非「当作空串占位」的直接证据(若当空串,会得到 无线鼠标, 机械键盘, 这样带尾随分隔符的畸形结果)。对照 SKU数组 仍是三个元素,NULL 被保留了。

详细说明 ​

聚合内的 ORDER BY 是唯一保序手段 ​

集合聚合的输入顺序默认不确定。原因有三:并行扫描会打乱行的到达顺序、HashAggregate 的哈希桶顺序不稳定、更新历史会让同一查询在不同时刻拿到不同顺序。

唯一可靠的保序方式是把 ORDER BY 写进聚合函数的参数列表:

sql
-- 正确:ORDER BY 写在聚合括号内
SELECT
  order_no,
  array_agg(sku ORDER BY sku)              AS 按SKU排,
  string_agg(sku, ',' ORDER BY sku DESC)   AS 倒序拼接
FROM orders o
JOIN order_items i ON i.order_id = o.id
GROUP BY order_no
ORDER BY order_no;
text
 order_no |                 按SKU排                  |               倒序拼接
----------+------------------------------------------+----------------------------------------
 SO-1001  | {CN-ELEC-0001,CN-ELEC-0002,CN-HOME-0003} | CN-HOME-0003,CN-ELEC-0002,CN-ELEC-0001
 SO-1002  | {CN-ELEC-0005,US-HOME-0004}              | US-HOME-0004,CN-ELEC-0005
 SO-1003  | {CN-BOOK-0006}                           | CN-BOOK-0006
(3 rows)

外层 ORDER BY 完全管不到聚合内部,这是最容易误判的一点:

sql
-- 错误认知:以为外层排序会影响聚合内的拼接顺序
SELECT
  order_no,
  string_agg(sku, ',') AS 拼接
FROM orders o
JOIN order_items i ON i.order_id = o.id
GROUP BY order_no
ORDER BY order_no;          -- 这行只排「结果行」,与拼接顺序无关
text
 order_no |                拼接
----------+--------------------------------
 SO-1001  | CN-ELEC-0001,CN-ELEC-0002,CN-HOME-0003
 SO-1002  | US-HOME-0004,CN-ELEC-0005
 SO-1003  | CN-BOOK-0006
(3 rows)

这里的顺序看着对,但只是巧合——它恰好等于物理插入顺序。一旦表变大、走上并行计划或发生 VACUUM FULL 重排,顺序就会变。要区分两者:

写法位置作用对象能否决定聚合内顺序
聚合参数内 agg(x ORDER BY y)聚合的输入流能
语句末尾 ORDER BY y最终结果行不能
有序集聚合 WITHIN GROUP (ORDER BY y)分位数 / 众数等能(但语法位置不同)

三类 ORDER BY 的语法位置辨析与报错对照,见 ORDER BY 在聚合中。

外层 ORDER BY 无效的直观证明 ​

用一张只有 4 行的临时表就能看出「外层排序不会重排聚合输入」:

sql
WITH t(pos, v) AS (
  VALUES (3, 'c'), (1, 'a'), (4, 'd'), (2, 'b')
)
SELECT
  string_agg(v, ',')              AS 无内部排序,   -- 顺序=扫描顺序,不确定
  string_agg(v, ',' ORDER BY pos) AS 按pos排序,    -- c 一定在 d 前
  string_agg(v, ',' ORDER BY v)   AS 按值排序
FROM t
ORDER BY 1;                                       -- 外层排序,对上面三列无效
text
 无内部排序 | 按pos排序 | 按值排序
------------+-----------+----------
 c,a,d,b    | c,a,d,b   | a,b,c,d

第一列的输出与扫描顺序一致,纯属当前物理布局的结果,换一次 UPDATE 就可能变成 a,d,c,b。

DISTINCT 与 ORDER BY 必须指向同一表达式 ​

去重聚合有一个硬性约束:DISTINCT 与聚合内 ORDER BY 同时出现时,排序表达式必须出现在聚合的参数列表里,否则报错。

sql
-- 错误!排序键 created_at 不在 string_agg 的参数里
SELECT string_agg(DISTINCT tag, ',' ORDER BY created_at)
FROM order_tags;
text
ERROR:  in an aggregate with DISTINCT, ORDER BY expressions must appear in argument list
LINE 1: SELECT string_agg(DISTINCT tag, ',' ORDER BY created_at)
                                             ^

原因是语义上无法成立:DISTINCT 已经把行压成「不同的 tag」,created_at 在这些去重后的行上不再有唯一对应值,排序键失去意义。

sql
-- 正确做法一:排序键就是被聚合的值本身
SELECT string_agg(DISTINCT tag, ',' ORDER BY tag) AS 去重有序
FROM (VALUES ('vip'), ('new'), ('vip'), ('hot')) AS t(tag);
text
  去重有序
-------------
 hot,new,vip
(1 row)
sql
-- 正确做法二:先在外层去重,再聚合
WITH uniq AS (
  SELECT DISTINCT ON (tag) tag, created_at
  FROM order_tags
  ORDER BY tag, created_at
)
SELECT string_agg(tag, ',' ORDER BY created_at) AS 按首次出现拼接
FROM uniq;

数组聚合同理,array_agg(DISTINCT x ORDER BY y) 一样要求 y 在参数中:

sql
-- 多列去重:把「值」写成行构造器,排序键也用同一个构造器
SELECT array_agg(DISTINCT (sku, qty) ORDER BY (sku, qty)) AS 去重明细
FROM order_items;

NULL 处理:三种函数三种行为 ​

这是本节最需要在代码评审里盯住的地方:

sql
WITH t(v) AS (VALUES ('a'), (NULL), ('b'))
SELECT
  string_agg(v, ',')      AS string_agg结果,   -- a,b
  array_agg(v)            AS array_agg结果,    -- {a,NULL,b}
  array_to_string(array_agg(v), ',') AS 数组转串_跳NULL,   -- a,b
  array_to_string(array_agg(v), ',', '[空]') AS 数组转串_占位, -- a,[空],b
  jsonb_agg(v)            AS jsonb_agg结果     -- ["a", null, "b"]
FROM t;
text
 string_agg结果 |   array_agg结果    | 数组转串_跳NULL | 数组转串_占位 |     jsonb_agg结果
----------------+--------------------+-----------------+---------------+-----------------------
 a,b            | {a,NULL,b}         | a,b             | a,[空],b      | ["a", null, "b"]
(1 row)
函数NULL 行为后果
string_agg跳过拼接结果里没有占位,元素个数少于行数
array_agg保留 NULL 元素数组长度 = 行数,但含 NULL
array_to_string(a, d)跳过 NULL与 string_agg 表面一致
array_to_string(a, d, 'X')用 'X' 替换 NULL明确占位
jsonb_agg / json_agg保留为 JSON null前端要处理 null 元素
xmlagg保留NULL 会变成空内容

string_agg 无法通过参数跳过或保留 NULL

string_agg 天生丢弃 NULL,没有 null_string 参数。若业务需要「未补录品名显示为『待补』」,只能先改写表达式:

sql
-- 把 NULL 转成占位文本再聚合
string_agg(coalesce(name, '待补'), ', ' ORDER BY sku)

反过来,若要「结果里不要 NULL」,对数组聚合必须显式过滤,因为 array_agg 不会替你跳过:

sql
SELECT
  array_agg(sku)                             AS 含NULL,
  array_agg(sku) FILTER (WHERE sku IS NOT NULL) AS 已过滤,
  array_remove(array_agg(sku), NULL)         AS 事后移除
FROM order_items;

嵌套构造:jsonb_agg(jsonb_build_object(...)) ​

把「订单 + 明细」结构化成前端可直接消费的嵌套 JSON,是这个函数组合最经典的用法:

sql
SELECT
  o.order_no,
  o.customer,
  jsonb_agg(
    jsonb_build_object(
      'sku',   i.sku,
      'name',  i.name,          -- 可能是 null
      'qty',   i.qty,
      'price', i.price,
      'amount', round(i.qty * i.price, 2)
    )
    ORDER BY i.sku
  ) AS items,
  sum(i.qty * i.price) AS total
FROM orders o
JOIN order_items i ON i.order_id = o.id
GROUP BY o.id, o.order_no, o.customer
ORDER BY o.order_no;
text
 order_no | customer |                                                                 items                                                                  |  total
----------+----------+----------------------------------------------------------------------------------------------------------------------------------------+---------
 SO-1001  | alice    | [{"qty": 1, "sku": "CN-ELEC-0001", "name": "无线鼠标", "price": 89.00, "amount": 89.00}, {"qty": 2, "sku": "CN-ELEC-0002", "name": "机械键盘", "price": 399.00, "amount": 798.00}, {"qty": 1, "sku": "CN-HOME-0003", "name": null, "price": 25.50, "amount": 25.50}] |  912.50
 SO-1002  | bob      | [{"qty": 1, "sku": "CN-ELEC-0005", "name": "显示器", "price": 899.00, "amount": 899.00}, {"qty": 4, "sku": "US-HOME-0004", "name": "收纳箱", "price": 25.50, "amount": 102.00}] | 1001.00
 SO-1003  | carol    | [{"qty": 3, "sku": "CN-BOOK-0006", "name": "SQL 进阶", "price": 68.00, "amount": 204.00}]                                              |  204.00
(3 rows)

要点:

  • jsonb_build_object 的参数是交替的键值对,键必须是文本,值可以是任意类型;键的书写顺序不影响结果(jsonb 会按长度与字节序重排键)。
  • ORDER BY 写在 jsonb_agg(...) 的括号内,ORDER BY i.sku 必须是聚合参数里的表达式或分组键——上例中 i.sku 是聚合参数的一层,PostgreSQL 允许在 ORDER BY 中引用输入行上的列。
  • GROUP BY o.id 带上主键即可,o.order_no / o.customer 可由主键函数依赖推出(PostgreSQL 9.1+ 的函数依赖分组)。

若确定不需要 jsonb 的运算能力(只做展示),用 json_agg 更省一次解析开销;反之,要往 JSON 里做 @> 包含查询就必须用 jsonb_agg。

json_object_agg / jsonb_object_agg:折叠成键值对象 ​

这两个函数把「两列」折叠成一个 JSON 对象,适合做「码值 → 名称」或「指标名 → 指标值」的字典:

sql
SELECT jsonb_object_agg(channel, cnt) AS 渠道分布
FROM (
  SELECT coalesce(channel, 'unknown') AS channel, count(*) AS cnt
  FROM orders
  GROUP BY 1
) s;
text
                 渠道分布
--------------------------------------------
 {"app": 1, "web": 1, "unknown": 1}
(1 row)

键的唯一性是调用方的责任——重复键不会报错,后者覆盖前者:

sql
SELECT jsonb_object_agg(k, v) AS 结果
FROM (VALUES ('a', 1), ('a', 2)) AS t(k, v);
text
    结果
-------------
 {"a": 2}
(1 row)

这与 jsonb_object_agg_unique(需要自己写聚合或用 DISTINCT 预处理)不同,标准函数就是「静默覆盖」。要防止覆盖,先在子查询里按 k 去重。

另一个限制:键不能为 NULL,会直接报错:

sql
SELECT jsonb_object_agg(k, v) FROM (VALUES (NULL, 1)) AS t(k, v);
-- ERROR:  field name must not be null

xmlagg ​

xmlagg 拼出来的是 XML 片段序列,常用于导出报表或与遗留系统对接:

sql
SELECT
  xmlelement(name orders,
    xmlagg(xmlelement(name item, i.sku))
  ) AS xml_doc
FROM order_items i;
text
                                    xml_doc
--------------------------------------------------------------------------------
 <orders><item>CN-ELEC-0001</item><item>CN-ELEC-0002</item>...</orders>
(1 row)

XML 类型在没有 XMLOPTION 特殊设置时保留空白,且没有原生排序保证,同样需要 ORDER BY:

sql
xmlagg(xmlelement(name item, sku) ORDER BY sku)

用 unnest 把集合还原成多行 ​

折叠的逆操作是展开。数组可以用 unnest() 直接变回行,这是「临时把列当表用」的核心技巧:

sql
-- 还原:把 SO-1001 的 SKU 数组展开回多行
SELECT o.order_no, u.sku
FROM (
  SELECT order_no, array_agg(sku ORDER BY sku) AS skus
  FROM orders o
  JOIN order_items i ON i.order_id = o.id
  GROUP BY order_no
) o
CROSS JOIN LATERAL unnest(o.skus) AS u(sku)
WHERE o.order_no = 'SO-1001';
text
 order_no |     sku
----------+---------------
 SO-1001  | CN-ELEC-0001
 SO-1001  | CN-ELEC-0002
 SO-1001  | CN-HOME-0003
(3 rows)

JSON 数组则要先用 jsonb_array_elements 拆开:

sql
SELECT o.order_no, e.value ->> 'sku' AS sku, (e.value ->> 'qty')::INT AS qty
FROM (
  SELECT order_no, jsonb_agg(jsonb_build_object('sku', sku, 'qty', qty) ORDER BY sku) AS items
  FROM orders o JOIN order_items i ON i.order_id = o.id
  GROUP BY order_no
) o
CROSS JOIN LATERAL jsonb_array_elements(o.items) AS e(value)
ORDER BY o.order_no, sku;

string_agg 无法无损还原:一旦某个值本身含分隔符(如品名里的逗号),拆分就会错位。需要往返转换的场景,一律用数组或 JSON。

性能与 work_mem ​

集合聚合必须把整个组的中间结果留在内存里——这与 sum / count 只需一个累加器完全不同:

聚合内存占用落盘
count / sum / avgO(1)不落盘
string_agg / array_agg / jsonb_aggO(组内行数 × 单行大小)超出 work_mem 时由 HashAggregate / Sort 溢出到临时文件

组特别大时(例如「把某客户 5 年订单全部拼成一个数组」),应:

  1. 提高会话级 work_mem:SET LOCAL work_mem = '128MB';
  2. 拆分聚合粒度,先按天/按订单折叠,再在外层二次聚合;
  3. 只取需要的列(jsonb_build_object 里少放字段),避免单行过大。
sql
-- 观察聚合是否落盘:注意 HashAggregate 的 "Disk Usage" 行
EXPLAIN (ANALYZE, BUFFERS)
SELECT order_id, jsonb_agg(jsonb_build_object('sku', sku, 'qty', qty)) FROM order_items GROUP BY order_id;
text
                                  QUERY PLAN
-------------------------------------------------------------------------------
 HashAggregate  (cost=1.20..1.30 rows=3 width=32) (actual time=0.045..0.048 rows=3 loops=1)
   Group Key: order_id
   Batches: 1  Memory Usage: 24kB
   ->  Seq Scan on order_items  (cost=0.00..1.06 rows=6 width=36)

小数据量下 Memory Usage 只有几十 kB,绝不会落盘。真正需要关注 Disk Usage 的是单组上万行、单行又很大的场景。另外,work_mem 是每个排序/哈希操作的上限,不是整个查询的总量,并发高时不要盲目调大。

实战示例 ​

场景:给订单详情接口产出一行「订单 + 嵌套明细 + 品名摘要」的 JSON,要求顺序稳定、NULL 可控。

第 1 步:拼接商品清单,专门处理 NULL 品名 ​

sql
SELECT
  o.order_no,
  string_agg(coalesce(i.name, '【待补录】'), ' / ' ORDER BY i.sku) AS 品名摘要,
  count(*)                                                        AS 明细数,
  count(i.name)                                                   AS 已补录数
FROM orders o
JOIN order_items i ON i.order_id = o.id
GROUP BY o.order_no
ORDER BY o.order_no;
text
 order_no |              品名摘要              | 明细数 | 已补录数
----------+------------------------------------+--------+----------
 SO-1001  | 无线鼠标 / 机械键盘 / 【待补录】   |      3 |        2
 SO-1002  | 显示器 / 收纳箱                    |      2 |        2
 SO-1003  | SQL 进阶                           |      1 |        1
(3 rows)

count(*) 与 count(i.name) 之差(3 vs 2)直接量化了「待补录」的条目数,报表里可以据此告警。

第 2 步:每单产出嵌套 JSON ​

sql
SELECT
  jsonb_build_object(
    'order_no',   o.order_no,
    'customer',   o.customer,
    'channel',    o.channel,
    'item_count', count(*),
    'total',      sum(i.qty * i.price),
    'items',      jsonb_agg(
                    jsonb_build_object('sku', i.sku, 'name', i.name, 'qty', i.qty, 'price', i.price)
                    ORDER BY i.sku
                  )
  ) AS payload
FROM orders o
JOIN order_items i ON i.order_id = o.id
GROUP BY o.id, o.order_no, o.customer, o.channel
ORDER BY o.order_no
LIMIT 1;
text
                                                    payload
---------------------------------------------------------------------------------------------------------------
 {"total": 912.50, "items": [{"qty": 1, "sku": "CN-ELEC-0001", "name": "无线鼠标", "price": 89.00}, ...], "channel": "app", "customer": "alice", "order_no": "SO-1001", "item_count": 3}
(1 row)

把 jsonb_build_object 放在聚合之外,得到的就是「每单一个完整对象」;如果放进去(如前一节示例),得到的是「对象数组」。两种嵌套层次要按接口约定选。

第 3 步:把渠道分布折叠成字典 ​

sql
SELECT jsonb_object_agg(channel, jsonb_build_object('orders', cnt, 'amount', amt)) AS 渠道看板
FROM (
  SELECT
    coalesce(o.channel, 'unknown') AS channel,
    count(DISTINCT o.id)           AS cnt,
    sum(i.qty * i.price)           AS amt
  FROM orders o JOIN order_items i ON i.order_id = o.id
  GROUP BY 1
) s;
text
                                             渠道看板
----------------------------------------------------------------------------------------------------
 {"app": {"amount": 912.50, "orders": 1}, "web": {"amount": 1001.00, "orders": 1}, "unknown": {"amount": 204.00, "orders": 1}}
(1 row)

第 4 步:数组去重 + 排序 + 展开验证 ​

sql
-- 把每个 SKU 前缀(前两段)去重后折叠
WITH prefixes AS (
  SELECT order_id, split_part(sku, '-', 1) || '-' || split_part(sku, '-', 2) AS prefix
  FROM order_items
)
SELECT
  o.order_no,
  array_agg(DISTINCT p.prefix ORDER BY p.prefix) AS 前缀去重
FROM orders o JOIN prefixes p ON p.order_id = o.id
GROUP BY o.order_no
ORDER BY o.order_no;
text
 order_no |    前缀去重
----------+------------------
 SO-1001  | {CN-ELEC,CN-HOME}
 SO-1002  | {CN-ELEC,US-HOME}
 SO-1003  | {CN-BOOK}
(3 rows)

展开验证一下折叠结果与明细一致:

sql
WITH folded AS (
  SELECT order_id, array_agg(DISTINCT split_part(sku, '-', 1) || '-' || split_part(sku, '-', 2) ORDER BY 1) AS pf
  FROM order_items GROUP BY order_id
)
SELECT o.order_no, count(*) AS 去重前缀数
FROM orders o JOIN folded f ON f.order_id = o.id
CROSS JOIN LATERAL unnest(f.pf) AS u(prefix)
GROUP BY o.order_no
ORDER BY o.order_no;
text
 order_no | 去重前缀数
----------+------------
 SO-1001  |          2
 SO-1002  |          2
 SO-1003  |          1
(3 rows)

第 5 步:对照 string_agg 与 array_to_string 的 NULL 差异 ​

sql
SELECT
  string_agg(i.name, '|' ORDER BY i.sku)                            AS string_agg_跳NULL,
  array_to_string(array_agg(i.name ORDER BY i.sku), '|')            AS 数组转串_跳NULL,
  array_to_string(array_agg(i.name ORDER BY i.sku), '|', '∅')       AS 数组转串_占位
FROM order_items i
WHERE i.order_id = 1;
text
 string_agg_跳NULL | 数组转串_跳NULL | 数组转串_占位
-------------------+-----------------+----------------
 无线鼠标|机械键盘 | 无线鼠标|机械键盘 | 无线鼠标|机械键盘|∅
(1 row)

结论很明确:默认两条路都会丢掉 NULL,想要占位就必须用三参数的 array_to_string,或者聚合前 coalesce。

第 6 步:清理 ​

sql
DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders;

注意事项 ​

  1. 聚合内 ORDER BY 是保序的唯一手段。外层 ORDER BY 排的是结果行,与聚合输入顺序无关,且「碰巧正确」不可依赖:

    sql
    -- 不可靠
    string_agg(x, ',') ... ORDER BY y
    -- 可靠
    string_agg(x, ',' ORDER BY y)
  2. string_agg 的第二个参数分隔符必填,且分隔符本身不会被转义:

    sql
    -- 错误:缺少分隔符
    SELECT string_agg(name) FROM order_items;
    -- ERROR:  function string_agg(text) does not exist
  3. string_agg 只接受 text / bytea,数字必须先转换:

    sql
    -- 错误
    SELECT string_agg(qty, ',') FROM order_items;
    -- 正确
    SELECT string_agg(qty::text, ',') FROM order_items;
  4. array_agg 与 jsonb_agg 保留 NULL,string_agg 跳过 NULL。同一个报表里混用这两类函数会出现「元素个数对不上」的诡异现象。

  5. DISTINCT 与聚合内 ORDER BY 必须同源,排序键必须出现在聚合参数中:

    sql
    -- 报错
    string_agg(DISTINCT tag, ',' ORDER BY created_at)
    -- 可行
    string_agg(DISTINCT tag, ',' ORDER BY tag)
  6. array_agg(DISTINCT a ORDER BY b) 中 b 也必须在参数里;多列去重请用行构造器 array_agg(DISTINCT (a, b) ORDER BY (a, b))。

  7. jsonb_object_agg 的重复键静默覆盖,键为 NULL 直接报错。折叠前先确认键唯一且非空。

  8. jsonb_build_object 的键顺序不保留(jsonb 会重排键)。需要保留原始键序时用 json_build_object + json_agg,代价是失去 jsonb 的索引与运算符能力。

  9. string_agg 的结果无法安全还原。值里含分隔符时会错位,需要往返转换必须用数组或 JSON。

  10. GROUP BY 与集合聚合组合时,ORDER BY 里只能引用分组键或聚合参数中的列,引用未分组的其他列会报 column must appear in the GROUP BY clause。

  11. 大组折叠会让 work_mem 溢出到临时文件,EXPLAIN (ANALYZE, BUFFERS) 中若出现 Disk Usage 就要考虑降低聚合粒度或提高 work_mem。

  12. array_agg 折叠数组列会得到「数组的数组」,再 unnest 一次才能拉平:

    sql
    SELECT unnest(array_agg(tags)) FROM t;   -- 二维 → 一维元素流

最佳实践 ​

  1. 只要顺序有必要,就给聚合写上 ORDER BY。哪怕当前数据碰巧有序,也把它显式化——顺序稳定才能被测试断言与缓存依赖:

    sql
    array_agg(sku ORDER BY sku)
  2. 展示层拼接优先 string_agg,数据交换层用 jsonb_agg。前者轻、可读;后者的结构可被前端直接解析,不会因为值里含逗号而歧义。

  3. NULL 语义在 SQL 里显式表态,不要依赖函数默认行为:

    sql
    -- 想显示占位
    string_agg(coalesce(name, '待补录'), ', ' ORDER BY sku)
    -- 想去掉 NULL
    array_agg(sku) FILTER (WHERE sku IS NOT NULL)
  4. 嵌套 JSON 用 jsonb_build_object + jsonb_agg 组合,并统一字段命名风格;把 ORDER BY 写在聚合内部保证数组顺序稳定:

    sql
    jsonb_agg(jsonb_build_object('sku', sku, 'qty', qty) ORDER BY sku)
  5. 接口返回 JSON 时限制 jsonb_agg 的字段与行数。一次返回上千条明细会把响应体和内存同时撑爆,超出阈值应改为「分页明细接口 + 摘要字段」。

  6. 先聚合后展开,而不是先展开后聚合。unnest 在 GROUP BY 之后能精确控制行数;反过来会先把行数放大再折叠,代价成倍。

  7. 需要「去重 + 有序」时优先在子查询里 DISTINCT,而不是在聚合里叠加 DISTINCT 与 ORDER BY——后者对排序键有额外约束,可读性也差。

  8. 对超大组的集合聚合做分级折叠:先按天/按订单折叠成较小的数组,再在外层 array_agg 拼接,避免单组内存爆掉:

    sql
    WITH daily AS (
      SELECT user_id, day, array_agg(event ORDER BY ts) AS events
      FROM events GROUP BY user_id, day
    )
    SELECT user_id, count(*) AS days, sum(cardinality(events)) AS total_events
    FROM daily GROUP BY user_id;
  9. array_agg 的结果若还要做集合运算,加上 array_remove / FILTER 清理 NULL,否则 @>、&& 的结果会被 NULL 元素干扰。

  10. 给拼接结果设定长度上限意识。text 没有硬上限,但一个几 MB 的字符串列会让网络传输、日志与前端渲染同时变慢;必要时用 left(string_agg(...), 2000) 截断。

下一节

继续学习:BOOL_AND / BOOL_OR / EVERY

参考链接 ​