处理传感器采集、用户行为、财务流水等连续数据时,NULL值几乎无法避免。设备掉线、日志丢失、字段未记录都会造成缺口。为了让报表曲线连续、计算移动平均时不被NULL打断,常见思路是把空值替换为它之前最近的一个非空值,也就是向前填充。SQL标准窗口函数中的LAST_VALUE再加上IGNORE NULLS可以比较直接地实现这个需求,但前提是必须理解窗口帧的默认行为。否则会发现函数返回的结果总是当前行的值,或者一旦遇到NULL就失效。

接下来从默认窗口帧、IGNORE NULLS语法、替代实现和跨数据库兼容性几个角度展开。
一、默认窗口帧下LAST_VALUE为什么不像预期
在窗口函数中,LAST_VALUE(expr) OVER (...) 的作用是取出窗口帧内最后一行中expr的值。这里的窗口帧不是肉眼看到的分区全部行,而是根据ORDER BY和frame clause动态确定的一段范围。SQL标准规定:当OVER子句中包含ORDER BY但没有显式写ROWS或RANGE时,默认帧为RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。这个范围从分区起点一直延伸到当前行,并且包含所有与当前行排序键相等的行。
例如按时间序列排序时,如果每一行的排序键都不同,那么到当前行时,窗口帧恰好只包含当前行自己。结果就是LAST_VALUE(reading)返回当前行的reading,而不是整个分区最后一个值。如果当前行reading为NULL,函数就返回NULL。很多开发者误以为LAST_VALUE自然会返回分区最后一行,于是发现结果等于原字段值,感觉窗口函数没有生效。
SELECT
sensor_id,
ts,
reading,
LAST_VALUE(reading) OVER (
PARTITION BY sensor_id
ORDER BY ts
) AS last_value_default
FROM sensor_data
ORDER BY sensor_id, ts;
上面的查询没有给出frame clause,因此每个分区内到当前行为止的最后一行就是当前行自身。即使读取到历史数据,当前行后面还有数据,也不会被纳入计算。这个机制与SUM、AVG等累计函数一致,但对于LAST_VALUE来说尤其容易造成理解偏差。
如果希望取出整个分区的最后一个非空值,需要把窗口帧扩大为ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING;如果希望只使用当前行之前的数据做向前填充,则要指定ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。两者语义不同,前者会使用未来数据,后者不会。
二、IGNORE NULLS如何让LAST_VALUE跳过空值
即使显式设置了正确的窗口帧,LAST_VALUE(reading)在遇到NULL时仍会返回NULL,因为它不会自动忽略空值。SQL标准为FIRST_VALUE、LAST_VALUE、LEAD、LAG等窗口函数提供了IGNORE NULLS和RESPECT NULLS两个修饰词。默认是RESPECT NULLS,即NULL也是有效输入,会被函数正常返回。使用IGNORE NULLS后,函数会在窗口帧内排除值为NULL的行,只从非空行中取最后一行的值。
向前填充空值的典型写法如下:
SELECT
sensor_id,
ts,
reading,
LAST_VALUE(reading IGNORE NULLS) OVER (
PARTITION BY sensor_id
ORDER BY ts
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS filled_reading
FROM sensor_data
ORDER BY sensor_id, ts;
这里的窗口帧始终从分区起点到当前行。对于任意第N行,函数只观察前N行。IGNORE NULLS会过滤掉中间的空值,因此返回的是当前行之前最近的一个非空读数。如果当前行本身非空,就返回当前行;如果当前行为空,就返回再往前最后一个非空值。这就是常见的前向填充逻辑。
需要特别注意的是,不要顺手把窗口帧写成ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING。那样虽然也会跳过NULL,但LAST_VALUE返回的是整个分区最后一个非空值,相当于把未来数据填入历史空值。例如月初缺数时,如果用整月最后一个非空值填充月初,就会造成数据污染。因此向前填充必须把窗口帧限制到当前行。
不同数据库对IGNORE NULLS的支持并不完全一致。Oracle较早支持,PostgreSQL从11版本开始对FIRST_VALUE、LAST_VALUE、NTH_VALUE等支持IGNORE NULLS,SQL Server 2022在LAG、LEAD、FIRST_VALUE、LAST_VALUE中也增加了该能力。MySQL 8的窗口函数目前默认遵循RESPECT NULLS,不支持显式IGNORE NULLS。因此使用前需要确认目标数据库版本。
三、不支持IGNORE NULLS时的替代实现方案
如果数据库暂时不支持IGNORE NULLS,或者项目要求兼容多种数据库,可以使用分组技巧等方案模拟前向填充。思路是给每个从非空值开始、包含后续NULL的连续区间打上相同分组号,然后在组内取唯一的非空值。
构建分组号的方式如下:对按时间排序的每一行,计算从分区起点到当前行为止非空读数的数量。每当遇到一个新的非空值,计数就加1;后续的NULL不会改变计数。这样每条NULL会继承最近一次非空值所在的分组号。
SELECT
ts,
reading,
SUM(CASE WHEN reading IS NOT NULL THEN 1 ELSE 0 END)
OVER (
ORDER BY ts
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS grp
FROM sensor_data
ORDER BY ts;
得到grp后,再使用MAX或FIRST_VALUE在每组内填充。因为每个grp中只有一个非空值,其他都是NULL,MAX会忽略NULL,结果自然就是该组唯一的非空值。外层查询如下:
SELECT
ts,
reading,
MAX(reading) OVER (PARTITION BY grp) AS filled_reading
FROM (
SELECT
ts,
reading,
SUM(CASE WHEN reading IS NOT NULL THEN 1 ELSE 0 END)
OVER (
ORDER BY ts
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS grp
FROM sensor_data
) t
ORDER BY ts;
这种写法不依赖IGNORE NULLS,通用性更强。它同样只向前看,不会引入未来数据。缺点是代码层级多一层,数据量大时窗口排序和分组需要额外计算。如果只需要在展示层处理少量数据,也可以在应用代码中迭代填充,但对于数据库内直接计算来说,上述SQL方案更利于保持查询语义一致。
另一个常见直觉是使用COALESCE配合子查询,但需要小心性能。例如每一行都执行一个相关子查询查找最近的过去非空值,通常会退化为O(n²)扫描,在百万行数据上几乎不可用。窗口函数方案能够在一次排序和扫描内完成计算,实际性能通常远好于逐行子查询。
四、排序键、分区键与NULL语义的工程细节
在实际生产环境中,孤立地写好函数语法还不够,排序键和分区键的设计直接影响填充结果。时间戳是最常见的排序键,但如果同一秒出现多条记录,RANGE和ROWS会有差异。RANGE按排序键值分组,相同排序值的行彼此可见;ROWS按物理行顺序推进。若时间戳重复且希望严格按插入顺序向前填充,使用ROWS更合适;若相同时间戳的记录在业务上不应互相影响,则可以用RANGE并增加其他排序键消除二义性。
分区键的选择同样重要。前向填充通常应该按传感器编号、用户ID或商品SKU等业务实体进行。如果忘记写PARTITION BY,窗口函数会把整张表按时间排序,可能出现设备A的空值被设备B的非空值填充。尤其是多主体数据混在一起时,这种错误不容易从结果表面发现。因此写查询前要明确填充的上下文边界。
NULL语义方面,IGNORE NULLS只作用于窗口帧内的NULL行,它不会把空字符串、零值或默认值一并处理。比如有些表用-999表示无效读数,这类值不会因为IGNORE NULLS而被跳过。此时可以在CASE表达式中先把哨兵值转换为NULL,再交给LAST_VALUE处理。类似地,若业务上需要同时填充多个字段,可以为每个字段分别写窗口函数,但要注意所有窗口函数使用相同的排序键和帧,避免出现填充结果错位。
最后,如果表中存在连续大量NULL,向前填充会得到一个很久之前的旧值。对于监控告警、库存预测等场景,旧值可能已经失去参考意义。可以在外查询中设置最大填充距离,例如只填充最近3个周期内的缺失值。这可以通过比较当前时间与最近非空时间的差值实现,也可以先生成带最近非空时间戳的中间结果,再用条件判断过滤。
SQL窗口函数LAST_VALUEIGNORE NULLS修改时间:2026-08-27 18:00:02