报表里的排名统计经常出现这种情况:几十万数据时RANK查询能在几百毫秒内返回,数据涨到上千万后,同样的SQL可能超过十秒甚至更久。核心原因通常不是RANK函数本身有多慢,而是窗口函数为了计算排名必须对指定排序键进行全量排序,排序过程又涉及大量回表与内存占用。如果排序键没有匹配索引,或结果集没有在窗口计算前被有效裁剪,性能就会急剧下降。下面从执行机制、索引设计、SQL改写和预聚合四个方向展开。

另一个容易被忽略的问题是磁盘排序。当排序缓冲区不足以容纳待排序行时,数据库会把中间结果写入临时文件,多次归并排序会显著拉长响应时间。因此排查RANK慢的第一步是确认执行计划中是否出现SORT、External Sort或类似标记,再决定优化策略。
RANK慢的根因:排序和回表被低估
RANK是一个典型的窗口函数。执行时,数据库先根据PARTITION BY子句把数据分成若干窗口,再在每个窗口内按ORDER BY指定的排序键进行排序,最后为每一行计算排名。如果不用ROW_NUMBER而是用RANK,出现并列值时需要比较前后行,排名会跳号,这就要求排序结果必须完整生成,不能提前截断。因此,排序是整个查询中最重的一步。
下面这段SQL在很多报表场景中都会出现:
SELECT
emp_name,
department,
salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rank_no
FROM employee_salary
WHERE stat_date = '2025-01-31';
执行计划里通常会先对employee_salary表做过滤,然后按department分组,再在salary上做降序排序。如果stat_date列有索引,但department和salary没有联合索引,数据库只能把符合条件的行全部取出来排序。当符合条件的行达到百万级时,排序要么挤爆内存,要么被迫写临时文件,响应时间自然就上去了。
除了排序本身,回表也是隐藏开销。排序完成后,数据库还需要回到原表取出emp_name等非索引列。如果排序只能基于salary单列索引,每条记录都要回表一次,这种随机读取会拖慢整体速度。即使排序很快,回表也可能让查询时间翻倍。所以优化RANK不能只盯着排序,还要考虑索引能否覆盖查询所需的列。
索引设计与覆盖索引优化
要减少RANK的排序压力,最直接的办法是让索引顺序和窗口函数的ORDER BY保持一致。例如查询中有WHERE stat_date = '2025-01-31',同时按department分区、按salary DESC排序,那么可以建立一个联合索引(stat_date, department, salary DESC)。这样数据库沿着索引扫描时,salary在department组内已经有序,优化器就不需要额外做一次全量排序。
CREATE INDEX idx_emp_salary_stat
ON employee_salary (stat_date, department, salary DESC);
需要说明的是,MySQL 8.0以上版本支持降序索引,PostgreSQL也原生支持。降序索引能让ORDER BY salary DESC直接命中索引方向,避免反向扫描。如果数据库版本较旧,也可以创建(stat_date, department, salary),让优化器反向扫描索引,效果通常也能接受。
覆盖索引的作用更明显。假设SELECT只需要emp_id、salary和department三个字段,而不用取emp_name等大字段,那么可以把这些字段都包含进索引。数据库在索引内部就能完成过滤、排序和取数,完全不用回表。这样即使数据量很大,查询也能保持在较快的范围内。实际设计时要权衡索引宽度,索引列太多会增大维护成本和存储空间,但报表场景通常读多写少,宽索引的收益往往大于代价。
还要避免在排序键上使用函数或表达式。比如ORDER BY salary * 0.9 DESC会导致索引失效,数据库必须逐行计算表达式的值,再排序。如果业务上确实需要按折算后的薪资排名,建议在表中增加一列存储折算结果,并给这列建索引。
先缩小窗口范围,再计算排名
很多慢SQL的根源是窗口函数处理了太多不必要的数据。比如业务上只要看各部门前10名,但SQL写成先对整张表排名,再在WHERE中过滤rank_no <= 10。这种写法在逻辑上就不成立,因为WHERE的执行顺序先于窗口函数,数据库会报错或得到错误结果。正确做法是把排名放进子查询,外层再过滤名次。
SELECT * FROM (
SELECT
emp_name,
department,
salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rank_no
FROM employee_salary
WHERE stat_date = '2025-01-31'
) t
WHERE rank_no <= 10;
这样写虽然能正确返回前10名,但内层子查询仍然会对stat_date = '2025-01-31'的全部数据进行排名。如果这个日期下有五百万行,即便最终只返回一百行,排序代价照样很高。因此要把确定性的过滤条件尽量下推到子查询里。比如报表只统计最近三个月的数据,就在子查询里加上stat_date >= '2024-11-01',让窗口函数只处理三个月的数据,而不是整张表的历史数据。
PARTITION BY本身并不能压缩排序总量,它只负责分区。真正影响排序量的还是WHERE过滤后的行数。所以在设计报表SQL时,第一优先是缩小进入窗口函数的数据范围,其次才是考虑分区策略。如果业务允许,还可以把排名计算拆分到多个统计周期,分别跑批生成结果,再合并给前端展示,避免单次查询负担过重。
预聚合与物化结果,适合离线报表
日报、周报、月报这类排名通常不需要实时更新。与其每次请求都重新排序几百万行数据,不如在业务低峰期把排名结果预先计算好,查询时直接读结果表。PostgreSQL支持物化视图,可以存储窗口函数的计算结果。
CREATE MATERIALIZED VIEW mv_dept_salary_rank AS
SELECT
stat_date,
emp_name,
department,
salary,
RANK() OVER (PARTITION BY stat_date, department ORDER BY salary DESC) AS rank_no
FROM employee_salary;
MySQL没有原生物化视图,但可以创建普通汇总表,通过定时任务或事件调度器执行INSERT INTO ... SELECT ...来重建数据。重建时如果直接删除旧表再插入新数据,要注意查询可能出现空窗。更稳妥的做法是写入临时表,再通过事务切换或重命名,减少对线上查询的影响。
CREATE TABLE dept_salary_rank_summary AS
SELECT
stat_date,
emp_name,
department,
salary,
RANK() OVER (PARTITION BY stat_date, department ORDER BY salary DESC) AS rank_no
FROM employee_salary;
增量更新排名结果比较麻烦,因为一条数据的变动可能影响同分区的多条排名。除非数据变更频率很高且实时性要求强,否则全量重建通常比增量维护更简单可靠。对于千万级数据,全量重建可能跑几十秒,但可以安排在凌晨执行,用户查询时只需读汇总表,速度会有数量级提升。
另外,RANK、DENSE_RANK和ROW_NUMBER的选择不会对性能产生根本性影响。RANK跳号,DENSE_RANK不跳号,ROW_NUMBER强制唯一名次。三者的主要计算都依赖排序,性能差距很小。真正决定快慢的还是排序范围和是否回表。不要指望换一个函数就能解决慢的问题,关键仍在索引和数据量控制。
常见误区与排查清单
第一个误区是直接在WHERE条件中使用窗口函数结果。例如下面这种写法在很多数据库里会直接报错,因为窗口函数在WHERE之后才计算。
SELECT
emp_name,
department,
salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rank_no
FROM employee_salary
WHERE stat_date = '2025-01-31'
AND rank_no <= 10;
必须使用子查询或CTE先完成排名,再在外层过滤。同理,窗口函数也不能直接出现在HAVING中,除非通过外层查询处理。
第二个误区是只有排序键有索引就认为万事大吉。实际上如果查询需要返回非索引列,回表仍会耗费大量随机IO。需要使用覆盖索引,或适当缩小返回列。可以把EXPLAIN输出中的Using filesort和Using temporary作为重点排查指标。出现Using filesort说明排序发生在内存或磁盘,出现Using temporary说明中间结果被物化,这两者都会拖慢排名查询。
排查RANK慢的清单可以归纳为:确认执行计划中的排序方式和排序行数;检查排序键是否命中联合索引;检查是否通过WHERE提前减小窗口范围;确认是否存在大量回表;评估是否可以用预聚合或汇总表替代实时排名。按这个顺序排查,基本能定位并解决绝大多数排名统计慢的问题。