3. SQL窗口函数入门

SQL窗口函数入门 学习卡片

在反洗钱交易监测中,我们经常需要回答这类问题:某账户当前这笔交易,与它过去7天的交易相比是否异常?同一客户在同一天内是否在多个地区发生交易?这些问题的共同点是——既要保留每一行明细,又要在某个“范围”内做聚合比较。传统GROUP BY会把多行压缩成一行,丢失明细;而窗口函数恰好解决这个矛盾:它在不减少行数的前提下,为每一行计算一个基于“窗口”的值。

一、核心原理:窗口函数到底在做什么

窗口函数(Window Function)的本质是:对结果集中的每一行,定义一个与之关联的行集合(称为窗口),然后在这个窗口上执行计算,并把计算结果附加到当前行上。它不会像GROUP BY那样合并行,因此你可以在同一行里同时看到“交易明细”和“该账户近30天交易总额”。

理解窗口函数,关键要抓住三个层次:

  • 分区(PARTITION BY):把数据切成互不重叠的组,窗口计算在每个组内独立进行。例如按账户号分区,每个账户各自计算。
  • 排序(ORDER BY):决定窗口内行的顺序,影响累计、排名、前后行取值等计算。
  • 窗口帧(Frame):进一步限定“当前行”参与计算的具体行范围,例如“从分区第一行到当前行”或“当前行的前3行到后3行”。
一句话记忆:PARTITION BY 决定“分几组”,ORDER BY 决定“怎么排”,窗口帧决定“算哪几行”。

二、语法结构与OVER子句

窗口函数的标准写法是在函数名后加 OVER()。OVER子句内部可以包含PARTITION BY、ORDER BY和窗口帧定义。基本语法如下:

函数名(参数) OVER (
    [PARTITION BY 分区列, ...]
    [ORDER BY 排序列 [ASC|DESC], ...]
    [ROWS|RANGE 窗口帧定义]
)

常见的窗口函数分为几类:聚合类(SUM、AVG、COUNT、MAX、MIN)、排名类(ROW_NUMBER、RANK、DENSE_RANK)、偏移类(LAG、LEAD、FIRST_VALUE、LAST_VALUE)。在反洗钱场景中,聚合类用于计算累计金额、滚动均值;排名类用于找出某账户交易金额的排名;偏移类用于对比上一笔交易的时间间隔或金额变化。

下面是一个最基础的例子:为每笔交易附加该账户的累计交易金额。

SELECT
    txn_id,
    account_id,
    txn_time,
    amount,
    SUM(amount) OVER (
        PARTITION BY account_id
        ORDER BY txn_time
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulative_amount
FROM transactions;

这里 PARTITION BY account_id 让每个账户独立累计,ORDER BY txn_time 保证按时间顺序累加,窗口帧 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 表示从分区第一行累加到当前行。

三、PARTITION BY、ORDER BY与窗口帧的协作

三者不是孤立的,它们的组合决定了计算结果。为了直观理解,下面用一个简化的交易表说明不同窗口帧的差异。假设某账户有5笔按时间排序的交易,金额分别为100、200、300、400、500。

窗口帧写法 含义 第3行(金额300)的计算结果
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 从第一行到当前行 100+200+300 = 600
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW 当前行及前一行 200+300 = 500
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING 前一行到后一行 200+300+400 = 900
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING 当前行到最后一行 300+400+500 = 1200

需要特别注意:当ORDER BY存在但未显式指定窗口帧时,默认帧是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。RANGE与ROWS的区别在于:ROWS按物理行数计算,RANGE按排序值的逻辑范围计算。如果排序值有重复(例如同一天多笔交易),RANGE会把所有相同值的行都纳入当前行范围,导致累计结果与预期不同。在反洗钱监测中,交易时间常有重复,建议明确使用ROWS以避免歧义。

常见误区:认为ORDER BY只影响排名。实际上,在聚合窗口函数中,ORDER BY会改变默认窗口帧,从而改变累计结果。若只想分区内整体聚合而不累计,应省略ORDER BY或显式指定整个分区为帧。

四、反洗钱场景案例:滚动窗口识别异常交易

假设我们需要监测“同一账户在最近3笔交易中,是否出现金额突然放大3倍以上”的可疑行为。可以利用窗口函数计算前3笔交易的平均金额,再与当前交易金额比较。

SELECT
    txn_id,
    account_id,
    txn_time,
    amount,
    AVG(amount) OVER (
        PARTITION BY account_id
        ORDER BY txn_time
        ROWS BETWEEN 3 PRECEDING AND 1 PRECEDING
    ) AS prev3_avg,
    amount / NULLIF(AVG(amount) OVER (
        PARTITION BY account_id
        ORDER BY txn_time
        ROWS BETWEEN 3 PRECEDING AND 1 PRECEDING
    ), 0) AS ratio
FROM transactions;

这里窗口帧 ROWS BETWEEN 3 PRECEDING AND 1 PRECEDING 表示“当前行之前的3行到前1行”,即不含当前行的前3笔交易。这样计算出的 prev3_avg 是历史基准,ratio 大于3即可标记为异常。这个例子体现了窗口帧的精确控制能力:如果写成 3 PRECEDING AND CURRENT ROW,当前笔的大额交易会拉高平均值,反而掩盖异常。

实战建议:在反洗钱规则中,滚动窗口的帧边界要反复验证。建议先用小样本数据手工核对每一行的窗口范围,再上线到全量数据。

五、复盘与常见误区

初学者在使用窗口函数时,容易踩以下几类坑:

  1. 混淆ROWS与RANGE:在排序值有重复时,两者结果不同。反洗钱场景中时间戳重复常见,优先使用ROWS。
  2. 忽略默认窗口帧:写了ORDER BY却没写帧,导致累计而非整体聚合。若需要整体聚合,应写 OVER(PARTITION BY account_id) 不带ORDER BY。
  3. 在WHERE中直接过滤窗口函数结果:窗口函数在WHERE之后执行,不能直接用于WHERE。需要先用子查询或CTE计算,再在外层过滤。
  4. PARTITION BY与GROUP BY混用:两者语义不同,GROUP BY改变行数,PARTITION BY不改变行数。混用时需明确最终输出粒度。
  5. 窗口帧边界写反:例如 BETWEEN CURRENT ROW AND 3 PRECEDING 是非法的,起始边界必须在结束边界之前。