☰
MySQL查询结果加序号:用户变量与窗口函数全解析
2026/10/21 6:02:42 网站建设 项目流程

1. 给查询结果加序号,先分清四种需求

给 MySQL 查询结果加上"序号",是我做后台系统这几年来接到过最多的需求之一。报表要导 Excel,第一列想带个 NO.;排行榜要显示名次;列表页每页要有 1 到 N 的行号;还有一种更隐蔽的,按客户分组的订单明细,每个客户自己的订单序号得从 1 开始数。很多人第一反应是"这还不简单,程序里循环加个 i 不就完了"。这话在数据量小、单页展示的时候确实没错,但一旦牵扯到 SQL 报表、大批量导出、跨页分页、数据去重,你就必须在 SQL 层把序号算出来,这也是这篇文章真正要解决的场景。

不过先别急着抄代码,我建议你先搞清楚自己想要的"序号"是哪种语义。这里的差别非常大,选错方案写出来的 SQL 不只是不好看的问题,是结果直接不对。我总结下来,日常需求基本就四种:

  • 结果集行号:查询出多少行,就按输出顺序从 1 编到 N,纯粹为了展示顺序,比如导出报表的"NO."列。
  • 排名(Ranking):按某个指标排大小,分数相同的要并列,比如销售排行榜、考试成绩单。
  • 组内序号:按某个字段分组,在每一组内部从 1 开始编号,比如每个客户自己的订单序号、每个班级里学生的学号位次。
  • 连续编号:不关心排序,只要每一行拿到一个不重复的递增值,比如临时给数据打标记用。

这四种需求,MySQL 给出的最佳答案完全不同。排名和组内序号是最容易搞混的,很多人拿全部结果的序号逻辑去套分组场景,结果永远是断号、乱序。所以这篇文章我会从最经典的 5.x 方案讲起,再讲 8.0 的窗口函数怎么把这些问题全部简化,最后落到分页、导出、去重这些实际业务场景。

还有一个前提必须先立住:给查询加序号前,必须先把 ORDER BY 想清楚。数据库返回行的顺序,在没有 ORDER BY 时是不保证稳定的,尤其是表数据量大了之后,全表扫描、索引选择一变,行顺序就可能变。如果你的序号是跟着行顺序生成的,那排序一变,序号跟着乱。所以下面所有写法里,要不要 ORDER BY、ORDER BY 写什么字段,都不是可选动作,而是整个 SQL 能不能用的前提。

2. MySQL 5.x 的主流答案:用户变量 @rownum 的自增玩法与三个大坑

在 MySQL 8.0 之前,窗口函数还没影儿,大家给查询结果加序号全靠用户变量。思路也很直白:定义了一个会话变量 @rownum,每输出一行就让变量加 1,把加完的值当成序号列输出。听起来简单,但这里水很深,我先给基础写法,再把坑一个个讲清楚。

2.1 基础写法:两条 SQL 还是单条 SQL 初始化

最常见的写法是先初始化变量,再查数据:

SET @rownum := 0; SELECT @rownum := @rownum + 1 AS seq, student_name, score FROM score_table ORDER BY score DESC;

逻辑没有任何问题,但有个隐患:这两个语句之间如果有人插了一句话,或者这条 SQL 是从程序里拼出来的,SET 语句一旦没执行,后面整条就废了。所以我更推荐用一条 SQL 完成的写法,通过派生表在原地初始化变量:

SELECT @rownum := @rownum + 1 AS seq, student_name, score FROM score_table, (SELECT @rownum := 0) r ORDER BY score DESC;

这里的(SELECT @rownum := 0) r是一个只输出一行的派生表,作用就是给变量赋初值 0,然后在同一个语句里直接使用。这样既不用分号拼接,也不怕前面遗漏,我个人强烈建议在任何生产脚本里都用这种单条写法。

顺带解释一下运算符::=是赋值,=在 SELECT 列表里是等值比较,这两个如果写混了,序号列会直接变成 0 或者 NULL,而且不报错。只有在 SET 语句里=和:=都能当赋值用,为了统一和保险,一律写:=。

2.2 大坑一:忘记初始化,编号全是 NULL

