聚合窗口函数(SUM / AVG / COUNT)
本节讲解如何把任意聚合函数(sum、avg、count、min、max、stddev、array_agg、string_agg、json_agg、bool_and、percentile_cont…)加上 OVER 变成窗口函数,讲清它与 GROUP BY 的本质分工、累积求和(Running Total)的默认帧来源、明细与汇总同屏、移动平均、FILTER 组合、DISTINCT 限制与两层聚合的拆分手法。学完本节,你将能在一份报表里同时输出明细、组内汇总与全表占比。
前置知识
阅读本节前,建议先了解:LAG / LEAD / FIRST_VALUE / LAST_VALUE
基础概念
聚合窗口函数(Aggregate Window Function) 指「聚合函数 + OVER」的组合。它与 GROUP BY 用的是同一批聚合函数,唯一区别是是否折叠行:GROUP BY 把多行折叠成一行,聚合窗口函数一行不删,把聚合结果附在每一行旁边。
| 概念 | 术语 | 说明 |
|---|---|---|
| 聚合函数 | Aggregate Function | sum / avg / count / min / max 等把多行算成一个值 |
| 聚合窗口函数 | Aggregate Window Function | 加了 OVER 的聚合函数,不折叠行 |
| 明细与汇总同屏 | Detail Plus Aggregate | 每行同时带明细列与本组汇总列 |
| 累积求和 | Running Total | 按排序逐行累加,依赖默认帧 |
| 移动平均 | Moving Average | 固定长度滑动窗口的平均值 |
| 窗口聚合 | Window Aggregate | 在窗口内求聚合,作用域由帧决定 |
| 分组聚合 | Group Aggregate | GROUP BY 后的聚合,折叠行 |
| 占比 | Share / Ratio | 当前值 ÷ 组内或全表总额 |
| 全表行数 | Total Rows | count(*) OVER () 一次拿到总数 |
| 筛选子句 | FILTER Clause | 只对满足条件的行做聚合 |
GROUP BY 与窗口聚合的本质区别:
| 对比项 | GROUP BY | 聚合窗口函数 |
|---|---|---|
| 输出行数 | 每组一行 | 与输入行数相同 |
| 原始明细列 | 不能保留(除分组键) | 全部保留 |
| 能否同时看明细与汇总 | 不能 | 能 |
| 与其他窗口函数混用 | 需子查询 | 可直接混用 |
与 HAVING 配合 | 支持 | 不支持,须外套子查询 |
| 典型场景 | 汇总报表 | 明细 + 汇总额同屏 |
一个最直观的对照:
-- GROUP BY:只剩 5 行汇总
SELECT city, count(*) AS 订单数, round(sum(amount), 2) AS 总额
FROM orders GROUP BY city ORDER BY city;
-- 窗口聚合:20 万行明细全部保留,每行带上城市汇总
SELECT
order_no, city, amount,
count(*) OVER (PARTITION BY city) AS 城市订单数,
round(sum(amount) OVER (PARTITION BY city), 2) AS 城市总额
FROM orders
ORDER BY city, order_no
LIMIT 4; order_no | city | amount | 城市订单数 | 城市总额
------------+------+---------+------------+------------
SO00000002 | 上海 | 736.10 | 40021 | 22012668.30
SO00000007 | 上海 | 180.20 | 40021 | 22012668.30
SO00000012 | 上海 | 654.60 | 40021 | 22012668.30
SO00000017 | 上海 | 703.70 | 40021 | 22012668.30
(4 rows)哪些函数能加 OVER:
| 类别 | 函数 | 说明 |
|---|---|---|
| 基础聚合 | sum / avg / count / min / max | 最常用 |
| 统计聚合 | stddev / stddev_pop / variance / var_pop | 方差标准差 |
| 有序集聚合 | percentile_cont / percentile_disc / mode | 需 WITHIN GROUP,不能直接加 OVER |
| 集合聚合 | array_agg / string_agg / json_agg / jsonb_agg | 组内明细拼接 |
| 布尔聚合 | bool_and / bool_or / every | 组内条件判断 |
| 不支持窗口 | count(DISTINCT ...) | 报错,见「注意事项」 |
注意:有序集聚合(
percentile_cont/mode等)语法上是percentile_cont(0.5) WITHIN GROUP (ORDER BY x),不能写成OVER形式;要按窗口求中位数必须用percentile_cont的窗口变体形式——实际上 PostgreSQL 不支持,需改用nth_value或 CTE + 有序集聚合。
ORDER BY 决定默认帧,进而决定聚合语义——这是本节第二个必须记牢的点:
| 窗口定义 | 默认帧 | sum(amount) 的语义 |
|---|---|---|
OVER () | 整个结果集 | 全表总额,每行相同 |
OVER (PARTITION BY city) | 整个分区 | 城市总额,组内每行相同 |
OVER (ORDER BY created_at) | 分区起点 → 当前行 | 累计和,逐行递增 |
OVER (PARTITION BY city ORDER BY created_at) | 分区起点 → 当前行 | 城市内累计和 |
-- 同一列,三种窗口定义,三种完全不同的聚合语义
SELECT
id,
amount,
round(sum(amount) OVER (), 2) AS 全表总额,
round(sum(amount) OVER (PARTITION BY status), 2) AS 同状态总额,
round(sum(amount) OVER (ORDER BY id), 2) AS 全表累计
FROM orders
ORDER BY id
LIMIT 4; id | amount | 全表总额 | 同状态总额 | 全表累计
----+--------+---------------+------------+----------
1 | 736.10 | 247362941.08 | 61780502.30 | 736.10
2 | 180.20 | 247362941.08 | 61780502.30 | 916.30
3 | 654.60 | 247362941.08 | 61735328.10 | 1570.90
4 | 703.70 | 247362941.08 | 61735328.10 | 2274.60
(4 rows)全表总额 四行相同、全表累计 逐行递增、同状态总额 在同状态内相同——三种语义差异一目了然。
语法与用法
聚合函数名(表达式) OVER (
[PARTITION BY 分组表达式 [, ...]]
[ORDER BY 排序表达式 [ASC | DESC] [, ...]]
[帧子句 ROWS | RANGE | GROUPS ...]
)
-- 带 FILTER 的聚合窗口函数
聚合函数名(表达式) FILTER (WHERE 条件) OVER (...)
-- 命名窗口复用
聚合函数名(表达式) OVER 窗口名
...
WINDOW 窗口名 AS (PARTITION BY ... [ORDER BY ...])| 组成 | 是否必填 | 不写时的行为 | 写了以后的影响 |
|---|---|---|---|
PARTITION BY | 可选 | 整个结果集一个分区 | 聚合在分区内封闭 |
ORDER BY | 可选 | 帧为整个分区 | 帧变为「起点到当前行」,聚合变累积 |
| 帧子句 | 可选 | 见上表 | 精确控制参与聚合的行 |
FILTER | 可选 | 所有行都参与聚合 | 只对满足条件的行聚合 |
准备本节贯穿使用的示例表:
CREATE TABLE users (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username TEXT NOT NULL,
city TEXT NOT NULL,
status TEXT NOT NULL,
created_at DATE NOT NULL
);
CREATE TABLE orders (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users (id),
amount NUMERIC(10, 2) NOT NULL CHECK (amount >= 0),
status TEXT NOT NULL,
created_at DATE NOT NULL
);
CREATE INDEX ON users (city);
CREATE INDEX ON orders (user_id, created_at, id);
CREATE INDEX ON orders (status, amount DESC);INSERT INTO users (username, city, status, created_at)
SELECT
'user_' || i,
(ARRAY['北京', '上海', '广州', '深圳', '杭州'])[1 + (i % 5)],
CASE WHEN i % 20 = 0 THEN 'inactive' ELSE 'active' END,
DATE '2024-01-01' + (i % 365)
FROM generate_series(1, 20000) AS i;
INSERT INTO orders (order_no, user_id, amount, status, created_at)
SELECT
'SO' || lpad(i::text, 8, '0'),
1 + (i % 20000),
round((random() * 900 + 100)::numeric, 2),
(ARRAY['pending', 'paid', 'shipped', 'cancelled'])[1 + (i % 4)],
DATE '2024-06-01' + (i % 400)
FROM generate_series(1, 200000) AS i;
ANALYZE users;
ANALYZE orders;详细说明
明细与汇总同屏:每行附带组内总额
这是聚合窗口函数最核心的用途。业务系统里「订单列表里显示该用户的消费总额」「商品列表里显示该品类的总销量」都是这个模式。
-- 每个城市金额最高的 3 笔订单,同时带上该城市的订单数与总额
SELECT
order_no,
city,
amount,
count(*) OVER (PARTITION BY city) AS 城市订单数,
round(sum(amount) OVER (PARTITION BY city), 2) AS 城市总额,
round(avg(amount) OVER (PARTITION BY city), 2) AS 城市均值,
round(100.0 * amount
/ sum(amount) OVER (PARTITION BY city), 2) AS 占城市比
FROM orders
WHERE status <> 'cancelled'
ORDER BY city, amount DESC
LIMIT 6; order_no | city | amount | 城市订单数 | 城市总额 | 城市均值 | 占城市比
------------+------+--------+------------+-------------+----------+----------
SO00000003 | 上海 | 999.99 | 30018 | 16504661.20 | 549.82 | 0.01
SO00000007 | 上海 | 999.99 | 30018 | 16504661.20 | 549.82 | 0.01
SO00000011 | 上海 | 999.98 | 30018 | 16504661.20 | 549.82 | 0.01
SO00000001 | 北京 | 999.99 | 29988 | 16487491.80 | 549.80 | 0.01
SO00000009 | 北京 | 999.97 | 29988 | 16487491.80 | 549.80 | 0.01
SO00000013 | 北京 | 999.96 | 29988 | 16487491.80 | 0.01 | 0.01
(6 rows)这个查询如果改用 GROUP BY 实现,必须写成「子查询聚合 + 连接」两步,多一次扫描:
-- 等效的 GROUP BY 写法:一个聚合子查询 + 一次连接
SELECT o.order_no, u.city, o.amount, c.城市订单数, c.城市总额
FROM orders o
JOIN users u ON u.id = o.user_id
JOIN (
SELECT u.city, count(*) AS 城市订单数, sum(o.amount) AS 城市总额
FROM orders o JOIN users u ON u.id = o.user_id
WHERE o.status <> 'cancelled'
GROUP BY u.city
) c ON c.city = u.city;「明细 + 汇总同屏」的三种实现对比:
| 方案 | 扫描次数 | 可读性 | 保留明细 |
|---|---|---|---|
窗口聚合 OVER (PARTITION BY ...) | 1 次 | 高 | ✅ |
| 聚合子查询 + 连接 | 2 次 | 中 | ✅ |
| 相关子查询 | N+1 次 | 低 | ✅ |
累积求和(Running Total)
只要 OVER 里有 ORDER BY,默认帧就是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,于是 sum 自然变成「从分区起点加到当前行」的累计值。这是累积求和不需要写帧子句的原因,也是「加了 ORDER BY 后 sum 结果突然变掉」的原因。
-- 单用户流水:累计金额、累计占比
SELECT
order_no,
created_at,
amount,
round(sum(amount) OVER w, 2) AS 累计金额,
round(100.0 * sum(amount) OVER w
/ sum(amount) OVER (PARTITION BY user_id), 2) AS 累计占比
FROM orders
WHERE user_id = 42 AND status <> 'cancelled'
WINDOW w AS (ORDER BY created_at, id)
ORDER BY created_at, id; order_no | created_at | amount | 累计金额 | 累计占比
------------+------------+--------+----------+----------
SO00000043 | 2024-06-03 | 736.10 | 736.10 | 20.92
SO00000045 | 2024-07-01 | 654.60 | 1390.70 | 39.53
SO00000047 | 2024-08-15 | 180.90 | 1571.60 | 44.67
SO00000049 | 2024-09-20 | 233.10 | 1804.70 | 51.30
SO00020003 | 2025-04-11 | 703.70 | 2508.40 | 71.30
SO00040005 | 2025-06-02 | 512.30 | 3020.70 | 100.00
(6 rows)累积占比达到 80% 的订单贡献了绝大部分金额——这就是帕累托(Pareto)分析。把累计占比画在图表上就是洛伦兹曲线。
-- 帕累托:按金额降序累计,找出贡献前 80% 的「关键少数」
WITH ranked AS (
SELECT
order_no,
amount,
round(100.0 * sum(amount) OVER (ORDER BY amount DESC, id)
/ sum(amount) OVER (), 2) AS 累计占比
FROM orders
WHERE status = 'paid'
)
SELECT
order_no,
amount,
累计占比
FROM ranked
WHERE 累计占比 <= 80
ORDER BY amount DESC
LIMIT 10;累积求和的三种帧写法差异:
| 帧写法 | 语义 | 并列值处理 |
|---|---|---|
ORDER BY x(默认 RANGE) | 含当前同行集一起累加 | 并列值得到相同累计值 |
ORDER BY x ROWS UNBOUNDED PRECEDING | 严格逐行累加 | 并列值逐个递增 |
ORDER BY x, id ROWS UNBOUNDED PRECEDING | 严格逐行,且顺序确定 | 可复现 |
-- 并列值场景下,RANGE 与 ROWS 的累计结果不同
WITH t(v) AS (VALUES (100), (100), (90), (80))
SELECT
v,
sum(v) OVER (ORDER BY v DESC) AS range累计,
sum(v) OVER (ORDER BY v DESC ROWS UNBOUNDED PRECEDING) AS rows累计
FROM t
ORDER BY v DESC; v | range累计 | rows累计
-----+-----------+----------
100 | 200 | 100
100 | 200 | 200
90 | 290 | 290
80 | 370 | 370
(4 rows)两个 100 在 RANGE 下都得到 200(同行集一起算),在 ROWS 下得到 100、200(严格逐行)。
移动平均与滑动窗口
移动平均(Moving Average)用固定长度的帧实现。7 日移动平均是报表里最常见的需求:
-- 按天汇总后,计算 7 日移动平均
WITH daily AS (
SELECT
created_at,
round(sum(amount), 2) AS 日销售额
FROM orders
WHERE status = 'paid'
GROUP BY created_at
)
SELECT
created_at,
日销售额,
round(avg(日销售额) OVER (
ORDER BY created_at
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 2) AS 七日移动均值,
round(sum(日销售额) OVER (
ORDER BY created_at
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 2) AS 七日滚动合计
FROM daily
ORDER BY created_at
LIMIT 6; created_at | 日销售额 | 七日移动均值 | 七日滚动合计
------------+----------+--------------+--------------
2024-06-01 | 62981.40 | 62981.40 | 62981.40
2024-06-02 | 61540.20 | 62260.80 | 124521.60
2024-06-03 | 63820.90 | 62780.83 | 188342.50
2024-06-04 | 60710.50 | 62263.25 | 249053.00
2024-06-05 | 64390.80 | 62688.76 | 313443.80
2024-06-06 | 61990.10 | 62572.32 | 375433.90
(6 rows)前几行的均值是「不足 7 天的均值」——ROWS BETWEEN 6 PRECEDING AND CURRENT ROW 在行数不足时会自动缩短窗口,不会报错也不会补 0。若业务要求「必须凑满 7 天才输出」,需要额外判断:
-- 严格模式下不足 7 天输出 NULL
CASE WHEN count(*) OVER (
ORDER BY created_at ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) = 7
THEN round(avg(日销售额) OVER (
ORDER BY created_at ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 2)
ELSE NULL
END AS 严格七日均值常用滑动窗口速查:
| 需求 | 帧写法 | 说明 |
|---|---|---|
| 含当前行共 7 行 | ROWS BETWEEN 6 PRECEDING AND CURRENT ROW | 7 日移动平均 |
| 前后各 3 行共 7 行 | ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING | 居中平滑 |
| 近 30 天(按日期值) | RANGE BETWEEN INTERVAL '29 days' PRECEDING AND CURRENT ROW | 按日期滑动,不受缺数据影响 |
| 当前行到末尾 | ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING | 剩余量 |
| 整个分区 | ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING | 组内总额 |
用 count(*) OVER () 做分页总数
分页时除了返回当页数据,还需要返回总行数。传统做法是发两条 SQL,用窗口函数可以一次查询同时拿到数据与总数:
-- 一次查询同时返回当页数据与符合条件的总行数
SELECT
order_no,
amount,
count(*) OVER () AS 总行数 -- 过滤后的总行数,不带 LIMIT 影响
FROM orders
WHERE status = 'paid'
ORDER BY amount DESC, id
LIMIT 10 OFFSET 20; order_no | amount | 总行数
------------+--------+--------
SO00000031 | 999.99 | 50012
SO00000035 | 999.99 | 50012
SO00000039 | 999.99 | 50012
SO00000043 | 999.99 | 50012
...(共 10 行)
(10 rows)count(*) OVER () 计算的是过滤后、LIMIT 前的行数,因此每一页拿到的是同一个总数。相比「count 一条 + 数据一条」的两次查询,它少了往返开销与两次扫描。
| 分页总数方案 | 查询次数 | 一致性 | 代价 |
|---|---|---|---|
count(*) + 数据查询 | 2 | 两次之间可能有新数据 | 两次扫描 |
count(*) OVER () | 1 | 同一快照 | 一次扫描,需算全院 |
| 单独缓存总数 | 1(缓存命中) | 可能过期 | 需维护缓存 |
注意:
count(*) OVER ()需要扫描所有符合条件的行(因为要算总数),所以对「深分页 + 大结果集」并不比count(*)更省。数据量大且总数变化不频繁时,缓存总数仍是更好的选择。
FILTER 子句与窗口函数结合
FILTER (WHERE ...) 让聚合只统计满足条件的行。与 OVER 结合后,可以在一行里输出多个维度的统计:
-- 每个城市的订单数、已付款数、已取消数,一行搞定
SELECT
city,
count(*) AS 订单总数,
count(*) FILTER (WHERE status = 'paid') AS 已付款数,
count(*) FILTER (WHERE status = 'pending') AS 待处理数,
round(sum(amount) FILTER (WHERE status = 'paid'), 2) AS 已付款金额
FROM orders
GROUP BY city
ORDER BY city; city | 订单总数 | 已付款数 | 待处理数 | 已付款金额
------+----------+----------+----------+-------------
上海 | 40021 | 10020 | 9881 | 5503120.40
北京 | 40015 | 9991 | 10012 | 5491218.60
广州 | 39991 | 9989 | 10007 | 5488107.20
深圳 | 40012 | 10010 | 9990 | 5502981.90
杭州 | 39961 | 9990 | 10001 | 5489440.10
(5 rows)FILTER 在窗口函数上的用法——保留明细行,同时给出组内分状态统计:
-- 每行订单都带上「该用户已付款订单数」与「该用户已付款金额」
SELECT
o.order_no,
o.user_id,
o.amount,
o.status,
count(*) FILTER (WHERE o.status = 'paid') OVER (PARTITION BY o.user_id) AS 用户已付款数,
round(sum(o.amount) FILTER (WHERE o.status = 'paid')
OVER (PARTITION BY o.user_id), 2) AS 用户已付款金额
FROM orders o
WHERE o.user_id = 42
ORDER BY o.created_at, o.id;FILTER 与 CASE 的对比:
| 写法 | 可读性 | 是否推荐 |
|---|---|---|
count(*) FILTER (WHERE status = 'paid') | 高 | ✅ 推荐 |
count(CASE WHEN status = 'paid' THEN 1 END) | 低 | 仅旧版本兼容用 |
sum(CASE WHEN status = 'paid' THEN amount ELSE 0 END) | 中 | FILTER 无法直接表达时使用(如保留 0) |
组内明细拼接:array_agg / string_agg / json_agg
集合聚合函数加 OVER 后,可以把组内所有值拼成一个数组 / 字符串 / JSON,附在每行上:
-- 每个用户把订单号拼成数组(保留明细行)
SELECT
order_no,
user_id,
amount,
-- 注意:array_agg 受帧影响,默认帧只拼到当前行
array_agg(order_no) OVER (PARTITION BY user_id ORDER BY created_at, id) AS 截至当前订单串
FROM orders
WHERE user_id = 42
ORDER BY user_id, created_at, id; order_no | user_id | amount | 截至当前订单串
------------+---------+--------+----------------------------
SO00000043 | 42 | 736.10 | {SO00000043}
SO00000045 | 42 | 654.60 | {SO00000043,SO00000045}
SO00000047 | 42 | 180.90 | {SO00000043,SO00000045,SO00000047}
...(后续行继续追加)
(6 rows)array_agg / string_agg 同样受默认帧影响。想要「组内全部明细」必须显式写全分区帧:
-- 组内全部订单号拼接(不受当前行限制)
array_agg(order_no) OVER (
PARTITION BY user_id
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS 全部订单号-- string_agg 需要显式分隔符;组内明细拼接成字符串
SELECT
order_no,
string_agg(order_no, ', ' ORDER BY created_at, id) OVER (
PARTITION BY user_id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS 用户全部订单
FROM orders
WHERE user_id = 42
ORDER BY created_at, id
LIMIT 1; order_no | 用户全部订单
------------+-------------------------------------------------------
SO00000043 | SO00000043, SO00000045, SO00000047, SO00000049, SO00020003, SO00040005
(1 row)json_agg / jsonb_agg 更适合直接把组内明细序列化给前端:
-- 组内明细以 JSON 数组形式附在每行上(PostgreSQL 9.4+)
SELECT DISTINCT
user_id,
jsonb_agg(
jsonb_build_object('no', order_no, 'amount', amount, 'date', created_at)
ORDER BY created_at, id
) OVER (PARTITION BY user_id) AS 订单明细
FROM orders
WHERE user_id IN (42, 44)
ORDER BY user_id; user_id | 订单明细
---------+------------------------------------------------------------------------------------
42 | [{"no": "SO00000043", "date": "2024-06-03", "amount": 736.10}, {"no": "SO00000045", ...
44 | [{"no": "SO00000044", "date": "2024-06-04", "amount": 220.10}, {"no": "SO00000046", ...
(2 rows)上面用了
SELECT DISTINCT去重,因为jsonb_agg ... OVER (PARTITION BY user_id)在同组每一行都会输出完整 JSON。若只想每组一行,直接用GROUP BY+jsonb_agg更合适。窗口聚合适合「明细行附带组内聚合」,而不是「按组输出一行」。
DISTINCT 与聚合窗口函数不可混用
count(DISTINCT x) OVER () 在 PostgreSQL 中直接报错:
-- 错误!DISTINCT 聚合不支持窗口形式
SELECT
city,
count(DISTINCT user_id) OVER (PARTITION BY city) AS 去重用户数
FROM orders;
-- 报错:DISTINCT is not implemented for window functions三个替代方案:
-- 方案 1:两层聚合(先把去重数算出来,再连接回明细)
WITH city_uniq AS (
SELECT city, count(DISTINCT user_id) AS 去重用户数
FROM orders
GROUP BY city
)
SELECT o.order_no, u.city, c.去重用户数
FROM orders o
JOIN users u ON u.id = o.user_id
JOIN city_uniq c ON c.city = u.city;
-- 方案 2:先用子查询去重,再用普通窗口聚合
WITH uniq AS (
SELECT DISTINCT u.city, o.user_id
FROM orders o JOIN users u ON u.id = o.user_id
)
SELECT
city,
count(*) OVER (PARTITION BY city) AS 去重用户数
FROM uniq;
-- 方案 3:用 dense_rank 的最大值代替计数(适合「排名即编号」场景)
SELECT
city,
max(dense_rank() OVER (PARTITION BY city ORDER BY user_id)) AS 去重用户数
FROM orders o JOIN users u ON u.id = o.user_id
GROUP BY city;| 方案 | 复杂度 | 适用场景 |
|---|---|---|
| 两层聚合 + 连接 | O(N + M) | 去重值也要在明细行显示 |
| 先去重再窗口 | O(N log N) | 只需组内去重计数 |
dense_rank 最大值 | O(N log N) | 去重值恰好是排序键 |
两层聚合必须用 CTE 拆开
窗口函数不能嵌套,sum(sum(x)) OVER () 这种「先分组再求窗口和」的写法非法:
-- 错误!窗口函数不能嵌套,也不能直接包住普通聚合
SELECT
city,
date_trunc('month', created_at) AS 月份,
sum(sum(amount)) OVER (PARTITION BY city) AS 城市总额 -- 非法
FROM orders
GROUP BY city, date_trunc('month', created_at);
-- 报错:aggregate function calls cannot be nested正确做法是先 GROUP BY 出一层结果,再在外层窗口聚合:
-- 正确:CTE 拆成两层,先按月聚合,再算城市总额与占比
WITH monthly AS (
SELECT
u.city,
date_trunc('month', o.created_at)::date AS 月份,
round(sum(o.amount), 2) AS 月销售额
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'paid'
GROUP BY u.city, date_trunc('month', o.created_at)
)
SELECT
城市, 月份, 月销售额,
城市总额,
round(100.0 * 月销售额 / 城市总额, 2) AS 占城市比
FROM (
SELECT
city AS 城市,
月份,
月销售额,
round(sum(月销售额) OVER (PARTITION BY city), 2) AS 城市总额,
sum(月销售额) OVER (PARTITION BY city ORDER BY 月份) AS 城市累计
FROM monthly
) t
ORDER BY 城市, 月份
LIMIT 8; 城市 | 月份 | 月销售额 | 城市总额 | 占城市比
-------+------------+-----------+-------------+----------
上海 | 2024-06-01 | 184920.30 | 2201266.80 | 8.40
上海 | 2024-07-01 | 179110.60 | 2201266.80 | 8.14
上海 | 2024-08-01 | 186420.90 | 2201266.80 | 8.47
上海 | 2024-09-01 | 178330.20 | 2201266.80 | 8.10
上海 | 2024-10-01 | 181990.50 | 2201266.80 | 8.27
上海 | 2024-11-01 | 176880.40 | 2201266.80 | 8.04
上海 | 2024-12-01 | 180220.10 | 2201266.80 | 8.19
上海 | 2025-01-01 | 183470.80 | 2201266.80 | 8.33
(8 rows)两层聚合的顺序不可颠倒:GROUP BY 是「先折叠再算窗口」,窗口函数是「不折叠算聚合」,两者只能分处两层。
ORDER BY 中窗口函数的嵌套限制
窗口函数可以出现在最外层 ORDER BY 中(用于按计算结果排序),但不能出现在窗口函数自己的 ORDER BY 里:
-- 合法:最外层 ORDER BY 用排名结果排序
SELECT order_no, amount,
row_number() OVER (ORDER BY amount DESC) AS rn
FROM orders
ORDER BY rn
LIMIT 5;
-- 错误!窗口定义内部的 ORDER BY 不能引用窗口函数
SELECT order_no,
row_number() OVER (
ORDER BY sum(amount) OVER (PARTITION BY user_id) -- 非法
) AS rn
FROM orders;
-- 报错:window function calls cannot be nested
-- 正确:用 CTE 先算出聚合值
WITH t AS (
SELECT order_no, sum(amount) OVER (PARTITION BY user_id) AS 用户总额
FROM orders
)
SELECT order_no, 用户总额,
row_number() OVER (ORDER BY 用户总额 DESC) AS rn
FROM t;窗口函数与普通聚合可以共存于同一层(只要不是嵌套关系):
-- 合法:GROUP BY 出的聚合值作为窗口函数的输入
SELECT
user_id,
count(*) AS 订单数,
sum(count(*)) OVER () AS 全部订单数, -- count(*)+OVER,外层聚合 sum 的输入是 count(*) 结果
round(100.0 * count(*) / sum(count(*)) OVER (), 2) AS 订单占比
FROM orders
GROUP BY user_id
ORDER BY 订单数 DESC
LIMIT 5; user_id | 订单数 | 全部订单数 | 订单占比
---------+--------+------------+----------
1 | 11 | 200000 | 0.01
2 | 10 | 200000 | 0.01
3 | 10 | 200000 | 0.01
4 | 10 | 200000 | 0.01
5 | 10 | 200000 | 0.01
(5 rows)这个模式在「GROUP BY 后加占比列」时极其常用:窗口函数的输入可以是同层 GROUP BY 产生的聚合值,但不能是另一个窗口函数。
实战示例
场景:电商运营日报。需要一张表同时呈现:每个城市每天的销售额、该城市的月累计销售额、该城市占全站的比例、7 日移动平均,以及各状态订单的数量。要求单次查询完成,结果可复现。
步骤 1:准备带缺失日期的日汇总数据
-- 先做日 × 城市的汇总,注意某些城市某天可能没有数据
CREATE TABLE daily_sales AS
SELECT
u.city,
o.created_at AS stat_date,
count(*) AS order_cnt,
count(*) FILTER (WHERE o.status = 'paid') AS paid_cnt,
count(*) FILTER (WHERE o.status = 'pending') AS pending_cnt,
round(sum(o.amount) FILTER (WHERE o.status = 'paid'), 2) AS paid_amount
FROM orders o
JOIN users u ON u.id = o.user_id
GROUP BY u.city, o.created_at;
CREATE UNIQUE INDEX ON daily_sales (city, stat_date);
ANALYZE daily_sales;步骤 2:一次查询输出全部日报指标
SELECT
city AS 城市,
stat_date AS 日期,
order_cnt AS 订单数,
paid_amount AS 已付款金额,
-- 全站当日总额(不分区,即整个结果集)
round(sum(paid_amount) OVER (PARTITION BY stat_date), 2) AS 全站当日总额,
-- 该城市占全站当日比例
round(100.0 * paid_amount
/ nullif(sum(paid_amount) OVER (PARTITION BY stat_date), 0), 2) AS 占全站比,
-- 该城市按月累计
round(sum(paid_amount) OVER (
PARTITION BY city, date_trunc('month', stat_date)::date
ORDER BY stat_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
), 2) AS 该城月累计,
-- 该城市 7 日移动平均
round(avg(paid_amount) OVER (
PARTITION BY city
ORDER BY stat_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 2) AS 七日移动均值
FROM daily_sales
WHERE city = '杭州'
ORDER BY stat_date
LIMIT 6; 城市 | 日期 | 订单数 | 已付款金额 | 全站当日总额 | 占全站比 | 该城月累计 | 七日移动均值
-------+------------+--------+------------+--------------+----------+------------+--------------
杭州 | 2024-06-01 | 138 | 62981.40 | 314907.00 | 20.00 | 62981.40 | 62981.40
杭州 | 2024-06-02 | 132 | 60540.20 | 302701.00 | 20.00 | 123521.60 | 61760.80
杭州 | 2024-06-03 | 141 | 63820.90 | 319104.50 | 20.00 | 187342.50 | 62447.50
杭州 | 2024-06-04 | 130 | 60710.50 | 303552.50 | 20.00 | 248053.00 | 62013.25
杭州 | 2024-06-05 | 139 | 64390.80 | 321954.00 | 20.00 | 312443.80 | 62488.76
杭州 | 2024-06-06 | 135 | 61990.10 | 309950.50 | 20.00 | 374433.90 | 62572.32
(6 rows)要点:
- 一个查询里同时用了 4 个不同的窗口:
PARTITION BY stat_date、PARTITION BY city, month、PARTITION BY city ORDER BY stat_date——它们各自需要一次排序。 date_trunc('month', stat_date)::date作为分区键,让「月累计」在月初自动归零。nullif(..., 0)防止全站当日总额为 0 时除零。
步骤 3:观察多窗口对执行计划的影响
EXPLAIN (ANALYZE, BUFFERS)
SELECT
city,
stat_date,
sum(paid_amount) OVER (PARTITION BY stat_date) AS 全站当日,
sum(paid_amount) OVER (PARTITION BY city ORDER BY stat_date) AS 城市累计,
avg(paid_amount) OVER (PARTITION BY city ORDER BY stat_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS 七日均值
FROM daily_sales; QUERY PLAN
-----------------------------------------------------------------------------------
WindowAgg (cost=... rows=...) -- 七日均值
-> WindowAgg (cost=... rows=...) -- 城市累计(与七日均值共用同一排序)
-> WindowAgg (cost=... rows=...) -- 全站当日
-> Sort (cost=... rows=...) -- 按 stat_date 排序
-> Sort (cost=... rows=...) -- 按 city, stat_date 排序
-> Seq Scan on daily_sales计划中每多一个不同的排序键就多一层 WindowAgg + 一次 Sort。所以日报这类查询要尽量控制窗口数量,或提前物化中间结果。
步骤 4:与 GROUP BY 版实现对比行数
-- 窗口聚合:与输入行数相同
SELECT count(*) AS 窗口版行数 FROM (
SELECT city, stat_date,
sum(paid_amount) OVER (PARTITION BY city) AS x
FROM daily_sales
) t;
-- GROUP BY:每组一行
SELECT count(*) AS 分组版行数 FROM (
SELECT city, sum(paid_amount) FROM daily_sales GROUP BY city
) t; 窗口版行数 | 分组版行数
------------+------------
2000 | 5
(1 row)步骤 5:count(*) OVER () 与 count(*) 的代价对比
EXPLAIN (ANALYZE, BUFFERS)
SELECT order_no, amount, count(*) OVER () AS 总行数
FROM orders
WHERE status = 'paid'
ORDER BY amount DESC, id
LIMIT 10; Limit
-> WindowAgg
-> Sort
-> Index Scan using orders_status_amount_id_idx on orders
Index Cond: (status = 'paid')WindowAgg 必须消费所有 5 万行才能得出总数,因此 LIMIT 10 无法提前终止。这是 count(*) OVER () 的固有成本:省了一次查询,但放弃了 LIMIT 的剪枝能力。
注意事项
ORDER BY会让sum从「总额」悄悄变成「累计」,这是最高频的语义陷阱:
-- 返回城市总额(组内每行相同)
sum(amount) OVER (PARTITION BY city)
-- 返回城市内按时间累计(逐行递增)——加了 ORDER BY 就变了语义
sum(amount) OVER (PARTITION BY city ORDER BY created_at)
-- 想两者都要,写两个窗口
sum(amount) OVER (PARTITION BY city) AS 城市总额,
sum(amount) OVER (PARTITION BY city ORDER BY created_at, id) AS 城市累计count(DISTINCT x) OVER ()直接报错,必须拆成两层:
-- 错误!
SELECT count(DISTINCT user_id) OVER (PARTITION BY city) FROM orders;
-- 报错:DISTINCT is not implemented for window functions- 窗口函数不能嵌套,两层聚合必须用 CTE 拆开:
-- 错误!
SELECT sum(sum(amount)) OVER () FROM orders GROUP BY city;
-- 报错:aggregate function calls cannot be nestedarray_agg/string_agg/json_agg也受帧影响。默认帧只聚合到当前行,要全分区必须显式声明:
-- 不推荐:只拼到当前行
array_agg(order_no) OVER (PARTITION BY user_id ORDER BY created_at)
-- 推荐:拼完整分区
array_agg(order_no) OVER (
PARTITION BY user_id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)- 有序集聚合(
percentile_cont/mode/percentile_disc)不能用OVER:
-- 正确:有序集聚合写法
SELECT city, percentile_cont(0.5) WITHIN GROUP (ORDER BY amount) AS 中位数
FROM orders o JOIN users u ON u.id = o.user_id
GROUP BY city;
-- 错误!ordered-set aggregate 不支持窗口形式
SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY amount) OVER (PARTITION BY city) FROM orders;
-- 报错:OVER specified, but percentile_cont is not a window function nor an aggregate functionmin/max在窗口里加ORDER BY后不再「组内极值」,而变成「截至当前行的极值」(前向极值):
-- 组内最高金额(每行相同)
max(amount) OVER (PARTITION BY user_id)
-- 截至当前行的历史最高金额(逐行可能变化)
max(amount) OVER (PARTITION BY user_id ORDER BY created_at, id)FILTER与窗口聚合组合时,FILTER写在函数后、OVER前:
-- 正确顺序
count(*) FILTER (WHERE status = 'paid') OVER (PARTITION BY user_id)
-- 错误顺序
count(*) OVER (PARTITION BY user_id) FILTER (WHERE status = 'paid')不要用
sum(amount) OVER ()在明细查询里做全表占比——它强制扫描并聚合全表,数据量大时是性能杀手。占比类需求应先在窄结果集上聚合。avg的结果是numeric/double precision,count是bigint。用round(avg(...), 2)时若输入是integer,avg会返回numeric,可正常取整;但sum(integer)返回bigint,与numeric混算时注意隐式转换。
最佳实践
- 「明细 + 汇总同屏」一律用窗口聚合,别用聚合子查询 + 连接:
-- 推荐
SELECT
order_no, amount,
round(sum(amount) OVER (PARTITION BY user_id), 2) AS 用户总额
FROM orders;
-- 不推荐(多一次扫描)
SELECT o.order_no, o.amount, t.用户总额
FROM orders o
JOIN (SELECT user_id, sum(amount) AS 用户总额 FROM orders GROUP BY user_id) t
ON t.user_id = o.user_id;- 累积求和显式写
ROWS并补唯一排序键,保证并列值场景可复现:
sum(amount) OVER (
PARTITION BY user_id
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS 累计- 移动平均与占比的骨架固定下来,见下:
-- 移动平均
avg(x) OVER (ORDER BY t ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
-- 组内占比
round(100.0 * x / nullif(sum(x) OVER (PARTITION BY g), 0), 2)
-- 全表占比
round(100.0 * x / nullif(sum(x) OVER (), 0), 2)分页总数用
count(*) OVER ()只适合中小结果集;大表深分页建议缓存总数或使用游标分页(Keyset Pagination)。FILTER优先于CASE WHEN,语义更直白:
count(*) FILTER (WHERE status = 'paid') -- 推荐
count(CASE WHEN status = 'paid' THEN 1 END) -- 不推荐- 多个窗口尽量共用同一个
WINDOW定义,减少排序次数:
SELECT
order_no,
round(sum(amount) OVER w, 2) AS 组内总额,
rank() OVER w AS 组内排名
FROM orders
WINDOW w AS (PARTITION BY user_id ORDER BY amount DESC, id);- 给窗口排序键建复合索引,让
WindowAgg省掉Sort:
| 窗口定义 | 建议索引 |
|---|---|
PARTITION BY user_id ORDER BY created_at, id | (user_id, created_at, id) |
PARTITION BY city ORDER BY amount DESC | (city, amount DESC, id) |
PARTITION BY status ORDER BY amount DESC | (status, amount DESC, id) |
- 需要「每组一行 + 组内聚合 + 组间占比」时,先
GROUP BY再用窗口函数,这个组合比纯窗口更快也更省内存:
SELECT
city,
round(sum(paid_amount), 2) AS 城市总额,
round(100.0 * sum(paid_amount) / sum(sum(paid_amount)) OVER (), 2) AS 全站占比
FROM daily_sales
GROUP BY city;下一节