窗口函数让很多复杂分析查询变得简洁,但数据量一旦上升到千万甚至亿级,原本毫秒级返回的SQL可能直接拖垮数据库。以一个常见的排名场景为例,假设需要对某电商平台近一年的订单明细按用户分组、按下单时间倒序取每个用户最近三笔订单,使用ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY order_time DESC)时,数据库往往需要为每个分组分配排序缓冲区,当分组数量极大或单组数据分布不均时,内存很快耗尽并溢出到磁盘,查询时间从秒级恶化到分钟级。

解决问题的思路并不复杂:让窗口函数处理更小的数据集。临时表正是实现这一目标最直接的载体。下文会从执行计划的角度拆解窗口聚合的瓶颈,再用临时表把一条重型SQL拆成多个轻量步骤,最后讨论索引、分区和物化视图等进一步优化手段。
窗口聚合为什么会慢
窗口函数和普通聚合函数最大的区别在于它不折叠行,每一行都要输出结果。为了实现OVER子句中的PARTITION BY和ORDER BY,数据库内部通常会先对数据按照分区键和排序键进行排序,或者建立哈希表来维护分区状态。对于大规模数据,排序操作本身就是O(n log n)级别的代价,且需要额外磁盘临时空间;如果分区键的基数很高,每个分区只包含很少的行,排序开销相对可控,但如果存在超级用户(单用户几百万行),排序缓冲区需要容纳整个分区,内存压力巨大。
以PostgreSQL为例,窗口函数执行时会使用work_mem参数指定的内存做排序,超过该容量就写入临时文件。全局来看,一条带有多个窗口函数或复杂分区条件的SQL可能触发多次排序和多次临时文件读写,这些I/O完全抵消了索引带来的优势。更麻烦的是,优化器有时无法准确估算窗口函数的中间结果规模,导致选择了错误的连接顺序或排序策略,进一步放大性能问题。
另一个容易被忽视的瓶颈是重复计算。很多业务SQL会把窗口函数的结果再用于过滤或与其他表连接。例如先选出每个分类下销量前10的商品,再关联商品详情。如果直接写成子查询,子查询每次执行时都要重新计算窗口结果,即使外层只需要其中一小部分行。临时表可以把窗口计算的结果物化下来,后续操作直接扫描这张小表,避免重复劳动。
用临时表拆分窗口计算
临时表优化方案遵循一个简单原则:把需要反复扫描或需要参与排序的大数据集,先通过GROUP BY或筛选缩小规模,再在这个较小的临时表上执行窗口函数。具体可以分为两种模式:预聚合模式和预过滤模式。
预聚合模式适用于窗口函数的排序字段或分区字段与分组字段不完全一致的情况。比如要计算每个部门员工工资占部门总工资的比例,同时还要按员工入职时间排序。可以先按部门做一次SUM(salary)总工资的聚合,将部门工资存为临时表,再和员工明细表连接后计算比例。这样窗口函数只需要在连接后的结果上按部门分区做一次排序,而无需对整个员工表排序。
预过滤模式则针对那些窗口计算结果还需要被后续条件筛选的场景。例如找出每个用户最近一次登录记录,但只需要登录时间在最近30天内的用户。如果直接对整个登录表做ROW_NUMBER然后过滤登录时间,会浪费大量计算;可以先筛选出最近30天的登录记录存入临时表,再在这个临时表上做窗口排名,数据量可能缩小几十倍。
下面用MySQL 8.0演示一个典型用例:统计每个城市销售额前三的店铺,并给出店铺名称和销售额。原始SQL可能写成这样:
SELECT city, shop_name, sales_amount
FROM (
SELECT city, shop_name, sales_amount,
ROW_NUMBER() OVER (PARTITION BY city ORDER BY sales_amount DESC) AS rn
FROM shop_sales
WHERE stat_date = '2024-01-01'
) t
WHERE rn <= 3;
如果shop_sales表有几千万行,即使有日期索引,窗口排序依然昂贵。改用临时表后,可以先按城市和店铺聚合出当天的销售额,并把结果存进临时表:
CREATE TEMPORARY TABLE tmp_city_shop_sales AS
SELECT city, shop_name, SUM(sales_amount) AS total_sales
FROM shop_sales
WHERE stat_date = '2024-01-01'
GROUP BY city, shop_name;
CREATE INDEX idx_tmp_city ON tmp_city_shop_sales (city, total_sales DESC);
SELECT city, shop_name, total_sales
FROM (
SELECT city, shop_name, total_sales,
ROW_NUMBER() OVER (PARTITION BY city ORDER BY total_sales DESC) AS rn
FROM tmp_city_shop_sales
) t
WHERE rn <= 3;
第一个步骤把明细数据聚合到店铺级别,数据量从几千万降到几十万甚至更少。第二个步骤在临时表上创建复合索引,帮助窗口函数按城市分区、按销售额排序时使用索引扫描代替文件排序。最后一步窗口函数只在这张几十万行的小表上执行,性能提升非常明显。
使用临时表时需要注意索引设计的顺序。对于常见的PARTITION BY A ORDER BY B DESC模式,最好在临时表上建立(A, B DESC)这样的复合索引,让数据库能够按分区顺序读取数据,并在分区内直接获得排序结果,避免额外的排序操作。如果还有其他过滤条件,也可以考虑将过滤列加入索引前缀。
索引、分区与物化视图的配合
临时表本身如果没有合适的索引,优化效果会大打折扣。创建临时表后,要分析后续窗口函数和关联查询的访问路径,针对性地添加索引。例如上面的例子中,idx_tmp_city索引让每个城市的数据在物理上相邻,排序键total_sales又保证了每个分区内直接有序,窗口函数可以流式处理,不需要一次性把整个分区加载到内存。
对于特别大的临时表,还可以考虑对临时表进行分区。虽然标准SQL的临时表分区支持有限,但PostgreSQL、Oracle等数据库允许在临时表上创建分区。按分区键对齐窗口函数的分区键,可以让每个分区独立处理,并行度更高。不过要权衡临时表创建时间和查询时间,频繁创建分区临时表带来的DDL开销可能得不偿失。
当临时表方案已经无法满足实时性要求,或者业务逻辑固定且数据更新频率较低时,物化视图是更好的选择。物化视图可以提前把窗口函数的计算结果持久化,并支持增量刷新。例如每天凌晨把前一天每个城市前三的店铺排名计算出来存成物化视图,白天查询直接读取结果,几乎没有额外计算。不过物化视图的维护成本和存储开销需要纳入评估。
另一个容易被忽略的细节是临时表的类型选择。MySQL中内存临时表(MEMORY引擎)虽然访问快但受限于max_heap_table_size,一旦超过大小就自动转为磁盘临时表,性能骤降。对于大规模数据,显式创建InnoDB磁盘临时表并配合索引往往比依赖数据库自动管理更可控。PostgreSQL的临时表默认就是磁盘表,但可以调整temp_buffers来提高缓存命中率。
实际项目中的落地建议
在决定是否使用临时表优化之前,建议先通过EXPLAIN ANALYZE查看原始SQL的执行计划,确认瓶颈确实来自窗口函数的排序或临时文件读写。如果瓶颈在JOIN或全表扫描,临时表方案未必能解决问题,需要先从索引或SQL重写入手。执行计划中如果出现Sort Method: external merge,说明排序溢出到了磁盘,这时用临时表预先缩减数据量效果最显著。
拆分后的多步骤SQL要保证事务一致性。如果原始查询要求强一致,临时表构建和最终查询需要在同一个事务内完成,并注意隔离级别。对于报表类场景,可以在低峰期先刷新临时表,业务查询直接读取,避免在线事务中实时构建临时表带来的锁竞争。
最后,临时表不是万能的。它增加了SQL复杂度,也消耗额外的存储空间和写入时间。但对于典型的窗口聚合性能问题,尤其是数据量在千万到亿级、分区基数较大、排序字段无明显索引可用的场景,临时表拆分是一种成本低、见效快、易于维护的优化手段。理解优化器行为与数据分布特征,才能做出最合适的性能决策。