如果漏了初始化,@rownum 一开始是 NULL,那么NULL + 1还是 NULL,第一次赋值就把 NULL 存进去了,后面每一行算出来全是 NULL。这类问题在报表里特别隐蔽,因为 SQL 不报错,导出来的 Excel 中间一列全是空的,你要排查半天才想起来变量没初始化。

这也是为什么我不推荐"先 SET 再 SELECT"的写法,因为 SET 语句离得太远,很容易在某次改动中被删掉或者被分支逻辑跳过。把初始化写进 FROM 子句的派生表里,和查询本身绑定在一起,才是不会被遗忘的姿势。

2.3 大坑二:连接池复用连接,序号不从头开始

用户变量是会话级的,这个很多人知道,但落到真实环境里还是会踩。程序连接 MySQL 一般都用连接池,一条连接执行完查询并不会关闭,而是还给连接池继续复用。如果你用的是"先 SET 再 SELECT"的写法,第一次查询 @rownum 跑到了 100,下一次这条连接又被分配出来,SET 语句没执行(比如框架只执行了你拼接好的那条 SELECT),@rownum 就从 100 开始继续加,序号直接从 101 开头。

表现就是偶发性的:这次导出正常,下次导出序号从某个奇怪数字开始。排查起来特别费劲,因为换一条连接可能又正常了。解法还是那句:永远用单条 SQL 内置初始化的写法,让变量生命周期和这条查询严格绑定,彻底排除会话残留。

2.4 大坑三:内层 ORDER BY 被优化器"吃掉"

如果你是想先算好序号,再用序号做范围筛选,比如取第 11 到第 20 行,通常会写子查询:

SELECT * FROM ( SELECT @rownum := @rownum + 1 AS seq, student_name, score FROM score_table, (SELECT @rownum := 0) r ORDER BY score DESC ) t WHERE t.seq BETWEEN 11 AND 20;

这段 SQL 在 MySQL 5.6 上大概率没问题,但在 5.7 上有个著名问题:优化器会把派生表合并进外层查询,如果外层没有 LIMIT,内层子查询的 ORDER BY 可能被直接忽略。结果是什么?@rownum 按照全表扫描的物理顺序自增,而不是按 score 排序后的顺序自增,序号确实是从 1 到 N,但对应到哪一行就完全不可控了,排第 1 名的人可能拿到的是 50 号。

5.7 时代的标准解决办法是给内层子查询加一个极大的 LIMIT,强制它成为"物化派生表",保住排序结果:

... ORDER BY score DESC LIMIT 18446744073709551615

这个数字是 MySQL 里 BIGINT UNSIGNED 的最大值,效果等同于"不限制行数,但别优化掉我的 ORDER BY"。这个 workaround 非常有名,如果你还在维护 5.7 的老系统,建议直接收藏这段模板。

3. MySQL 8.0 再也不用凑变量:ROW_NUMBER() 与 RANK 系函数的正确用法

MySQL 8.0 正式引入了窗口函数,给查询结果加序号这件事,终于有了一种官方、稳定、不依赖执行细节的写法。窗口函数也解决了我上一节讲的绝大多数痛点:不用初始化变量、不怕连接池残留、内层排序不会被优化器吃掉,因为排序本身就是窗口定义的组成部分。如果你的生产环境已经是 8.0,我会建议你忘掉 @rownum,直接拥抱这套新语法。

3.1 ROW_NUMBER() 的基础语法与执行逻辑

窗口函数里最常用的就是 ROW_NUMBER(),基本语法是:

SELECT ROW_NUMBER() OVER (ORDER BY score DESC) AS seq, student_name, score FROM score_table;

OVER (ORDER BY ...)就是窗口定义,它告诉数据库:先按 score 降序把行排好,再按这个顺序给每一行分配一个从 1 开始、连续递增的序号。理解这个执行逻辑很重要,因为窗口函数的排序发生在 WHERE 和 GROUP BY 之后、SELECT 输出之前,所以它基于的排序和表里的物理存储没有关系,完全由 OVER 子句决定。

日常写的时候,我一般还会在最后面再补一个整体的 ORDER BY:

SELECT ROW_NUMBER() OVER (ORDER BY score DESC) AS seq, student_name, score FROM score_table ORDER BY seq;

