为什么老站长该把子查询换成窗口函数
做站久了总会遇到一类查询:从文章表里挑出每个分类最新的一篇、从访问日志里算每个 IP 的累计请求数、把订单按用户分组后取金额最高的前三笔。过去我们习惯用相关子查询或者自连接来写,代码又长又难读,数据量一大就慢得让人抓狂。MySQL 8.0 引入的窗口函数(Window Function)正是为这类「分组内排名与累计」的场景准备的,它可以在不折叠结果行的前提下,对每一行附带计算出分组内的排序、累计和、上一行/下一行等派生值。
这篇文章不讲教科书式的语法罗列,而是从个人站长真实会碰到的三类查询出发:分组取最新、分组取 TOP N、滑动累计与环比,把窗口函数怎么替代旧的写法、性能差在哪里、以及线上使用时要避开的坑一次讲透。
窗口函数到底做了什么
普通聚合(GROUP BY)会把一组多行压缩成一行,你一旦用了 GROUP BY,就没法在同一行里既看到分组汇总又看到明细。窗口函数的思路不一样:它先按 PARTITION BY 把结果集划成若干「窗口」(分组),再在窗口内部按 ORDER BY 排序,最后用一个函数对窗口内的相关行求值,但每一行仍然保留,派生值作为新列挂在这一行上。
函数名(...) OVER (
PARTITION BY 分组列
ORDER BY 排序列
[ROWS/RANGE 帧范围]
)OVER 子句就是「窗口函数」与普通函数的唯一区别标志。没有 OVER 的 ROW_NUMBER() 或 SUM() 会被 MySQL 当成语法错误;而有 OVER 时,SUM() 不再折叠行,而是逐行给出窗口内的累计值。
最常用的几个函数可以这样归纳:
ROW_NUMBER():窗口内从 1 开始的连续序号,重复值也各不相同。RANK()/DENSE_RANK():并列排名。RANK遇并列会跳号(1,1,3),DENSE_RANK不跳号(1,1,2)。SUM()/AVG()/COUNT()带OVER:窗口内聚合或累计聚合。LAG()/LEAD():取窗口内上一行 / 下一行的值,做环比、同比特别顺手。FIRST_VALUE()/LAST_VALUE():窗口第一行 / 最后一行的值。
场景一:每个分类只取最新一篇(去重取一)
这是个人站长最常写的查询——首页或分类页要展示「每个分类下最新的一篇文章」。老写法是相关子查询套一层,或者对每个分类单独跑一次,分类一多就要跑几十次。
假设文章表结构近似如下(字段是示意,按你本站的实际表名替换):
CREATE TABLE posts (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
category VARCHAR(32),
title VARCHAR(200),
created DATETIME,
KEY idx_cat_created (category, created)
);用 ROW_NUMBER() 一行搞定:
SELECT id, category, title, created
FROM (
SELECT id, category, title, created,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY created DESC, id DESC
) AS rn
FROM posts
) t
WHERE t.rn = 1;内层子查询给每一行标上「在本分类内按时间倒序的序号」,外层只留序号为 1 的行,也就是每个分类的最新一篇。ORDER BY created DESC, id DESC 里补一个 id 是为了在同一时间戳有多个分类文章时保证结果稳定——不补的话,ROW_NUMBER 在并列时的分配是未定义的,可能每次跑出来不一样。
如果某个分类的最新文章可能有并列(比如同一秒导入的),而你想把并列的都保留,就把 ROW_NUMBER() 换成 RANK(),然后仍然过滤 rn = 1,这样并列的行都会留下来。
场景二:分组取 TOP N(每个分类前 3 篇)
把上一节的 rn = 1 改成 rn <= 3,就得到了「每个分类最新 3 篇」。这个模式几乎可以做任何「分组取 TOP N」:
SELECT category, title, created, rn
FROM (
SELECT category, title, created,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY created DESC
) AS rn
FROM posts
) t
WHERE t.rn <= 3
ORDER BY category, rn;对比一下旧的自连接写法,你会立刻明白窗口函数的价值。用自连接取「每类前 3」要写成:
SELECT p1.category, p1.title
FROM posts p1
LEFT JOIN posts p2
ON p1.category = p2.category
AND p1.created < p2.created
GROUP BY p1.id
HAVING COUNT(p2.id) < 3;自连接的写法要 join 出的中间结果远大于原表(每类 N 篇就产生约 N² 的匹配行),在几十万行的日志表上会直接拖垮数据库。窗口函数则只需一次扫描加一次排序,代价可控得多。只要索引建对(category, created 的联合索引),窗口函数在这个场景下几乎是标准答案。
场景三:累计值与环比(滑动窗口)
做数据看板时,经常要给出「每个 IP 按时间的累计访问量」以及「今天比昨天增长了多少」。这类需求靠 SUM() OVER 配合帧范围,以及 LAG() 就能优雅解决。
先看累计求和。默认带 ORDER BY 的窗口帧是「从窗口第一行到当前行」,所以下面的查询会给出逐行递增的累计值:
SELECT day, pv,
SUM(pv) OVER (ORDER BY day) AS running_pv
FROM daily_stats
ORDER BY day;如果你想让它按某个维度分组后各自累计,只需要加 PARTITION BY:
SELECT ip, day, pv,
SUM(pv) OVER (
PARTITION BY ip
ORDER BY day
) AS running_pv
FROM ip_daily
ORDER BY ip, day;再看环比。LAG() 取出窗口内上一行的值,与当前行相减就得到日环比:
SELECT day, pv,
LAG(pv) OVER (ORDER BY day) AS prev_pv,
pv - LAG(pv) OVER (ORDER BY day) AS diff
FROM daily_stats
ORDER BY day;LAG(pv, 1, 0) 的第三个参数是「没有上一行时的默认值」,不填则返回 NULL。第一行没有上一天,若不设默认值,diff 会是 NULL,很多前端图表遇到 NULL 会直接画出断点,所以线上给个 0 更稳。
帧范围(frame)——最容易踩坑的地方
默认的窗口帧不是常量,它取决于 OVER 里有没有 ORDER BY:
- 有
ORDER BY:默认帧是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,即从窗口第一行累计到当前行。 - 没有
ORDER BY:默认帧是整个分区ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING,即聚合整组。
这两者行为差别巨大。比如下面这个查询,没写 ORDER BY 时 SUM 给的是整组总和(每组每行都一样),写了 ORDER BY 就变成累计:
-- 整组总和(每行相同)
SELECT category, pv, SUM(pv) OVER (PARTITION BY category) AS total FROM t;
-- 逐行累计(从第一行加起)
SELECT category, pv, SUM(pv) OVER (PARTITION BY category ORDER BY day) AS running FROM t;另一个易错点是 LAST_VALUE():因为默认帧到「当前行」为止,LAST_VALUE() 拿到的其实是当前行自己,而不是整个分区的最后一行。要让 LAST_VALUE() 真正取分组最后一行,必须显式把帧扩展到分区末尾:
LAST_VALUE(pv) OVER (
PARTITION BY category
ORDER BY day
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)忘了写帧范围是新手最常犯的错,结果 LAST_VALUE 等于当前值,白白排查半天。
性能:索引与排序才是瓶颈
窗口函数本身不会「神奇地快」,它底层要做两件事——按 PARTITION BY 分桶、按 ORDER BY 排序。如果排序能用上索引,窗口函数的成本就很低;用不上索引,就成了全表排序。 所以关键仍是索引设计:
PARTITION BY category ORDER BY created DESC——建(category, created)联合索引,MySQL 可借索引顺序免去额外排序。- 多列排序时,索引列顺序要与
PARTITION BY加ORDER BY的顺序一致,且注意方向。 - 先
WHERE过滤掉大量无关行再开窗,比全表开窗后再过滤快得多——但要注意,WHERE里的过滤条件如果引用窗口结果(比如过滤rn = 1),必须放在外层子查询,不能直接写进内层WHERE,因为窗口函数是「先算后过滤」。
一个常见的性能陷阱是:把 ROW_NUMBER() 的过滤放到同一个 SELECT 的 WHERE 里,MySQL 会报 Unknown column 'rn' in 'where clause'。正确做法是套一层派生表,如前面示例所示。
用 EXPLAIN 对比新旧写法时,重点看 type 有没有变成 ALL(全表扫描)以及 Extra 里有没有 Using filesort。如果窗口函数查询出现了 Using temporary; Using filesort,说明分区或排序没能用上索引,该回去检查索引了。
三个线上必须记住的坑
- 窗口函数不能在
WHERE/HAVING里直接过滤。 SQL 的执行顺序是「FROM → WHERE → GROUP BY → HAVING → SELECT(含窗口)→ ORDER BY → LIMIT」,窗口在 SELECT 阶段才计算,自然早于它的 WHERE 看不到它。要过滤就再套一层子查询。 - 只会出现在
SELECT列表和ORDER BY里。 把窗口函数写在JOIN ... ON或GROUP BY里都是语法错误,这是标准行为,不是 MySQL 的缺陷。 - 版本要求 8.0 以上。 MySQL 5.7 不支持窗口函数,如果本站还在 5.7,升级前要有回退方案——切记别在生产库直接大版本升级,先备份(mysqldump 或用 LVM 快照做一份一致性副本),再在从库验证业务 SQL 无误后再切换。
把旧查询迁移过来的检查清单
- 先确认数据库版本
SELECT VERSION();是否 ≥ 8.0。 - 把每个「相关子查询取 TOP1」或「自连接取 TOP N」翻译成
ROW_NUMBER() OVER(...),外层过滤rn <= N。 - 检查索引:
PARTITION BY+ORDER BY的组合列,尽量建成一条联合索引。 - 跑
EXPLAIN,确认没有多余的Using filesort/Using temporary。 - 对需要「稳定结果」的分组排序,补上唯一列(如
id)作为排序第二列。 - 给
LAG/LEAD设默认值,避免前端图表出现断点。 - 改完后在从库或测试库先跑一遍真实数据,对比新旧结果的条数和数值是否一致。
窗口函数是 MySQL 8 时代最值得个人站长掌握的一项能力。它不增加任何运维成本,却能把过去又慢又难维护的「分组取最新 / 分组取 TOP N / 滑动累计」查询,压缩成一段一眼能看懂、性能又可控的 SQL。下一次再遇到「每个分类最新一篇」这种需求,先想想能不能用 ROW_NUMBER() 解决,你会发现代码清爽了很多。