如何用SQL高效计算滑动平均值:避开那些“看起来对”的坑
说到用SQL计算滑动平均值,很多人的第一反应是:这不就是窗口函数加个ORDER BY吗?但实际操作过的人都知道,这里面的水,可比想象的要深。一个语法细节没抠对,出来的结果可能就南辕北辙了。
滑动平均值必须用 ROWS BETWEEN,仅 ORDER BY 默认按值分组(RANGE),导致同时间戳数据被错误聚合;需显式指定 ROWS BETWEEN n PRECEDING AND CURRENT ROW 并确保 ORDER BY 列具有确定性排序,否则结果不可预测。

滑动平均值必须用 ROWS BETWEEN,不能只靠 ORDER BY
这可能是最普遍、也最隐蔽的误区。你以为写了A VG(col) OVER (ORDER BY ts),数据库就会乖乖地按行顺序滚动计算?其实不然。默认情况下,窗口函数会使用RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。关键就在这个RANGE上——它是按ORDER BY列的值进行分组的。
这意味着什么?如果你的时间戳ts精度只到秒,而同一秒内有多条记录,那么所有这些“同秒”的记录都会被视作一个“组”,一起参与当前行的平均值计算。结果就是,你得到的不是“最近N行”的滑动平均,而是“截止到当前时间点所有值”的平均,数据会出现阶梯状的突变,完全失去了滑动的意义。
所以,要严格按物理行序滚动,ROWS BETWEEN是唯一可靠的选择。
- 正确写法:必须显式声明窗口框架。例如,计算最近5行的滑动平均,应该写成:
A VG(val) OVER (ORDER BY ts ROWS BETWEEN 4 PRECEDING AND CURRENT ROW)。注意,这里的4 PRECEDING指的是“往前数4行”,加上当前行自己,正好是5行。 - 对称窗口:如果想计算当前行前后各2行的平均值,框架应定义为:
ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING。
ORDER BY 列必须有确定性排序,否则 ROWS BETWEEN 行为不可预测
好了,现在你加上了ROWS BETWEEN,是不是就高枕无忧了?别急,还有一关:排序的确定性。
数据库怎么决定“前一行”是谁?它依赖于ORDER BY子句给出的顺序。如果ORDER BY的列存在大量重复值(比如同样是毫秒级的时间戳,也可能有并发写入导致重复),并且你没有提供第二排序键,那么数据库在多次执行中,可能会对相同值的行给出不同的排序。这可不是bug,而是SQL标准允许的未定义行为。结果就是,你的滑动平均值今天算出来是这样,明天可能就变了。
这在处理金融tick数据、IoT传感器高频采样或用户点击流日志时尤为常见。
- 黄金法则:永远为
ORDER BY提供一个能确保唯一性的兜底列。最常用的组合是:ORDER BY event_time, id(假设id是主键或唯一键)。 - 数据库特定方案:在PostgreSQL或SQL Server中,可以使用物理行标识符,如
ORDER BY ts, ctid。MySQL 8.0+在某些条件下可以使用隐藏的_rowid。 - 性能警告:务必避免使用
ORDER BY RAND()或无索引的列进行排序。否则,面对海量数据,一个临时排序操作就足以让查询性能崩溃。
空值和边界行处理:默认跳过 NULL,首几行结果行数不足
理解了框架和排序,接下来要面对的是数据的“不完美”。窗口函数在处理NULL和窗口边界时,有一套默认逻辑,不了解就容易踩坑。
首先,A VG()函数会忽略NULL值,但窗口框架ROWS BETWEEN划定的行范围是物理的。这就产生了一个现象:当你计算一个5行窗口的平均值时,第1行只有它自己(如果值非空)参与计算;第2行只有前2行参与……直到第5行,窗口才被“填满”。很多人误以为前4行会返回NULL,其实不然,它们返回的是“当前有限窗口内”的平均值。
- 如何让前n-1行返回NULL?这需要额外的条件判断,通常结合
ROW_NUMBER()窗口函数来实现。 - 想彻底排除NULL行:正确的做法是在外层查询的
WHERE子句中提前过滤掉NULL值,而不是指望窗口函数。 - 把NULL当0算:可以用
COALESCE(val, 0),但务必清醒——这已经改变了统计含义,计算出的均值不能真实反映非空样本的情况。
性能关键:ORDER BY 列必须有索引,且避免在大偏移窗口中用 FOLLOWING
最后,我们来谈谈性能。语法正确不代表反赌。滑动平均计算的性能瓶颈,往往不在求平均本身,而在排序和窗口定位。
一个常见的性能杀手是使用FOLLOWING。像ROWS BETWEEN ... AND ... FOLLOWING这样的框架,要求数据库必须能够“预读”后续的行。在流式处理或超大数据集上,这会急剧增加内存开销。事实上,像Spark SQL和一些MySQL版本,会直接拒绝执行这类窗口。
更大的坑在于排序。如果ORDER BY的列没有索引,数据库就需要对全表进行临时排序。想象一下,一张千万行的日志表,这个操作足以引发严重的I/O和内存问题。
- 索引是生命线:务必为
ORDER BY的列建立索引。如果是按设备分组再按时间滑动(PARTITION BY device_id ORDER BY event_time),那么一个(device_id, event_time)的复合索引将是性能利器。 - 优先使用单向窗口:尽量采用
PRECEDING AND CURRENT ROW这种只回顾过去的窗口。它更容易被优化,支持流式计算,中间结果也常可被复用。 - 对ClickHouse用户的特别提醒:
ROWS BETWEEN在MergeTree表上效率极高,但这高度依赖于建表时的ORDER BY键。如果窗口排序键与表排序键不匹配,性能优势将荡然无存。
说到底,写出正确的ROWS BETWEEN语法只是第一步。真正的挑战在于确认:你选择的ORDER BY列,在业务逻辑上是否严格代表了时间的先后顺序?数据库底层是否真的按照这个顺序来组织和检索数据?这两点如果出了问题,再标准的语法也保证不了结果的正确性。这,才是计算滑动平均值时最需要想清楚的事。