这样能保证最终输出顺序稳定,也方便分页或者进一步处理。注意 ROW_NUMBER() 的结果是一个计算值,你可以在外层 ORDER BY 里直接使用列别名 seq,这是窗口函数和普通表达式一样的地方。

3.2 ROW_NUMBER、RANK、DENSE_RANK 到底差在哪里

这三个是新手最容易混的。很多业务说要"排名",结果一上来就用 ROW_NUMBER(),遇到分数并列的情况就出问题了。我用一张表把它们的区别说清楚,假设有成绩 98、97、97、95 四个人:

函数结果说明
ROW_NUMBER()1, 2, 3, 4只关心物理输出顺序,就算两个 97 也要分出先后
RANK()1, 2, 2, 4并列相同名次,跳过后续编号
DENSE_RANK()1, 2, 2, 3并列相同名次,不跳过后续编号

看到区别了吗?如果有人考了 98、两个人并列考了 97,按照中国式排名,第三名实际是不存在的,接下来是第四名,这正是 RANK() 的行为。DENSE_RANK() 则适合那些希望名次连续的业务,比如"这一批里有多少个不同的分数段"。而 ROW_NUMBER() 严格意义上不是排名,它只是给了每行一个不重复的序号——哪怕两行分数完全一样,也会因为在 ORDER BY 里碰了运气分成前后,得到 2 和 3。

这里有个实操小技巧:如果排序字段不唯一,ROW_NUMBER() 的分配顺序在并列行之间是不确定的。比如两个 97 分的人,到底谁拿 2 谁拿 3,取决于执行计划。如果你希望这种行为稳定,就在 OVER 的 ORDER BY 里补一个唯一字段作为决胜条件:

ROW_NUMBER() OVER (ORDER BY score DESC, id ASC)

这样即使分数一样,序号也完全可预测。

3.3 窗口函数不能直接用在 WHERE,需要子查询兜底

和 @rownum 时代的一个问题是共通的:你算出来的序号,能不能直接拿来当筛选条件?比如说"只取排名前 10 的学生":

SELECT ROW_NUMBER() OVER (ORDER BY score DESC) AS seq, student_name, score FROM score_table WHERE seq <= 10; -- 这么写会直接报错

这里有两个层面的错。第一,WHERE 在 SELECT 别名求值之前执行,所以 WHERE 里根本看不到 seq 这个别名;第二,窗口函数的结果也是在 WHERE 之后才产生的,逻辑上就不可能被 WHERE 引用。正确做法是包一层子查询,先算出序号,再在外面筛选:

SELECT * FROM ( SELECT ROW_NUMBER() OVER (ORDER BY score DESC) AS seq, student_name, score FROM score_table ) t WHERE t.seq <= 10;

这层子查询是窗口函数使用的标准姿势。做分页也好、Top N 也好,都要记住这个模式。MySQL 8.0 没有提供"窗口函数可直接用于 WHERE"的捷径,谁跟你说有,那一定是用子查询包了一层。

3.4 什么情况别用窗口函数

窗口函数当然不是万能的。如果只是要一个程序端展示用的行号,比如网站列表页前端循环 i++,那根本没必要让数据库去算。另外一个实际情况是:如果你的 MySQL 版本还在 5.7,那窗口函数根本用不了,MariaDB 从 10.2 起才有窗口函数,升级前必须确认版本。

还有一点可能被低估,窗口函数排序是需要在排序区进行的,排序数据量过大时会使用磁盘临时文件,慢查询日志里很容易出现Using filesort。当然 @rownum 方案排序时同样要 filesort,两者的量级没有本质差别。窗口函数的优势在于稳定性,不是说在性能上能碾压变量法。

4. 组内编号(每个人自己的第 N 单):最容易被做错的需求

前两节讲的都是对整张结果集编号,这算比较容易的。真正让很多开发者翻车的是组合编号:按某个字段分组,每一组内部从 1 开始编号。我给一个非常典型的业务场景:客户订单表要导出,每个客户的订单要从 1 开始编自己的序号,第一个订单是 1,第二个是 2,换个客户又重新从 1 开始。

4.1 需求看一眼很简单,动手就露馅

