同样使用 SUM(amount) OVER,查询可能按交易逐笔累计,也可能把同一天的多笔金额一次计入。二者都能执行,结果却回答不同问题。本文以 PostgreSQL 18 文档为语义依据,结合 DuckDB 的窗口范围示例说明机制;示例用于推导,不代表生产性能实测。
先把业务问题写成窗口契约
可靠的企业 Text-to-SQL 要求查询回答正确的业务问题。窗口函数尤其需要先明确计算粒度:一行是一笔交易、一天汇总还是一次快照;分区按客户、仓库还是公司;排序表示业务发生顺序还是展示顺序;窗口表示若干行、若干日还是整个分区。没有这些定义,增加一个 ORDER BY 只能让结果看起来有顺序。
本文使用三行构造交易,全部属于部门 A:id=1,business_date=2026-10-01,amount=100;id=2,同为2026-10-01,amount=50;id=3,business_date=2026-10-03,amount=20。id 唯一,金额为非空整数,日期已按同一业务时区确定。三行总额是170,10月2日没有记录,但仅凭缺行不能判定该日业务量为零。
窗口计算通常保留输入行,而分组聚合会改变粒度。[2] 如果业务要的是每日结算累计,应先按部门和日期聚合成每日记录;如果要逐笔流水余额,则保留交易行并明确先后规则。把两种粒度混在一个结果中,容易出现同日重复展示相同累计值,却被误认为金额重复入账。
默认窗口为什么一次加进同日两笔
考虑表达式 SUM(amount) OVER (PARTITION BY dept ORDER BY business_date)。在这里讨论的 PostgreSQL 默认规则下,带排序而未显式指定 frame 的窗口会延伸到当前行的最后一个排序同伴,也就是排序键相同的行。[1] 因而按 id 展示时,三行累计分别为150、150、170。第一行的150不意味着引擎错加,而是同日两笔都属于当前范围。
如果目标是按给定顺序逐笔累计,可明确写为 SUM(amount) OVER (PARTITION BY dept ORDER BY business_date, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)。三行分别为100、150、170。此处 id 只负责定义本例的稳定顺序;真实业务必须确认它是否适合作为同日交易的先后规则,不能把随机标识符排序当成真实发生时间。
ROWS 以排序后的行位置确定范围,RANGE 以排序值及其范围解释边界。显式写 ROWS 却只按日期排序,仍没有解决同日并列行谁先谁后的问题。输出末尾的 ORDER BY business_date, id 只能控制展示顺序,不能补写窗口内部缺少的顺序。窗口排序与最终输出排序应分别声明。
若业务关心日末累计,150、150、170可能恰好符合含义,但报表应避免让读者把150再按交易行求和。更清晰的办法是先得到10月1日150、10月3日20的日粒度表,再累计为150、170。正确性取决于契约,而不是选择看起来更平滑的曲线。
最近两行不等于最近两个自然日
在同样的数据上,ROWS BETWEEN 1 PRECEDING AND CURRENT ROW 表示当前行及前一行。按日期和 id 排序,到第三行时求和为50+20=70,前一行来自10月1日。这并不是10月2日至3日的合计。若把窗口扩大到六行,同样不能自动得到最近七天。
对于 DATE 类型的 business_date,按日期排序并使用 RANGE BETWEEN INTERVAL '1 day' PRECEDING AND CURRENT ROW,可表达当前日期及前一日期范围内已有记录的求和。在本例10月3日结果为20;10月1日两行均为150。带偏移量的 RANGE 在 PostgreSQL 中对排序表达式有约束,此例只使用一个日期排序键,不应直接把逐笔窗口的日期加 id 排序照搬过来。 [4]
DuckDB 的官方窗口文章用日期范围解释移动聚合如何跨越缺失日期。[3] 该文章发表于2021年,本文只借用机制说明,不引用其中历史性能数据作为当前速度承诺。范围窗口会选择范围内存在的记录,不会自行生成缺失日期,也不会替你决定缺失是零业务还是漏采。
若要求每日一行且平均值分母固定为日历天数,需要日历表和完整性规则:只有确认无活动的日期才补零,来源未到齐的日期保持未知或标为暂定。对 TIMESTAMP 使用滚动时间范围又是另一种定义;自然日、过去48小时和两个营业日不可混称。应先定义时区、营业日历与边界,再选择日期或时间戳字段。
LAST_VALUE 的最后可能只是当前窗口末尾
LAST_VALUE(amount) 容易被读成整个部门的最终金额,但函数读取的是当前 frame 的最后一行。[1] 在本例按 business_date, id 唯一排序、采用默认 frame 时,结果为100、50、20。若要每行都显示部门按该顺序最后一笔金额,需要完整范围,例如 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING,三行才均为20。
这也不意味着最后一笔交易金额就是期末余额。余额是状态,交易金额是流量,必须使用正确业务字段及计算方法。遇到空值、撤销记录或补录交易时,还要定义最后指最后发生、最后生效还是最后入库,不能由函数名字替代口径。
LAG(amount) 返回排序中的前一行值,也不自动等于昨天的值。第三行的前一行是10月1日的50。若要比较昨日汇总,应先建立合适的日粒度数据,再按业务日期关联前一天,或在完整日历序列中使用 LAG。不同窗口函数对 frame 的使用并不相同,不要以为修改 SUM 的范围会同步改变所有函数含义。
展示筛选放错位置,累计就会重新起算
在同一层 SELECT 中先用 WHERE business_date >= DATE '2026-10-03' 排除前两行,再执行窗口累计,第三行得到20。先在内层对三行累计、外层再只展示10月3日,则第三行保留170。PostgreSQL 教程说明窗口处理的是经过同层筛选和分组等步骤形成的输入,因此外层查询是区分计算范围与展示范围的一种方法。[2]
可采用这样的结构:WITH w AS (SELECT id, dept, business_date, SUM(amount) OVER (PARTITION BY dept ORDER BY business_date, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total FROM transactions) SELECT * FROM w WHERE business_date >= DATE '2026-10-03' ORDER BY business_date, id; 这里的完整表名与字段是假设,部署时需替换为经过授权和口径确认的数据对象。
外层筛选只适用于本来就应纳入计算、但不展示的历史记录。租户、行级权限及业务排除条件必须在允许的输入范围内执行,不能为了保留累计而先让窗口计算无权访问的数据。结果只有一行,也可能通过累计值泄露其他范围的信息。
大表不一定适合每次从最早记录重算。可使用经过核验的期初余额加本期增量,但必须记录期初截止边界、数据版本及补录修订方式。不能为了优化扫描成本随意截断历史,再把本期累计标成全历史累计;迟到交易可能使以后所有累计值变化,需要明确重算范围。
用小数据和独立对照验收,而非只比 SQL 文本
先为本文三行数据建立手工期望:默认日期窗口150、150、170;唯一排序的逐行累计100、150、170;第三行的两行移动和70;两个自然日范围和20;内层累计后展示170,同层先筛选20。它们是构造输入下的语义推导,尚不是目标数据库上的运行结果。上线团队应在实际引擎、版本和字段类型上运行并保存回执。
再加入反例:交换插入顺序但不改变业务排序键;增加另一部门,确认分区不串数;增加同日交易,确认逐笔与日末口径各自保持;插入跨日边界记录,检查时区转换;删除中间一天,检查行窗口没有被误称为日窗口;加入空值和负数,验证指标允许的语义。金额可以下降时,累计曲线不应被要求永远单调。
对小样本可用独立的日期条件聚合或逐项求和作为对照,不要只复制原窗口表达式到测试中。比较每个业务键的结果和行数,确认重复排序键处理规则及最终展示顺序。对于大数据量,再检查执行计划、排序开销和数据倾斜;查询耗时合格与业务含义正确需要分别通过。
最后把粒度、分区键、稳定排序键、frame、计算时间范围、展示范围、空值与缺日政策记录为查询契约,随报表或智能问数结果版本管理。数据变更、引擎迁移和模型生成 SQL 更新时重跑边界用例。显式窗口不能消除所有错误,但能让原本隐含的业务选择进入可审查、可复现的交付过程。
