窗口分区决定哪些行属于同一组,窗口帧决定其中哪些行参与当前行的某个帧敏感计算。把两种边界混淆,可能让逐笔累计变成重复显示的小计,也可能让 last_value 返回当前值,而报表原本需要的是分区最后一行的值。应先说明计算要回答什么,再选择排序和帧范围。

默认范围取决于是否排序

PostgreSQL 的默认帧是 RANGE:从分区开头到当前行的最后一个并列行。窗口 ORDER BY 中全部表达式都相等的行,属于同一个并列组。因此,有排序时的默认累计和,会包含当前行的全部同值伙伴,即使其中某些行在最终展示结果里排在后面,也已经进入当前帧。

如果窗口没有 ORDER BY,所有行都是并列行,默认帧就覆盖整个分区。因此,sum(amount) OVER (PARTITION BY account) 会重复显示账户总额;添加排序后,计算就变成有顺序的累计结果。“默认总是整个组”和“默认每次恰好多加一行”都不能同时描述这两种情况,不能把其中一种当成通用规则。

窗口顺序与最终输出顺序是分开的。外层 ORDER BY 可以改变展示排列,而不改变帧。反过来,在 RANGE 窗口内部加入唯一标识,可能把原来的并列组拆开,改变计算结果;这不只是让页面看起来稳定,而是修改了语义,应当按需求审阅。

按行、按值域和按并列组

ROWS 计算窗口排序中的位置。从前两行到当前行,最多包含三行;靠近分区起点时还会更少。它不表示三天或三分钟。如果多个事件时间戳相同,而业务需要确定的逐事件顺序,就应加入合适的唯一标识,明确同一时刻的事件谁先谁后。

RANGE 使用排序值和并列边界。指定偏移量时,PostgreSQL 要求只有一个排序表达式,并使用兼容的偏移类型,例如时间戳可以配合 interval。基于时间值域的帧按时间差纳入事件,忙时可能包含很多行,闲时可能很少。GROUPS 则按并列组计数,让帧沿不同排序值对应的组移动,同时保留组内全部并列行。这三者表达的是不同的计量单位。

first_value、last_value、nth_value,以及作为窗口函数使用的聚合,都依赖帧范围。排名和 lag、lead 的定义则基于分区顺序,并非相同规则;缩小帧不会让 lag 忘记分区中的前一行。解释某个窗口函数前,应查看该函数的语义,不能把“只看到帧内的行”套用到所有函数上。

帧成员范围不会覆盖函数本身的空值规则。PostgreSQL 18 的 last_value 及相关取值函数采用 RESPECT NULLS,末行为空时结果也为空;sum 等聚合通常忽略空输入。应先确定报表要末行取值、最后一个非空值,还是数值最大值。这些需求不能仅靠把帧延长到分区末尾就互相替代,改窗口之前要把空值案例写入验收条件。

坏掉的样子

有序默认帧中的 last_value,通常取得当前帧末尾的值,并不是整个分区末尾的值。排序唯一时,这恰好就是当前行。若要在每一行重复显示分区最后一个值,需要把结束边界写为 UNBOUNDED FOLLOWING,并提供能够定义“最后”的顺序。按时间排序后的末行值,与该字段的数值最大值,也不是同一个问题。

累计报表中,同一天的多行显示相同累计额,可能让人误以为计算漏了步骤,其实这是默认 RANGE 的并列行为。如果业务要求每个独立事件都推进一次累计,应使用明确的 ROWS 帧和确定的事件顺序。若报表本来按日期层级累计,就不应为了画出更熟悉的曲线而消除真实并列;结果是否正确要依据业务单位判断。

快速验证

把下面内容保存为 frames.sql,在可丢弃的 PostgreSQL 18 数据库中执行 psql -X -v ON_ERROR_STOP=1 -f frames.sql。两笔事件在第一天,第三笔在第二天。脚本最后回滚,临时对象和输入行都会消失。

\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE sales (id integer, day integer, amount integer);
INSERT INTO sales VALUES (1,1,10),(2,1,20),(3,2,5);
CREATE TEMP VIEW framed AS
SELECT *,
  sum(amount) OVER (ORDER BY day) AS default_sum,
  sum(amount) OVER (ORDER BY day,id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS row_sum,
  last_value(amount) OVER (ORDER BY day,id) AS default_last,
  last_value(amount) OVER (ORDER BY day,id ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS partition_last,
  sum(amount) OVER () AS whole_sum
FROM sales;
SELECT * FROM framed ORDER BY id;
DO $$ BEGIN
  IF (SELECT array_agg(default_sum ORDER BY id) FROM framed)
     IS DISTINCT FROM ARRAY[30,30,35]::bigint[] THEN RAISE EXCEPTION 'wrong peer frame'; END IF;
  IF (SELECT array_agg(row_sum ORDER BY id) FROM framed)
     IS DISTINCT FROM ARRAY[10,30,35]::bigint[] THEN RAISE EXCEPTION 'wrong row frame'; END IF;
  IF (SELECT array_agg(default_last ORDER BY id) FROM framed)
     IS DISTINCT FROM ARRAY[10,20,5] THEN RAISE EXCEPTION 'wrong current-frame end'; END IF;
  IF EXISTS (SELECT FROM framed WHERE partition_last IS DISTINCT FROM 5 OR whole_sum IS DISTINCT FROM 35) THEN
    RAISE EXCEPTION 'whole partition values wrong';
  END IF;
END $$;
ROLLBACK;

PostgreSQL 18.6 返回的默认累计额依次为三十、三十、三十五:第一天的两行都能看到当天完整并列组。显式 ROWS 版本按 day、id 排序,结果为十、三十、三十五。这里特意用 id 定义独立事件之间的先后,而按日期累计的默认窗口没有加入 id,两个窗口分别回答按天与逐笔的问题。

默认 last_value 列得到十、二十、五,因为它使用 day、id 的唯一顺序,每行都是自己的并列组。完整分区帧在每行返回五,不带排序的总和则在每行返回三十五。断言分别检查这些结果,防止某次修改静默改变帧范围而仍然返回看似合理的数字。

这个小数据集验证的是答案语义,不是资源成本。需要读取后续行的帧以及大型分区,可能需要较多缓冲或排序。性能调优应另用真实分布检查执行计划,同时保留这里验证的并列和边界行为;不能通过改变计算范围,换取一个更快但回答不同问题的查询。

来源