假如 order_table 里有 customer_id、order_no、order_amount、create_time 这些字段。要求的输出长这样:同一个 customer_id 内,create_time 最早的订单序号是 1,接下来的依次递增;换一个 customer_id,序号重置为 1。很多人第一反应是直接对全表加一个序号,导出后发现客户 B 的订单接着客户 A 的号往下编,完全不对。这就是因为把"全局行号"和"组内序号"混为一谈。

4.2 8.0 写法:PARTITION BY 一行解决

窗口函数里有个专门的分组关键字 PARTITION BY,完美对应这个需求:

SELECT customer_id, order_no, order_amount, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY create_time) AS group_seq FROM order_table ORDER BY customer_id, create_time;

在 OVER 子句里,PARTITION BY 表示"按客户分组",ORDER BY create_time 表示"组内按时间排序",ROW_NUMBER() 就是每一组内部从 1 开始的序号。三个部分配合,一个需求、一行窗口,干净利落。

这里我再提一个容易忽略的细节:输出外层我加了ORDER BY customer_id, create_time,这行最好别省。因为窗口函数分组后,结果集的物理输出顺序不会自动按分组整齐排列,不显式排序,导出的数据会看起来东一块西一块,客户 A 的组可能碰到中间,客户 B 的组又挨在后面,体验很不好。

4.3 5.x 写法:变量组标记法

还在 5.7 上跑的同学不用慌,用户变量也能做组内编号,核心思路是:用第二个变量记住"上一行是谁",每当发现当前行的 customer_id 和上一个 customer_id 不同,就把序号重置为 1:

SELECT customer_id, order_no, order_amount, @row := CASE WHEN @last_customer_id = customer_id THEN @row + 1 ELSE 1 END AS group_seq, @last_customer_id := customer_id AS last_customer_id FROM order_table, (SELECT @row := 0, @last_customer_id := NULL) r ORDER BY customer_id, create_time;

拆开看,这个 SQL 做了三件事:

  • 派生表 r 初始化两个变量:@row 是组内序号,@last_customer_id 用来记忆上一个客户 ID。
  • SELECT 列表里,CASE 先判断当前行和上一行是否同一个客户。同一个客户,说明还在同一组内,@row 加 1;不是同一个客户,说明新组开始了,序号重置为 1。
  • 判断完之后,立刻把当前行的 customer_id 更新到 @last_customer_id 里,给下一行用。

输出的 group_seq 就是每个客户自己的订单序号。

4.4 变量版为什么容易翻车:判断与更新必须分开

如果你照着写了一遍,发现所有行的 group_seq 全是 1,不用怀疑,几乎肯定是把@last_customer_id := customer_id这个赋值写在了 CASE 之前。MySQL 的 SELECT 列表实际执行是从左到右的,如果你先把 @last_customer_id 更新成了当前行客户 ID,CASE 再判断"当前行和上一行是否相同",永远都是相同,序号永远重置为 1,组内编号彻底失效。

这个"先判断后更新"的顺序是整个变量版组内编号的灵魂。它依赖的是 MySQL 对 SELECT 列表从左到右求值这个实现细节,官方文档并没有把这种顺序写成硬性标准,所以这套 SQL 能跑、能跑很多年,但属于"用脚踩钢丝"的玩法。我的态度很明确:5.7 上如果确实要做组内编号,用就用了,但一定要把这个 CASE 和赋值的顺序钉死在模板里,别让后来接手的人随便调换列顺序。

还有几个边界情况提醒一下:如果 customer_id 存在 NULL,@last_customer_id = customer_id的判断会出错,NULL 之间比较结果不是真,每一组 NULL 客户都会各自从 1 开始编号,而且结果不可控。处理方式是在业务上先过滤掉 customer_id 为空的记录,或者在 CASE 里用<=>安全相等运算符,它能正确判断 NULL 与 NULL 相同。排序字段也一样,create_time 如果有重复,并列行之间的编号顺序不稳定,建议再补一个唯一字段做决胜条件。

5. 把序号接进真实业务:分页、导出、去重与选型对比

前面讲清楚了全局编号和组内编号,最后我把这些能力落到几个真实的业务场景里。这一节的价值在于,很多人在学习时都见过 ROW_NUMBER() 的示例,但一到自己的项目里就不知道往哪儿放。

