窗口函数让报表保留每条读数,同时附加相关行的信息。一条传感器读数可以展示前值、变化量和在该传感器总量中的占比,而不必把整个传感器压缩成一行结果。能否正确解释这些附加列,取决于计算前是否明确了输入总体、分区和顺序,不能只看函数名称就认定答案符合业务。

给相邻关系明确的含义

PARTITION BY 用来划分相互独立的序列。按 sensor 分区的 lag,只比较同一传感器的值;漏掉分区条件,可能把某个传感器的第一条读数与另一个传感器的最后一条相比较。OVER 内部的 ORDER BY 决定谁是前一行。可以使用稳定的序列键;时间戳可能重复时,则应使用时间戳加唯一标识,明确同一时刻的先后关系。

lag 找的是这个有序总体中的前一行,不一定是前一分钟、前一天或上一个计划采集时刻。采集有缺口时,数据库不会自动补出缺失行。如果计算要求与前一个自然日比较,应明确建立日历关系,或先构造所需的时间序列,再做窗口计算。表里相邻的两行,并不能证明采样间隔固定。

在分区开头,lag 返回默认值,通常是 SQL NULL,用来表示没有前驱行。把它改成零,会改变首条变化量的含义,应有业务规则支持。PostgreSQL 的 lag、lead 也会保留空值语义:如果前一条读数本身为空,它不会自动越过这条记录,继续向前寻找更早的非空值。不存在前驱与前驱值为空,需要在业务解释中保持区别。

在明细旁保留总额与占比

聚合函数配合 OVER 使用时,会保留输入行,并为每行增加计算结果。sum(value) OVER (PARTITION BY sensor) 没有窗口排序,因此重复显示整个传感器分区的总额。如果未考虑默认帧就加上排序,总额可能变成累计额,随后每一行的占比分母都会改变,这不是无关紧要的展示调整。

需要小数占比时,应在除法之前转换为 numeric。整数相除可能截断小数,让较小贡献看起来全部为零。示例使用 NULLIF 把零分母转换成 SQL NULL,使无法定义的占比保持未定义状态。这个处理是一条明确规则;改为零、拒绝数据集或展示另一个状态,都是不同的业务选择,不能让算式偶然决定。

并非每个窗口函数都使用移动帧。聚合和 last_value 等函数依赖帧,而 lag、lead 在分区行之间移动。仅仅缩小 ROWS 范围,并不会重新定义 lag 所指向的前一条分区记录。应分别为每个问题选择函数和窗口,不能把一条帧公式机械地套在所有计算上。

坏掉的样子

WHERE 会先过滤窗口输入,再计算窗口值。如果在同层查询中只保留最新读数,lag 就没有旧行可看。如果要展示一条读数并保留它已有的历史上下文,应先在子查询或视图中计算窗口,再从外层过滤结果。反过来,如果排除的读数本来就不应参与比较,先过滤才正确;过滤位置要由总体定义决定。

关联也可能在窗口执行前扩张行数。一条读数关联到多个标签后,会出现多次,导致总额膨胀、相邻位置改变。添加窗口前,应确认输入仍然是一条事件对应一行的预期粒度。窗口语法不能修复重复事件,也不保证只进行一次廉价扫描;不同排序、大型分区和排序操作,都可能成为主要执行成本。

快速验证

把下面内容保存为 row-context.sql,在可丢弃的 PostgreSQL 18 数据库中执行 psql -X -v ON_ERROR_STOP=1 -f row-context.sql。脚本在事务中创建临时读数和视图,对比两种过滤位置,最后回滚整个实验。

\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE readings (id integer, sensor text, value integer);
INSERT INTO readings VALUES (1,'A',10),(2,'A',15),(3,'A',12),(4,'B',0);
CREATE TEMP VIEW contextual AS
SELECT *, lag(value) OVER (PARTITION BY sensor ORDER BY id) AS previous_value,
       sum(value) OVER (PARTITION BY sensor) AS total,
       value::numeric / NULLIF(sum(value) OVER (PARTITION BY sensor),0) AS share
FROM readings;
SELECT id,sensor,value,previous_value,value-previous_value AS delta,total,round(share,3) AS share
FROM contextual ORDER BY id;
SELECT id,previous_value FROM contextual WHERE id=3;
SELECT id,lag(value) OVER (PARTITION BY sensor ORDER BY id) AS previous_value
FROM readings WHERE id=3;
DO $$ BEGIN
  IF (SELECT previous_value FROM contextual WHERE id=3) IS DISTINCT FROM 15 THEN
    RAISE EXCEPTION 'previous row was lost';
  END IF;
  IF (SELECT share FROM contextual WHERE sensor='B') IS NOT NULL THEN
    RAISE EXCEPTION 'zero denominator policy not preserved';
  END IF;
  IF (SELECT count(*) FROM contextual) <> 4 THEN RAISE EXCEPTION 'rows collapsed'; END IF;
  IF (SELECT array_agg(previous_value ORDER BY id) FROM contextual WHERE sensor='A')
     IS DISTINCT FROM ARRAY[NULL,10,15]::integer[] THEN RAISE EXCEPTION 'wrong lag sequence'; END IF;
  IF EXISTS (SELECT FROM contextual WHERE sensor='A' AND total IS DISTINCT FROM 37) THEN
    RAISE EXCEPTION 'wrong partition total';
  END IF;
  IF (SELECT previous_value FROM (
        SELECT lag(value) OVER (PARTITION BY sensor ORDER BY id) AS previous_value
        FROM readings WHERE id=3
      ) AS filtered) IS NOT NULL THEN RAISE EXCEPTION 'WHERE did not narrow the window input'; END IF;
END $$;
ROLLBACK;

PostgreSQL 18.6 保留了全部四条读数。传感器 A 的前值依次为 NULL、十、十五,变化量为 NULL、五、负三,每行总额都是三十七。numeric 占比保留三位小数后为零点二七零、零点四零五、零点三二四。逐行四舍五入后的展示值相加,不一定恰好等于一,不能据此认定原始分母错误。

从已计算的视图筛选 id 为三的行,仍得到前值十五;在源表先筛到这一行再计算 lag,则得到 NULL。传感器 B 的首行前值和零总额占比都是 NULL,但原因不同:前者没有前驱,后者没有可定义的分母。断言检查行数保留、前值序列、分区总额和过滤语义,避免重构后虽然仍返回一行,却已经丢掉原本需要的上下文。

来源