Skip to content

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 排序,最后为每一行划定一个帧,函数只在这个帧范围内计算。

概念术语说明
分区PartitionPARTITION BY 划分的独立计算分组,分区之间互不影响
排序OrderORDER BY 决定分区内行的顺序,也决定「当前行」的位置
帧Frame当前行参与计算的行集合,由帧子句定义
帧起点frame_start帧的第一行,如 UNBOUNDED PRECEDING
帧终点frame_end帧的最后一行,如 CURRENT ROW
当前行Current Row正在被计算的那一行,是帧的锚点
同行集Peer GroupORDER BY 值相等的所有行组成的一组;RANK 中的并列即来自它
物理行Physical Row排序后按位置计数的行,ROWS 模式使用
逻辑值Logical ValueORDER 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 之后,写在窗口定义的括号内:

sql
-- 帧子句完整语法
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 } ]
)

最小可运行示例:

sql
-- 创建演示表
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;
text
 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 不会自动交换。
sql
-- 错误!起点晚于终点
SELECT sum(amount) OVER (
  ORDER BY created_at
  ROWS BETWEEN CURRENT ROW AND 1 PRECEDING
) FROM orders;
text
ERROR:  frame starting from current row cannot have preceding rows
sql
-- 正确:按「最近 3 行(当前行 + 前两行)」滑动
SELECT sum(amount) OVER (
  ORDER BY created_at, id
  ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS 近三日合计
FROM orders;

ROWS 与 RANGE 的核心差异 ​

这是本节最重要的知识点。两者都在划帧,但计数方式完全不同:

模式计数依据并列值如何处理offset 类型
ROWS排序后的物理行位置并列值按顺序逐行计数,一行算一行整数
RANGEORDER BY 的值并列值整体包含或整体排除与排序键可比的值,如 INTERVAL、数字
GROUPS同行集的序号一个同行集算一组整数

用一组带并列值的数据来看差异:

sql
-- 造一组有并列金额的数据
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;
text
 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 严格得多:

  1. 只能有一列排序键。ORDER BY a, b RANGE BETWEEN ... 会直接报错;
  2. 排序键类型必须支持加减运算,即可以写 排序键值 ± offset。常见可用类型:数字、date、timestamp、interval 及支持加减的自定义类型;
  3. text、boolean、json 等类型不能作为 RANGE offset 的排序键。
sql
-- 正确:单列 date 排序键 + INTERVAL 偏移
SELECT sum(paid_amount) OVER (
  ORDER BY sale_date
  RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW
) AS 近七日合计
FROM daily_sales;
sql
-- 错误!RANGE 偏移不允许多列排序键
SELECT sum(paid_amount) OVER (
  ORDER BY sale_date, id
  RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW
) FROM daily_sales;
text
ERROR:  RANGE with offset PRECEDING/FOLLOWING requires exactly one ORDER BY column

如果确实需要按「日期 + 序号」双重排序,又要按日期滑动,正确做法是先聚合到「每天一行」,再用 RANGE:

sql
-- 先按天汇总,消除同一天的多行,再按天滑动
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 引入的第三种模式,它按同行集的序号计数,相当于「按并列组计数」:

sql
-- 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;
text
 player | score | 近两组合计
--------+-------+------------
 alice  |    90 |        180
 bob    |    90 |        180
 carol  |    85 |        265
 dave   |    80 |        165

alice 与 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 行。

sql
-- 每天一行,但日期不连续(中间有大段空缺)
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;
text
 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不排除任何行(默认行为)
sql
-- 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;
text
 player | score | 他人平均分
--------+-------+------------
 alice  |    90 |      85.00
 bob    |    90 |      85.00
 carol  |    85 |      86.67
 dave   |    80 |      88.33

EXCLUDE GROUP 与 EXCLUDE TIES 的差别在并列行上:

sql
-- 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;
text
 player | score | 排除本组 | 只排并列
--------+-------+----------+----------
 alice  |    90 |      165 |      255
 bob    |    90 |      165 |      255
 carol  |    85 |      260 |      260
 dave   |    80 |      265 |      265

alice 的同行集是 {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
sql
-- 默认帧下 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;
text
 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 开始,且受帧限制:

sql
-- 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;
text
 order_no | amount | 全分区第二值
----------+--------+--------------
 NO-1001  | 120.00 |        88.00
 NO-1007  |  88.00 |        88.00
 NO-1015  | 200.00 |        88.00

实战示例 ​

步骤 1:准备演示表 ​

sql
-- 连接到演示库
-- 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:默认帧与显式全分区帧对比 ​

sql
-- 观察默认帧(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;
text
 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:

sql
-- 同样数据,改用 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;
text
 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:按时间语义做滑动窗口 ​

sql
-- 用 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;
text
 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 计算组内占比 ​

sql
-- 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;
text
 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%。以此类推,分母随当前行金额变化。

注意事项 ​

  1. 不写 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;
  2. 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
    )
  3. ROWS 与 RANGE 在排序键有重复值时结果不同。做排名累计想「并列共享同一累计值」用 RANGE;做流水逐行累计用 ROWS。

  4. RANGE 的 offset 只能有一列排序键,多列会直接报错。需要多列排序时,先聚合或加 id 后用 ROWS。

  5. RANGE offset 要求排序键支持加减。text 列不能写 RANGE BETWEEN 'x' PRECEDING。

  6. GROUPS 与 EXCLUDE 需要 PostgreSQL 11+。低版本无法使用,需改写为 ROWS/RANGE 或无 EXCLUDE 的方案。

  7. frame_start 不能晚于 frame_end,UNBOUNDED FOLLOWING 不能作起点,否则报错。

  8. nth_value 帧内不足 n 行时返回 NULL,不是报错。若期望「取全表第 n 值」,必须写全分区帧。

  9. EXCLUDE 必须配合显式帧,因为它的作用对象是帧。若想排除当前行又要全分区范围,终点要写 UNBOUNDED FOLLOWING。

  10. 并行查询下窗口函数可能被拆分。查看计划时若看到 WindowAgg 上方的 Gather Merge,说明窗口计算在并行 worker 内完成,帧语义不受影响,但性能特征会变。

  11. 别在 WHERE 里直接过滤窗口结果。窗口函数在 WHERE 之后、ORDER BY 之前计算,必须用子查询或 CTE 包一层。

最佳实践 ​

  1. 只要涉及滑动窗口,就显式写帧。别依赖默认帧,代码可读性更重要:

    sql
    -- 推荐:意图一目了然
    sum(amount) OVER (
      PARTITION BY user_id
      ORDER BY order_date
      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS 累积
  2. 按时间语义滑动用 RANGE,按记录条数滑动用 ROWS,这是选型的核心判断标准。

  3. RANGE 无法多列排序键时,先「预聚合到目标粒度」再 RANGE,而不是退化成 ROWS:

    sql
    WITH 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;
  4. 计算「组内占比」优先用 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)
  5. 为帧排序键建复合索引,让 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)
  6. 帧越大内存开销越高。UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING 需要缓存整个分区,ROWS BETWEEN 1 PRECEDING AND CURRENT ROW 几乎不占内存。大分区上尽量用小帧。

  7. 用 EXPLAIN (ANALYZE, BUFFERS) 确认窗口节点。关注 WindowAgg 是否伴随 Sort,以及 rows 是否与预期分区大小一致:

    bash
    psql -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;"
  8. last_value 的替代写法更容易维护:如果只是要分区末值,用 max(...) OVER (PARTITION BY ...) 或 CTE 预聚合往往比堆帧子句更清晰。

下一节

继续学习:窗口函数使用场景

参考链接 ​