5.1 分页场景:子查询包一层,rn 才能进 WHERE

网页表格分页最常见的诉求是"每一页的行号从 1 到 N 连续显示"。这个诉求其实有两个层面:第一个层面是当前页的行号,这个我建议直接在程序端算,数据库只查当前页的数据,行号等于offset + 循环计数,完全没必要让数据库多算一个全局序号;第二个层面是跨页也要保持全局序号连续,也就是第一页显示 1-10,第二页显示 11-20,这种就必须在 SQL 层生成。8.0 下用窗口函数包一层子查询:

SELECT * FROM ( SELECT ROW_NUMBER() OVER (ORDER BY id) AS rn, customer_id, order_no, order_amount FROM order_table ) t WHERE t.rn BETWEEN 11 AND 20;

5.7 下就还得用变量版,并且记得我前面讲的排序保护手段,加上LIMIT 18446744073709551615防止内层 ORDER BY 被优化掉。这是我做分页时最想提醒你的一点:不要图省事直接在内存里把全表排序后再取第 11 到 20 行,数据量一大就是灾难。

5.2 导出报表:在 SQL 层生成序号列的推荐模板

导出 Excel 这类场景,我强烈建议序号在 SQL 里直接生成,而不是导出后再用程序补。原因有两个:很多报表工具和 BI 系统不支持事后补行号,数据源是什么样子,Excel 就是什么样子;另外序号跟着 SQL 走,能让导出脚本和预览 SQL 保持一致,不容易出现"预览有序号、导出一片空"的错位。

我常用的模板就三段:ORDER BY 定序、窗口函数编号、外层保证顺序:

SELECT seq, student_name, score FROM ( SELECT ROW_NUMBER() OVER (ORDER BY score DESC) AS seq, student_name, score FROM score_table ) t ORDER BY seq;

注意外层一定要有 ORDER BY,不然序号虽然算出来了,但行顺序乱掉,导出的数据逻辑上是对的,看着却像洗过牌一样。

5.3 数据清洗:用 ROW_NUMBER 按组去重

窗口函数还有一个非常香的使用场景:按某个字段去重,保留每组里最早的一条。比如 users 表里有重复手机号,想保留 id 最小的一条,删除其余:

DELETE FROM users WHERE id IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER (PARTITION BY phone ORDER BY id) AS rn FROM users ) t WHERE t.rn > 1 );

注意这里必须套两层子查询。MySQL 不允许在 DELETE 的 WHERE 里直接引用要操作的目标表的子查询结果,会报You can't specify target table for update in FROM clause,所以要先包一层派生表,再在外面删。这个去重场景用窗口函数比变量法直观太多,变量法去掉重逻辑要同时维护序号和分组标记两个变量,写出来非常绕,能用 8.0 就不要再折磨自己。

5.4 方案选型速查表与我的最终建议

把前面的内容汇总成一张选型表,方便你直接对照自己的情况:

方案适用版本稳定性性能推荐程度
用户变量 @rownum5.x 全系列依赖执行顺序,有坑通常够用5.x 环境下可用
ROW_NUMBER() 窗口函数8.0+官方标准,无执行顺序依赖与变量法同级新版首选
变量组标记法5.x 全系列顺序敏感,容易翻车通常够用组内编号兜底
PARTITION BY 窗口函数8.0+稳定与变量法同级组内编号首选
自连接计数任意版本结果准确O(n²) 级,很差不推荐

一个更省心的办法是:如果你还在 5.7 上挣扎,与其在变量法里抠执行顺序,不如认真规划一次升级到 8.0。窗口函数解决了太多在 5.x 时代只能靠"经验性写法"解决的问题,升级带来的收益比想象中大。

最后再分享一条我自己的习惯:无论是变量法还是窗口函数,我都坚持"排序字段 + 决胜字段"的策略。排序字段决定业务含义,比如按分数、按时间;决胜字段保证并列情况的确定性,通常是主键。别的小技巧就是建好复合索引,让 ORDER BY 的字段顺序尽量匹配索引顺序,能少一次 filesort 就少一次。序号这个东西看起来简单,但一旦在线上报表里出现乱序,找人背锅的功夫够你写好几遍正确 SQL 的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询