报表中的排名查询几乎每个业务系统都会遇到,比如销售榜单、商品热度排行、员工绩效名次。随着数据量从几十万涨到上千万,同样一条排名 SQL,执行时间可能从几百毫秒拉长到十几秒,甚至拖垮整个报表页面。问题通常不是数据库不够强,而是排名计算的 SQL 写法让数据库做了大量重复工作:多次扫描同一批数据、为每行单独执行聚合、生成不必要的临时表。要真正优化,得从两个层面下手:一是用窗口函数替代低效的传统排名写法,减少数据和排序开销;二是对非实时性要求较高的报表结果做缓存,避免高峰期每次都重算全量排名。

一、传统排名 SQL 的性能瓶颈
在窗口函数普及之前,很多排名需求是用相关子查询或自连接实现的。比如要计算每个订单在全局中的销售额名次,常见写法如下。
SELECT o.order_id,
o.amount,
(SELECT COUNT(*)
FROM orders o2
WHERE o2.amount > o.amount) + 1 AS rank_no
FROM orders o
ORDER BY rank_no;
这段 SQL 的逻辑很直观:对于 orders 表中的每一行,都到同表中数一遍有多少行的 amount 比当前行大,然后加一得到名次。可执行引擎并不像人眼那样智能,如果 orders 表有一百万行,外层查询返回一百万行,内层子查询理论上也要执行一百万次。即便数据库优化器能利用索引把内层计数变成范围扫描,整体复杂度依然接近 O(n²),并且还会产生大量回表或索引扫描。
另一种传统做法是使用用户变量,在 MySQL 中尤其常见。它通过一次排序后逐行赋值来模拟排名,语句看起来比子查询快很多,但实际限制也不少。
SET @rank := 0;
SET @prev := NULL;
SELECT order_id,
amount,
@rank := IF(@prev = amount, @rank, @rank + 1) AS rank_no,
@prev := amount AS prev_amount
FROM orders
ORDER BY amount DESC;
这种写法依赖两个前提:结果集必须严格按 amount 降序输出,变量赋值的顺序必须与输出顺序一致。MySQL 官方文档明确指出,用户变量的求值顺序并不保证,某些版本或复杂查询里优化器可能会打乱这个顺序,导致名次错乱。如果需要按部门分组排名,用户变量还得额外处理分组键的变化,逻辑会变得非常脆弱。更关键的是,这种方案仍然需要全表排序,一旦不能在索引上直接完成排序,就会出现 Using temporary 和 Using filesort,数据量大时性能下降非常明显。
还有一种用 JOIN 加 GROUP BY 的写法,本质上也是先产生笛卡尔积再聚合,数据量一大临时表就会膨胀,不值得推荐。因此传统排名的核心问题不是排序本身,而是为了计算名次反复读取和比较同一批数据,以及大量依赖会话变量带来的不稳定性。
二、窗口函数怎么改写排名查询
SQL 标准中的窗口函数专门用来解决这类“在结果集内按分组排序并编号”的需求。最常用的四个排名函数是 ROW_NUMBER、RANK、DENSE_RANK 和 NTILE。它们把排序和编号合并到一个窗口计算里,数据库只需要扫描一次基础数据,然后按窗口定义完成排序,不再需要为每一行单独执行子查询。
| 函数 | 并列名次处理 | 是否跳号 | 典型场景 |
|---|---|---|---|
| ROW_NUMBER() | 不并列,强制唯一编号 | 不跳号 | 取固定条数、分页 |
| RANK() | 并列同名次,下一个名次跳号 | 跳号 | 竞技排名,如第1、第1、第3 |
| DENSE_RANK() | 并列同名次,下一个名次连续 | 不跳号 | 等级排名,如第1、第1、第2 |
| NTILE(n) | 将结果均分为 n 个桶 | 不适用 | 按比例分层,如前20% |
例如要按部门给员工销售额排名,窗口函数写法如下。
SELECT dept_id,
emp_id,
amount,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY amount DESC) AS rn,
RANK() OVER (PARTITION BY dept_id ORDER BY amount DESC) AS rk,
DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY amount DESC) AS dr
FROM sales
WHERE order_date >= '2024-01-01';
这里的 PARTITION BY dept_id 表示按部门分组计算,ORDER BY amount DESC 表示组内按销售额降序。同一个 SELECT 里可以同时使用多个窗口函数,数据库通常只对同一组排序条件做一次排序,多个函数复用该排序结果,这比分别写多个子查询要高效得多。相比相关子查询,窗口函数避免了逐行执行聚合;相比用户变量,它的语义由 SQL 标准定义,结果可预测,也不会受会话变量影响。
一个常见的报表需求是“取每个部门销售额前 3 名”。窗口函数配合子查询即可完成。
SELECT dept_id, emp_id, amount, rn
FROM (
SELECT dept_id,
emp_id,
amount,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY amount DESC) AS rn
FROM sales
) t
WHERE rn <= 3;
需要留意的是,rn 是窗口函数别名,在 MySQL 中不能直接在外层 WHERE 中引用,必须通过子查询或 CTE 包一层。虽然窗口函数本身也需要排序,但它可以利用 WHERE 过滤后的结果集排序,并且可以借助索引减少排序代价。例如在 sales 表上建立 idx_sales_dept_amount(dept_id, amount) 复合索引,当过滤条件能固定到较小的部门范围时,排序开销会明显降低。
窗口函数也不是完全没有代价。如果同时对几百万行做全量排名,仍然会消耗内存和排序时间。所以优化时还要结合业务:只对需要的分区和合适的时间范围计算排名,避免无意义的全表窗口计算。
三、缓存优化:让排名结果不用每次现算
SQL 层面优化到一定程度后,报表接口可能仍然扛不住高频访问,尤其是排名结果变化不频繁的场景。这时更有效的办法是缓存。报表排名的数据通常具备一个特征:读多写少,且用户可以接受分钟级甚至小时级的延迟。把昂贵的窗口函数计算从请求链路中剥离,放到后台任务里执行,是投入产出比很高的优化。
第一种做法是使用定时汇总表。可以每天凌晨、每小时甚至每十分钟执行一次排名 SQL,把结果写入一张普通表,线上报表直接查这张小表。
CREATE TABLE report_sales_rank AS
SELECT dept_id,
emp_id,
amount,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY amount DESC) AS rn
FROM sales
WHERE order_date >= '2024-01-01';
ALTER TABLE report_sales_rank ADD INDEX idx_dept_rn (dept_id, rn);
这张 report_sales_rank 表的数据量通常远小于原始明细表,查询某个部门的前 10 名可以直接走 idx_dept_rn 索引,响应时间从秒级降到毫秒级。更新时可以采用全量重建或增量合并,全量重建适合数据量不大或统计周期固定的场景;增量合并则适合数据持续写入、又希望排名接近实时的业务,例如每小时只更新新增订单的排名,再与已有结果合并。全量重建实现简单,但窗口计算的全部行数较多时可能占用较多数据库资源,建议放在低峰期执行。
第二种做法是引入 Redis 等缓存中间件,把排名结果缓存到应用层。比如前端请求“部门 1001 的本月销售排名”,后端先查缓存,命中就直接返回,未命中再查汇总表或明细表,并写入缓存。缓存键可以设计为 rank:dept:1001:month:202502,值可以是 JSON 数组或排行榜字符串。为了避免缓存穿透,可以在没有数据时写入空列表并设置较短过期时间。对于热点榜单,可以设置多级缓存或本地缓存,减少 Redis 请求量。
import redis
import json
r = redis.Redis(host='127.0.0.1', port=6379, db=0)
key = 'rank:dept:1001:month:202502'
data = r.get(key)
if data is None:
rows = query_database(dept_id=1001, month='202502')
r.setex(key, 3600, json.dumps(rows))
else:
rows = json.loads(data)
这段缓存逻辑很简单:先读缓存,未命中再查询数据库并回填,同时设置 3600 秒过期时间。需要注意的是,缓存更新策略要和业务一致性要求匹配。如果排名允许 1 小时延迟,过期时间设长一点即可;如果要求用户下单后马上看到新排名,就不能简单依赖过期失效,需要在下单事件中主动更新缓存或删除相关缓存键,让下一次请求回源重建。也可以使用数据库的物化视图,但 MySQL 原生并不支持自动刷新的物化视图,一般需要通过定时任务模拟,Oracle、PostgreSQL 等数据库则有更成熟的物化视图能力。
下表对比了几种缓存方案。
| 方案 | 实时性 | 实现成本 | 一致性 | 适用场景 |
|---|---|---|---|---|
| 定时汇总表 | 低到中,分钟到小时级 | 低 | 最终一致 | 日报、周报、月度榜单 |
| Redis 应用缓存 | 可高可低 | 中 | 取决于失效策略 | 高频访问的实时或多级缓存 |
| 数据库物化视图 | 中 | 看数据库支持程度 | 最终一致或手动刷新 | 底层统计表、宽表加速 |
缓存优化的关键不是缓存本身,而是把昂贵的计算从在线请求中剥离出来。SQL 窗口函数计算一次可能只需要几百毫秒,但如果在高并发下每个请求都执行一次,数据库 CPU 和 I/O 压力会被无限放大。缓存将计算频次从“请求数”降低到“更新频次”,这正是报表系统最需要的削峰手段。
四、落地时还要注意这些细节
优化排名查询不能只盯着 SQL 改成窗口函数,还要看执行计划是否真的减少了排序和扫描。用 EXPLAIN 或 EXPLAIN ANALYZE 检查是否出现 Using temporary 和 Using filesort,这在 MySQL 中意味着查询用到了临时表和文件排序,数据量大时是主要瓶颈。如果排序字段和分区字段已经有复合索引,优化器可能通过索引扫描直接获得有序结果,从而避免文件排序。
分页也容易成为排名报表的暗坑。假设前端需要展示“第 1 名到第 100 名”,使用 LIMIT 100 OFFSET 0 没问题,但如果用户翻到第 10001 名,使用 LIMIT 100 OFFSET 10000 会让数据库扫描并丢弃前一万行,排名越高越慢。更好的做法是先用窗口函数生成名次,再用索引覆盖名次范围查询,或者使用游标分页,记录上一页最后一名的名次作为下一页起点。虽然窗口函数本身不能消除大偏移量,但结合缓存和汇总表,可以把分页查询落在更小的结果集上。
业务上还要明确并列规则。如果两个人销售额相同,榜单应该显示两个第 1 名然后直接第 3 名,还是显示第 1、第 1、第 2?前者用 RANK,后者用 DENSE_RANK。取前 N 名时,DENSE_RANK 可能返回多于 N 行,ROW_NUMBER 则可能在并列场景下漏掉本该入围的人。这些规则必须在 SQL 和缓存结构设计之前确定,因为一旦缓存键和值结构按照某个排序函数设计,后期切换会造成数据口径不一致。
最后,窗口函数和缓存优化并不冲突,反而可以组合使用。后台任务用窗口函数生成排名汇总表,应用层用 Redis 缓存热点榜单,报表接口只做轻量查询。这样数据库执行昂贵排序的次数被定时任务控制住,高并发请求也不会直接打到明细表上。即使数据量继续增长,也可以通过缩短刷新周期、拆分分区、引入列式存储等方式继续扩展。