FRAME 子句(ROWS / RANGE / GROUPS)
窗口函数里最容易被忽略、也最容易算错的,就是帧(Frame)——它决定了每一行「能看到哪些行」。
上一节我们用 sum(...) OVER (ORDER BY ...) 得到累积求和,用 ROWS BETWEEN 6 PRECEDING AND CURRENT ROW 得到移动平均,但当时并没有解释:为什么加了 ORDER BY 之后 sum 就变成累积的?为什么 last_value 默认只返回到当前行?为什么有时候按 7 行滑动、有时候却要按 7 天滑动?
答案全在帧子句里。学完本节你能做到:
- 准确说出 PostgreSQL 的默认帧规则,并预判每个窗口函数在不写帧时的行为;
- 分清
ROWS/RANGE/GROUPS三种模式,知道什么时候必须用RANGE; - 用
EXCLUDE精确排除当前行、同行集(Peer Group)或并列值; - 修掉
last_value、first_value、nth_value最常见的「结果只有一行对」的 Bug。
前置知识
阅读本节前,建议先了解:聚合窗口函数(SUM / AVG / COUNT)
基础概念
窗口函数分两步工作:先根据 PARTITION BY 把数据切成若干分区,再在分区内根据 ORDER BY 排序,最后为每一行划定一个帧,函数只在这个帧范围内计算。
| 概念 | 术语 | 说明 |
|---|---|---|
| 分区 | Partition | PARTITION BY 划分的独立计算分组,分区之间互不影响 |
| 排序 | Order | ORDER BY 决定分区内行的顺序,也决定「当前行」的位置 |
| 帧 | Frame | 当前行参与计算的行集合,由帧子句定义 |
| 帧起点 | frame_start | 帧的第一行,如 UNBOUNDED PRECEDING |
| 帧终点 | frame_end | 帧的最后一行,如 CURRENT ROW |
| 当前行 | Current Row | 正在被计算的那一行,是帧的锚点 |
| 同行集 | Peer Group | ORDER BY 值相等的所有行组成的一组;RANK 中的并列即来自它 |
| 物理行 | Physical Row | 排序后按位置计数的行,ROWS 模式使用 |
| 逻辑值 | Logical Value | ORDER BY 表达式的值,RANGE 模式按它计算 |
关键点:帧的默认值不是「整个分区」。很多人以为 OVER (ORDER BY x) 里的函数能看见分区全部行,实际并非如此。
默认帧规则
只要写了 ORDER BY,默认帧就是「分区起点到当前行(含与当前行并列的所有行)」;不写 ORDER BY 时,默认帧才是整个分区。
| 窗口定义 | 默认帧 | 帧语义 |
|---|---|---|
OVER () | 整个分区 | 全部分区行 |
OVER (PARTITION BY city) | 整个分区 | 该城市全部分区行 |
OVER (ORDER BY created_at) | RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW | 从分区第一行到当前行所在同行集的最后一行 |
OVER (PARTITION BY city ORDER BY created_at) | RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW | 同上,仅限本城市 |
RANGE ... AND CURRENT ROW 的「当前行」含义特殊:它包含当前行的所有并列行(同行集),而不是物理意义上的那一行。这一点在排序键有重复值时会造成「意外多算」,后面 ROWS vs RANGE 一节会详细演示。
语法与用法
帧子句紧跟在 ORDER BY 之后,写在窗口定义的括号内:
-- 帧子句完整语法
function_name(...) OVER (
[ PARTITION BY 表达式 [, ...] ]
[ ORDER BY 表达式 [ASC | DESC] [NULLS FIRST | NULLS LAST] [, ...] ]
[ { ROWS | RANGE | GROUPS } BETWEEN frame_start AND frame_end ]
[ EXCLUDE { CURRENT ROW | GROUP | TIES | NO OTHERS } ]
)最小可运行示例:
-- 创建演示表
CREATE TABLE daily_sales (
id bigserial PRIMARY KEY,
sale_date date NOT NULL,
city text NOT NULL,
paid_amount numeric(12,2) NOT NULL
);
INSERT INTO daily_sales (sale_date, city, paid_amount) VALUES
('2026-01-01', 'beijing', 100.00),
('2026-01-02', 'beijing', 120.00),
('2026-01-03', 'beijing', 80.00),
('2026-01-04', 'beijing', 150.00),
('2026-01-01', 'shanghai', 200.00),
('2026-01-02', 'shanghai', 180.00);
-- 显式声明帧:只看「前一行 + 当前行」
SELECT
city,
sale_date,
paid_amount,
sum(paid_amount) OVER (
PARTITION BY city
ORDER BY sale_date
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
) AS 近两日合计
FROM daily_sales
ORDER BY city, sale_date; city | sale_date | paid_amount | 近两日合计
----------+------------+-------------+------------
beijing | 2026-01-01 | 100.00 | 100.00
beijing | 2026-01-02 | 120.00 | 220.00
beijing | 2026-01-03 | 80.00 | 200.00
beijing | 2026-01-04 | 150.00 | 230.00
shanghai | 2026-01-01 | 200.00 | 200.00
shanghai | 2026-01-02 | 180.00 | 380.00详细说明
frame_start 与 frame_end 的取值
帧由起点和终点共同描述,两端各有 5 种取值,但合法组合有限制:
| 取值 | 含义 | 能否作起点 | 能否作终点 |
|---|---|---|---|
UNBOUNDED PRECEDING | 分区第一行 | 是 | 是 |
offset PRECEDING | 当前行往前第 offset 行/值 | 是 | 是 |
CURRENT ROW | 当前行(RANGE 下为同行集边界) | 是 | 是 |
offset FOLLOWING | 当前行往后第 offset 行/值 | 是 | 是 |
UNBOUNDED FOLLOWING | 分区最后一行 | 否 | 是 |
约束规则:
frame_start不能在frame_end之后,例如BETWEEN CURRENT ROW AND 1 PRECEDING直接报错;UNBOUNDED FOLLOWING不能作为起点,因为它意味着「起点在分区末尾」;- 写入顺序必须是「起点在前、终点在后」,PostgreSQL 不会自动交换。
-- 错误!起点晚于终点
SELECT sum(amount) OVER (
ORDER BY created_at
ROWS BETWEEN CURRENT ROW AND 1 PRECEDING
) FROM orders;ERROR: frame starting from current row cannot have preceding rows-- 正确:按「最近 3 行(当前行 + 前两行)」滑动
SELECT sum(amount) OVER (
ORDER BY created_at, id
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS 近三日合计
FROM orders;ROWS 与 RANGE 的核心差异
这是本节最重要的知识点。两者都在划帧,但计数方式完全不同:
| 模式 | 计数依据 | 并列值如何处理 | offset 类型 |
|---|---|---|---|
ROWS | 排序后的物理行位置 | 并列值按顺序逐行计数,一行算一行 | 整数 |
RANGE | ORDER BY 的值 | 并列值整体包含或整体排除 | 与排序键可比的值,如 INTERVAL、数字 |
GROUPS | 同行集的序号 | 一个同行集算一组 | 整数 |
用一组带并列值的数据来看差异:
-- 造一组有并列金额的数据
CREATE TABLE score_board (
player text NOT NULL,
score integer NOT NULL
);
INSERT INTO score_board (player, score) VALUES
('alice', 90),
('bob', 90),
('carol', 85),
('dave', 80);
-- 同一条 SQL 里对比 ROWS 与 RANGE 的差异
SELECT
player,
score,
sum(score) OVER (ORDER BY score DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rows_累积,
sum(score) OVER (ORDER BY score DESC RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS range_累积
FROM score_board
ORDER BY score DESC, player; player | score | rows_累积 | range_累积
--------+-------+-----------+------------
alice | 90 | 90 | 180
bob | 90 | 180 | 180
carol | 85 | 265 | 265
dave | 80 | 345 | 345看第 1 行:alice 与 bob 分数并列 90。
ROWS模式:alice排第一,帧只含alice,累积 90;RANGE模式:alice与bob是同行集,CURRENT ROW把整个同行集纳入帧,累积 90 + 90 = 180。
所以两个「并列第一名」在 RANGE 下拿到完全相同的累积值,在 ROWS 下则分先后。做排名类累计时,RANGE 才是语义正确的:并列名次理应共享同一个累计值。
反过来,如果业务上要求「逐行流水累计」,比如按插入顺序累计交易额,即使金额相同也要分先后,就必须用 ROWS。
RANGE 的 offset 限制
RANGE 支持 offset,但限制比 ROWS 严格得多:
- 只能有一列排序键。
ORDER BY a, b RANGE BETWEEN ...会直接报错; - 排序键类型必须支持加减运算,即可以写
排序键值 ± offset。常见可用类型:数字、date、timestamp、interval及支持加减的自定义类型; text、boolean、json等类型不能作为RANGEoffset 的排序键。
-- 正确:单列 date 排序键 + INTERVAL 偏移
SELECT sum(paid_amount) OVER (
ORDER BY sale_date
RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW
) AS 近七日合计
FROM daily_sales;-- 错误!RANGE 偏移不允许多列排序键
SELECT sum(paid_amount) OVER (
ORDER BY sale_date, id
RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW
) FROM daily_sales;ERROR: RANGE with offset PRECEDING/FOLLOWING requires exactly one ORDER BY column如果确实需要按「日期 + 序号」双重排序,又要按日期滑动,正确做法是先聚合到「每天一行」,再用 RANGE:
-- 先按天汇总,消除同一天的多行,再按天滑动
WITH daily AS (
SELECT sale_date, sum(paid_amount) AS amount
FROM daily_sales
GROUP BY sale_date
)
SELECT
sale_date,
amount,
sum(amount) OVER (
ORDER BY sale_date
RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW
) AS 近七日合计
FROM daily
ORDER BY sale_date;GROUPS 模式
GROUPS 是 PostgreSQL 11 引入的第三种模式,它按同行集的序号计数,相当于「按并列组计数」:
-- PostgreSQL 11+
-- GROUPS:向前 1 个同行集 + 当前同行集
SELECT
player,
score,
sum(score) OVER (
ORDER BY score DESC
GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW
) AS 近两组合计
FROM score_board
ORDER BY score DESC, player; player | score | 近两组合计
--------+-------+------------
alice | 90 | 180
bob | 90 | 180
carol | 85 | 265
dave | 80 | 165alice 与 bob 同属第 1 组,向前没有组,所以只算本组 = 180;carol 是第 2 组,含第 1 组 + 第 2 组 = 180 + 85 = 265;dave 是第 3 组,含第 2 组 + 第 3 组 = 85 + 80 = 165。
GROUPS 的实用场景是「按名次区间取数」:比如「取当前名次及前一名次的所有选手」,用 GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW 比 RANGE 更直观,且不要求排序键支持加减。
RANGE 做日期滑动窗口
这是 RANGE 最有价值、也最容易被 ROWS 误伤的场景。
假设要算「最近 N 天销售额」。如果数据每天恰好一行且日期连续,ROWS N PRECEDING 和 RANGE BETWEEN INTERVAL 'N days' PRECEDING AND CURRENT ROW 结果一样。但只要日期出现空缺,两者就分道扬镳——ROWS 会「补足行数」,把窗口拉长到 N 行。
-- 每天一行,但日期不连续(中间有大段空缺)
CREATE TABLE web_traffic (
stat_date date NOT NULL,
visits integer NOT NULL
);
INSERT INTO web_traffic (stat_date, visits) VALUES
('2026-02-01', 100),
('2026-02-10', 120),
('2026-02-11', 140),
('2026-02-20', 160);
-- ROWS 按「上一条记录」滑:空缺日期不影响,会跨月补足
-- RANGE 按「过去 3 天」滑:空缺日期如实反映为「窗口内无数据」
SELECT
stat_date,
visits,
sum(visits) OVER (ORDER BY stat_date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS rows_上一条,
sum(visits) OVER (ORDER BY stat_date RANGE BETWEEN INTERVAL '3 days' PRECEDING AND CURRENT ROW) AS range_近三天
FROM web_traffic
ORDER BY stat_date; stat_date | visits | rows_上一条 | range_近三天
------------+--------+-------------+--------------
2026-02-01 | 100 | 100 | 100
2026-02-10 | 120 | 220 | 120
2026-02-11 | 140 | 260 | 260
2026-02-20 | 160 | 300 | 160看 2026-02-10 这一行:ROWS 取「上一条记录」,也就是 2026-02-01 的 100,合计 220——但这条记录在 9 天前,用它算「近三天」显然不合理;RANGE 严格按「当前日期减 3 天」划窗,2026-02-01 落在窗口外,所以只有自己 120。同理 2026-02-20:ROWS 得到 140 + 160 = 300,RANGE 则只有 160。
结论很明确:凡是要表达「过去 N 天 / N 小时」这种时间语义的滑动窗口,一律用 RANGE + INTERVAL;ROWS 只适合「过去 N 条记录」的位置语义。 数据一旦有日期空缺,用 ROWS 就会把窗口悄悄拉长,算出语义错误的数字。
EXCLUDE 子句
EXCLUDE(PostgreSQL 11+)用来在已经划好的帧里再排除一部分行,常用于「排名时排除自己」:
| 选项 | 排除内容 |
|---|---|
EXCLUDE CURRENT ROW | 排除当前行 |
EXCLUDE GROUP | 排除当前行所在的整个同行集 |
EXCLUDE TIES | 排除同行集中的其他行,但保留当前行 |
EXCLUDE NO OTHERS | 不排除任何行(默认行为) |
-- PostgreSQL 11+
-- 计算「除自己以外的其余人平均分」
SELECT
player,
score,
round(avg(score) OVER (
ORDER BY score
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
EXCLUDE CURRENT ROW
), 2) AS 他人平均分
FROM score_board
ORDER BY score DESC; player | score | 他人平均分
--------+-------+------------
alice | 90 | 85.00
bob | 90 | 85.00
carol | 85 | 86.67
dave | 80 | 88.33EXCLUDE GROUP 与 EXCLUDE TIES 的差别在并列行上:
-- PostgreSQL 11+
-- 排除整个同行集 vs 只排除并列的其他行
SELECT
player,
score,
sum(score) OVER (
ORDER BY score DESC
RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
EXCLUDE GROUP
) AS 排除本组,
sum(score) OVER (
ORDER BY score DESC
RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
EXCLUDE TIES
) AS 只排并列
FROM score_board
ORDER BY score DESC, player; player | score | 排除本组 | 只排并列
--------+-------+----------+----------
alice | 90 | 165 | 255
bob | 90 | 165 | 255
carol | 85 | 260 | 260
dave | 80 | 265 | 265alice 的同行集是 {90, 90},EXCLUDE GROUP 把两行都排除,剩 85 + 80 = 165;EXCLUDE TIES 只排除并列的 bob,保留 alice 自己,剩 90 + 85 + 80 = 255。汇总值必须用 UNBOUNDED FOLLOWING 作终点,否则默认帧还是「到当前行为止」。
帧对 first_value / last_value / nth_value 的影响
first_value、last_value、nth_value 直接返回帧边界的值,所以帧写法决定结果:
| 函数 | 返回 | 默认帧下的行为 |
|---|---|---|
first_value(x) | 帧内第一行的 x | 恒为分区第一行(起点是 UNBOUNDED PRECEDING) |
last_value(x) | 帧内最后一行的 x | 只到当前行,不是分区最后一行 |
nth_value(x, n) | 帧内第 n 行的 x | 当前行不足 n 行时返回 NULL |
-- 默认帧下 last_value 退化成「当前行的值」
SELECT
order_no,
amount,
first_value(amount) OVER (ORDER BY created_at, id) AS 首值,
last_value(amount) OVER (ORDER BY created_at, id) AS 末值_默认帧_错误,
last_value(amount) OVER (
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS 末值_全分区_正确
FROM orders
WHERE user_id = 42 AND status <> 'cancelled'
ORDER BY created_at, id; order_no | amount | 首值 | 末值_默认帧_错误 | 末值_全分区_正确
----------+--------+------+------------------+------------------
NO-1001 | 120.00 | 120 | 120.00 | 120.00
NO-1007 | 88.00 | 120 | 88.00 | 120.00
NO-1015 | 200.00 | 120 | 200.00 | 120.00「末值_默认帧_错误」列每行都等于本行 amount,这就是上节强调的 last_value 陷阱在帧层面的原因。first_value 因为帧起点是 UNBOUNDED PRECEDING,默认就能正确取分区首行。
nth_value 的计数从 1 开始,且受帧限制:
-- nth_value 从 1 开始计数;帧不足时返回 NULL
SELECT
order_no,
amount,
nth_value(amount, 2) OVER (
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS 全分区第二值
FROM orders
WHERE user_id = 42 AND status <> 'cancelled'
ORDER BY created_at, id; order_no | amount | 全分区第二值
----------+--------+--------------
NO-1001 | 120.00 | 88.00
NO-1007 | 88.00 | 88.00
NO-1015 | 200.00 | 88.00实战示例
步骤 1:准备演示表
-- 连接到演示库
-- psql -d demo -f frame_demo.sql
DROP TABLE IF EXISTS frame_orders;
CREATE TABLE frame_orders (
id bigserial PRIMARY KEY,
user_id integer NOT NULL,
order_date date NOT NULL,
amount numeric(10,2) NOT NULL
);
INSERT INTO frame_orders (user_id, order_date, amount) VALUES
(1, '2026-03-01', 100.00),
(1, '2026-03-01', 50.00),
(1, '2026-03-02', 200.00),
(1, '2026-03-03', 150.00),
(1, '2026-03-05', 300.00),
(1, '2026-03-06', 120.00),
(2, '2026-03-01', 400.00),
(2, '2026-03-02', 250.00);步骤 2:默认帧与显式全分区帧对比
-- 观察默认帧(RANGE + CURRENT ROW)与显式全分区帧的差异
SELECT
user_id,
order_date,
amount,
sum(amount) OVER (
PARTITION BY user_id ORDER BY order_date
) AS 默认帧_累积,
sum(amount) OVER (
PARTITION BY user_id ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS 全分区合计,
last_value(amount) OVER (
PARTITION BY user_id ORDER BY order_date
) AS 末值_默认帧,
last_value(amount) OVER (
PARTITION BY user_id ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS 末值_全分区
FROM frame_orders
ORDER BY user_id, order_date, id; user_id | order_date | amount | 默认帧_累积 | 全分区合计 | 末值_默认帧 | 末值_全分区
---------+------------+--------+-------------+------------+-------------+-------------
1 | 2026-03-01 | 100.00 | 150.00 | 920.00 | 100.00 | 120.00
1 | 2026-03-01 | 50.00 | 150.00 | 920.00 | 50.00 | 120.00
1 | 2026-03-02 | 200.00 | 350.00 | 920.00 | 200.00 | 120.00
1 | 2026-03-03 | 150.00 | 500.00 | 920.00 | 150.00 | 120.00
1 | 2026-03-05 | 300.00 | 800.00 | 920.00 | 300.00 | 120.00
1 | 2026-03-06 | 120.00 | 920.00 | 920.00 | 120.00 | 120.00
2 | 2026-03-01 | 400.00 | 400.00 | 650.00 | 400.00 | 250.00
2 | 2026-03-02 | 250.00 | 650.00 | 650.00 | 250.00 | 250.00注意前两行:2026-03-01 有 100 和 50 两笔,是并列行(同行集),默认 RANGE 帧把整个同行集纳入,所以两行的「默认帧_累积」都是 150,而不是 100 和 150。这就是 RANGE 的语义。如果换成 ROWS:
-- 同样数据,改用 ROWS,逐行累计
SELECT
user_id,
order_date,
amount,
sum(amount) OVER (
PARTITION BY user_id ORDER BY order_date, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS rows_累积
FROM frame_orders
ORDER BY user_id, order_date, id; user_id | order_date | amount | rows_累积
---------+------------+--------+-----------
1 | 2026-03-01 | 100.00 | 100.00
1 | 2026-03-01 | 50.00 | 150.00
1 | 2026-03-02 | 200.00 | 350.00
1 | 2026-03-03 | 150.00 | 500.00
1 | 2026-03-05 | 300.00 | 800.00
1 | 2026-03-06 | 120.00 | 920.00
2 | 2026-03-01 | 400.00 | 400.00
2 | 2026-03-02 | 250.00 | 650.00加了 id 作次级排序键后,两笔同日订单不再并列,ROWS 就能逐行累加。
步骤 3:按时间语义做滑动窗口
-- 用 RANGE + INTERVAL 表达「过去 3 天」,语义与数据缺失无关
SELECT
user_id,
order_date,
amount,
sum(amount) OVER (
PARTITION BY user_id
ORDER BY order_date
RANGE BETWEEN INTERVAL '3 days' PRECEDING AND CURRENT ROW
) AS 近三日合计
FROM frame_orders
ORDER BY user_id, order_date, id; user_id | order_date | amount | 近三日合计
---------+------------+--------+-------------
1 | 2026-03-01 | 100.00 | 150.00
1 | 2026-03-01 | 50.00 | 150.00
1 | 2026-03-02 | 200.00 | 350.00
1 | 2026-03-03 | 150.00 | 500.00
1 | 2026-03-05 | 300.00 | 650.00
1 | 2026-03-06 | 120.00 | 570.00
2 | 2026-03-01 | 400.00 | 400.00
2 | 2026-03-02 | 250.00 | 650.00逐行验证:2026-03-05 的窗口是 [2026-03-02, 2026-03-05],命中 03-02 的 200、03-03 的 150、03-05 的 300,合计 650;03-01 的 150 距它 4 天,落在窗口外被排除。2026-03-06 的窗口是 [2026-03-03, 2026-03-06],命中 150 + 300 + 120 = 570,03-02 的 200 已出窗。可见 RANGE 严格按日期值划窗,不因中间日期缺失而把窗口拉长。
步骤 4:EXCLUDE 计算组内占比
-- PostgreSQL 11+
-- 计算每笔订单在「同用户其他订单」中的占比
SELECT
user_id,
order_date,
amount,
round(
100.0 * amount / sum(amount) OVER (
PARTITION BY user_id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
EXCLUDE CURRENT ROW
),
2
) AS 占他人比例
FROM frame_orders
ORDER BY user_id, order_date, id; user_id | order_date | amount | 占他人比例
---------+------------+--------+------------
1 | 2026-03-01 | 100.00 | 12.20
1 | 2026-03-01 | 50.00 | 5.75
1 | 2026-03-02 | 200.00 | 27.78
1 | 2026-03-03 | 150.00 | 19.48
1 | 2026-03-05 | 300.00 | 48.39
1 | 2026-03-06 | 120.00 | 15.00
2 | 2026-03-01 | 400.00 | 160.00
2 | 2026-03-02 | 250.00 | 62.50用户 1 总额 920。第一笔 100 的分母是「其余 820」,100 / 820 ≈ 12.20%;第二笔 50 的分母是 870,50 / 870 ≈ 5.75%。以此类推,分母随当前行金额变化。
注意事项
不写
ORDER BY与写了ORDER BY的默认帧完全不同,这是最隐蔽的坑:sql-- 不写 ORDER BY:帧 = 整个分区,sum 得到分区总额 SELECT sum(amount) OVER (PARTITION BY user_id) FROM orders; -- 写了 ORDER BY:帧 = 起点到当前行,sum 得到累积值 SELECT sum(amount) OVER (PARTITION BY user_id ORDER BY created_at) FROM orders;last_value必须显式写全分区帧,否则结果恒为当前行:sql-- 错误!默认帧下末值就是当前行 last_value(amount) OVER (ORDER BY created_at) -- 正确 last_value(amount) OVER ( ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING )ROWS与RANGE在排序键有重复值时结果不同。做排名累计想「并列共享同一累计值」用RANGE;做流水逐行累计用ROWS。RANGE的 offset 只能有一列排序键,多列会直接报错。需要多列排序时,先聚合或加id后用ROWS。RANGEoffset 要求排序键支持加减。text列不能写RANGE BETWEEN 'x' PRECEDING。GROUPS与EXCLUDE需要 PostgreSQL 11+。低版本无法使用,需改写为ROWS/RANGE或无EXCLUDE的方案。frame_start不能晚于frame_end,UNBOUNDED FOLLOWING不能作起点,否则报错。nth_value帧内不足 n 行时返回NULL,不是报错。若期望「取全表第 n 值」,必须写全分区帧。EXCLUDE必须配合显式帧,因为它的作用对象是帧。若想排除当前行又要全分区范围,终点要写UNBOUNDED FOLLOWING。并行查询下窗口函数可能被拆分。查看计划时若看到
WindowAgg上方的Gather Merge,说明窗口计算在并行 worker 内完成,帧语义不受影响,但性能特征会变。别在
WHERE里直接过滤窗口结果。窗口函数在WHERE之后、ORDER BY之前计算,必须用子查询或 CTE 包一层。
最佳实践
只要涉及滑动窗口,就显式写帧。别依赖默认帧,代码可读性更重要:
sql-- 推荐:意图一目了然 sum(amount) OVER ( PARTITION BY user_id ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS 累积按时间语义滑动用
RANGE,按记录条数滑动用ROWS,这是选型的核心判断标准。RANGE无法多列排序键时,先「预聚合到目标粒度」再RANGE,而不是退化成ROWS:sqlWITH daily AS ( SELECT user_id, order_date, sum(amount) AS amount FROM frame_orders GROUP BY user_id, order_date ) SELECT user_id, order_date, sum(amount) OVER ( PARTITION BY user_id ORDER BY order_date RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW ) AS 近七日 FROM daily;计算「组内占比」优先用
EXCLUDE CURRENT ROW,比总额 - 当前值更不易出错:sql-- PostgreSQL 11+ round(100.0 * amount / sum(amount) OVER ( PARTITION BY user_id ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW ), 2)为帧排序键建复合索引,让
WindowAgg免去排序:窗口定义 建议索引 PARTITION BY user_id ORDER BY order_date(user_id, order_date)PARTITION BY user_id ORDER BY order_date, id(user_id, order_date, id)ORDER BY amount DESC(amount DESC)帧越大内存开销越高。
UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING需要缓存整个分区,ROWS BETWEEN 1 PRECEDING AND CURRENT ROW几乎不占内存。大分区上尽量用小帧。用
EXPLAIN (ANALYZE, BUFFERS)确认窗口节点。关注WindowAgg是否伴随Sort,以及rows是否与预期分区大小一致:bashpsql -d demo -c "EXPLAIN (ANALYZE, BUFFERS) SELECT sum(amount) OVER (PARTITION BY user_id ORDER BY order_date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) FROM frame_orders;"last_value的替代写法更容易维护:如果只是要分区末值,用max(...) OVER (PARTITION BY ...)或 CTE 预聚合往往比堆帧子句更清晰。
下一节
继续学习:窗口函数使用场景