ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL查询结果加序号:窗口函数与用户变量的完整实践

MySQL查询结果加序号:窗口函数与用户变量的完整实践

MySQL查询结果加序号:从临时需求到窗口函数的完整实践

干数据库开发的人,十有八九都遇到过这种需求:报表上要加一列序号,前端展示的时候要按顺序标上1、2、3……或者领导突然说“这个列表我要看排名”。接到这种需求时,如果只在脑海里的第一反应是“这还不简单,循环一下嘛”,那八成是个新手。真正上手操作过的朋友都知道,MySQL里给查出的数据加序号,背后牵扯到排序稳定性、变量生命周期、版本差异、性能取舍等一系列问题,远没有想象中那么轻松。

这篇文章会把这个需求彻底讲透。我会从最基础的实现方式讲起,逐步深入到MySQL 8.0的窗口函数方案,中间穿插老版本必须面对的用户变量方案,最后再展开介绍排名场景的扩展玩法。无论你是刚入行的初级开发,还是被历史项目绑在MySQL 5.7上动弹不得的运维老手,这篇文章都能给你一套可以直接抄作业的完整解法。更重要的是,我会把那些日常文档里查不到、只有踩过坑才知道的细节一并写出来。

1. 加序号需求的本质拆解

先说清楚,给查询结果加序号,本质上是一个**“排序后的位置标记”过程**。这意味着序号不是凭空生成的,它必须依附于一个明确的、稳定的顺序。如果顺序本身不稳定,序号就没有任何意义,甚至会误导人。

1.1 两种典型场景,决定了两条完全不同的技术路线

把需求拆开来看,绝大多数加序号请求无非落在以下两种场景里:

场景一:展示型序号。比如导出的Excel表格需要带一列序号,或者前端列表页需要显示当前是第几条数据。这种场景的核心要求是:序号连续、从1开始、跟着查询结果的顺序走。一般来说,对性能要求不高,数据量也大多在可控范围内。

场景二:排名型序号。比如销售排行榜、成绩排名、积分排名等。这时候的序号本质是“名次”,它必须按照某个指标(业绩、分数、积分)从高到低排列,而且可能出现并列名次——两个人都是第2名,后面的人是从第4名开始还是继续按3、4、5排?这决定了不同的技术方案。

我见过不少初学者,一看到“加序号”就直接在应用层用一个循环变量去计数,然后拼接在结果集里。这个做法不能说错,但它有几个致命的问题:第一,如果查询本身没有明确的ORDER BY子句,MySQL返回结果的行序是不稳定的,你加上的序号就可能是“乱序的序号”;第二,一旦分页,应用层的计数变量很容易算错;第三,如果数据量达到几十万甚至上百万行,应用层逐个拼接的效率会非常难看。所以我的建议是:能在SQL层解决的,就不要拖到应用层。

1.2 最重要的一条原则:先排序,再编号,顺序永远不能乱

这是整个加序号操作中最核心的一条经验,没有之一。任何加序号的需求,第一步永远是先把查询结果按照业务要求的顺序排列出来,然后再在这个有序结果上编号。你绝对不能在业务排序之前就先计算序号,那样结果必乱。

举个实际例子。我第一次写分页查询时,直接写了这样一个查询:

SELECT * FROM products LIMIT 10;

然后在应用层循环,给每行数据加上1到10的序号。第一页没问题,但翻到第二页时,应用层计数器没有重置,序号变成了11到20。这其实还算是小问题,更大的问题是:这个查询没有ORDER BY,MySQL实际返回的行顺序可能跟插入顺序不一致,也可能随时间或数据量变化而变化。结果就是用户每次刷新页面,看到的数据顺序都不一样,序号也跟着串位。

正确的做法是:先在SQL里用ORDER BY把顺序定死,再在SQL层面完成编号,或者至少在使用窗口函数时把ORDER BY放进函数里。顺序是序号的生命线,这条原则必须刻在脑子里。

2. MySQL 8.0之后的推荐方案:ROW_NUMBER() 窗口函数

如果你用的MySQL版本是8.0或更高,那么恭喜你,你拥有了最优雅、最标准的解决方案。MySQL 8.0引入了窗口函数(Window Function),其中ROW_NUMBER()就是专门用来给查询结果生成连续序号的。

2.1 一行代码搞定全表统一编号

语法出奇地简单:

SELECT ROW_NUMBER() OVER (ORDER BY id ASC) AS row_no, name, department, salary FROM employees;

这个查询的含义非常直观:在employees表中,按照id从小到大排列,然后每一行依次标记1、2、3……作为row_no列返回。这里的ORDER BY不一定要是主键,也可以是任何你希望排序的字段,比如创建时间、销售额等。

这么写的好处到底在哪里?第一,不需要任何辅助变量,SQL解析器自己帮你维护“当前行号”,没有变量状态污染的隐患。第二,查询结果天然就是排序好的,你不需要在外层再包一层ORDER BY。第三,逻辑非常清晰——任何人都能从这段代码里一眼看出“这是一个按id排序并编号的查询”,可读性比后面要讲的变量方案好太多。

2.2 为什么窗口函数方案完胜变量方案

有人可能会问:既然MySQL 5.7时代的变量方案也能实现同样效果,为什么还要专门升到8.0用窗口函数?我总结过三个核心原因:

原因一:变量方案存在优化器与变量的诡异交互问题。当查询涉及子查询、JOIN时,MySQL的优化器可能会改变执行顺序,导致@rownum的计算结果不符合预期。这类问题排查起来极其痛苦,有时候你完全搞不懂为什么同样的SQL,数据量一变序号就乱了。

原因二:变量方案不便于嵌套使用。如果你想在加序号的结果上再筛选一次(比如取第11到20行),变量方案需要在外层再套一大层子查询,代码马上变得臃肿难读。窗口函数可以直接放在FROM子句的子查询里,清晰得多。

原因三:未来趋势决定迁移成本。虽然现在还有不少生产环境运行在5.7甚至更老的版本上,但数据库版本升级是迟早的事。新写的SQL如果用窗口函数,将来升级时完全不用改动。反之,老代码里的变量逻辑迟早要重写,早写晚写都是写,不如一步到位。

这里要特别说明一个实操细节:ROW_NUMBER()内部的ORDER BY决定了序号的生成顺序,但这个函数并不会保证外层查询返回结果的顺序一定按这个序号排列。如果你希望最终展示的顺序就是序号顺序,还需要在外层或者同一个查询中保持一致的ORDER BY。看下面这个例子:

SELECT ROW_NUMBER() OVER (ORDER BY created_at DESC) AS row_no, title, author FROM articles ORDER BY row_no ASC;

这是我踩过的一个坑。只写ROW_NUMBER()里面的ORDER BY,发现有时候MySQL返回的行序跟预期的序号顺序不完全一致,尤其在加上WHERE条件或JOIN之后。后来我习惯性地在外面再写一层ORDER BY row_no,一切才稳定下来。这里面的原理涉及优化器对结果集的处理方式,但你不必深究,只需要记住这个习惯即可。

3. MySQL 5.7及以下版本的兼容方案:用户变量的完整实践

现实很骨感。虽然MySQL 8.0发布已经很多年,但仍有大量存量项目跑在5.7甚至更老的版本上。我自己就接手过几个这样的系统——数据量不小、业务逻辑复杂、数据库版本老旧,升级数据库的工期排到了好几个季度之后。在这种局面下,该用什么方案加序号?答案是:用户变量。

3.1 用变量模拟行号的核心原理

用户变量方案的核心思想非常朴素:定义一个变量作为计数器,遍历结果集的每一行时,把计数器的值加1,然后把这个值作为序号输出。这就好比你在数排队的人数,每走过一个人,就在计数板上加一。

最基本的写法是:

SET @rownum = 0; SELECT @rownum := @rownum + 1 AS row_no, name, department, salary FROM employees ORDER BY salary DESC;

这个写法有两个关键动作:SET语句初始化计数器为0,SELECT语句里用@rownum := @rownum + 1实现每行自增。注意,这里用的是赋值运算符:=,而不是比较运算符=。在MySQL里,SELECT中给用户变量赋值的标准写法就是:=,这一点需要特别注意,很多初学者在这里翻了车——写成了=之后,序号永远都是1,百思不得其解。

还有一点必须强调:SET语句和SELECT语句必须分两次执行,不能合并成一条。你当然可以在FROM子句里用一个衍生表来初始化变量(后面会讲),但那属于进阶玩法。初学者阶段,规规矩矩写两条语句,逻辑完全可控。

3.2 分组序号、多表JOIN场景的变量方案

用户变量的强大之处在于,它不仅能做整个结果集的全局编号,还能做分组编号。什么叫分组编号?举个例子:查询每个部门的员工,要求每个部门内部从1开始编号,部门一换,编号重新计算。

这个需求的SQL写法是:

SET @rownum = 0; SET @current_dept = ''; SELECT department, @rownum := IF(@current_dept = department, @rownum + 1, 1) AS row_no, @current_dept := department AS dept_marker, name, salary FROM employees ORDER BY department, salary DESC;

这段代码的思路是:额外维护一个变量@current_dept,记录“上一个部门”。每取一行数据,先判断当前行的department和@current_dept是否一致。如果一致,说明还在同一部门内,序号继续累加;如果不一致,说明进了一个新部门,序号重新从1开始。判断完之后,再把@current_dept更新为当前行的部门名。

这里有个极其重要的执行顺序问题:MySQL对SELECT子句中表达式的求值顺序,并不保证是按照书写顺序从左到右进行的。但在这个特定写法里,@rownum的更新依赖@current_dept的值,而@current_dept的更新又依赖当前行的department,所以实际执行时可以看成逐行按顺序求值的。为了保险起见,我在实际项目中通常把赋值语句写得简洁一点,并且在表和别名之间做好注释,避免后续维护的人理解偏差。

多表JOIN场景就更加简单了,只要把JOIN之后的结果集先排好序,再在最外层套上变量编号即可:

SET @rownum = 0; SELECT @rownum := @rownum + 1 AS row_no, e.name, d.department_name FROM employees e JOIN departments d ON e.dept_id = d.id ORDER BY d.department_name, e.salary DESC;

你只需要关注一件事:JOIN、过滤、排序等操作都发生在编号之前。变量编号永远应该放在结果集已经基本定型之后。

3.3 必须警惕的排序结果不可靠问题

这是我见过最多人踩的坑,也是我要特别重点强调的。MySQL官方文档承认,用户变量的求值顺序在优化器眼中是不确定的——MySQL 5.7文档里明确提到“不要依赖用户变量的求值顺序”。实际上,同一个SQL在你的本机上可能运行正确,到了生产环境、换了数据分布或索引,结果就可能出错。

比较常见的出错场景有这些:

  • SELECT子句里同时存在多个变量赋值,且赋值顺序隐式关联,优化器可能做常量替换。
  • 使用了HAVING、GROUP BY、ORDER BY等子句时,变量赋值时机可能被打乱。
  • 查询中涉及子查询,且子查询中也用到用户变量,内外层变量相互干扰。

有人会问:那是不是变量方案完全不可用?我的看法是:模拟行号的简单场景,变量方案在数据量可控、排序条件明确的前提下,可以用,但必须经过充分验证。生产环境出现任何可疑现象时,第一时间把变量方案替换成其他写法。你要把变量方案当成一个“能用但随时可能踩坑的兼容手段”,而不是一个无懈可击的官方方案。

4. 各种排名场景的深度扩展:不止是加序号

前面几节主要解决的是“连续序号”的需求。但实际业务中,另一个高频需求是“排名”。排名和序号长得像,本质却不同:序号要求连续,排名允许并列,或者允许跳号。

4.1 ROW_NUMBER、RANK与DENSE_RANK的选型对比

MySQL 8.0除了ROW_NUMBER()之外,还提供了RANK()和DENSE_RANK()两个窗口函数。它们之间的区别只需一张表就能说清楚:

场景ROW_NUMBER()RANK()DENSE_RANK()
分数序列90, 90, 85, 8090, 90, 85, 8090, 90, 85, 80
序号结果1, 2, 3, 41, 1, 3, 41, 1, 2, 3
序号是否连续始终连续并列后跳号并列后不跳号

具体解释一下:RANK()遇到并列时会跳过下一个序号——两个第1名之后,下一个人是第3名,因为第2名不存在。DENSE_RANK()不跳号——两个第1名之后,下一个人直接排第2名。

这三者的选用完全取决于业务语义。比如赛事排名,通常用RANK(),因为并列第一之后没有第二名是体育界的常识。而员工工资排名,如果用RANK(),很可能出现一群人挤在同一个名次,后面全是空洞,这时反而用DENSE_RANK()更自然。但如果只是给列表标号,不涉及并列语义,ROW_NUMBER()永远是最稳的选择。

4.2 实战案例:按部门薪水排行并保留部门独立编号

接下来写一个综合案例,这个案例几乎覆盖了日常工作里会遇到的绝大多数加序号场景。需求如下:查询所有员工信息,要求输出一个结果集,包含四列:总序号、部门内序号、员工姓名、工资。而且总序号要求全局连续,部门内序号要求每个部门独立从1开始。

这个需求在MySQL 8.0里用两个窗口函数一次搞定:

SELECT ROW_NUMBER() OVER (ORDER BY created_at ASC) AS global_row, ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_row, name, department, salary FROM employees ORDER BY department, salary DESC;

解释一下这个查询里的关键点:global_row列使用ROW_NUMBER()加全局ORDER BY,生成的就是全局连续序号。dept_row列使用PARTITION BY department将结果按部门切分成多个“窗口”,每个窗口内部再按salary递减排列,并独立编号。这就是“部门内序号”的由来。

如果你用的是MySQL 5.7,没有窗口函数,必须用用户变量来实现同样的分组编号效果,代码就会长一些:

SET @global_no = 0; SET @dept_no = 0; SET @prev_dept = NULL; SELECT @global_no := @global_no + 1 AS global_row, @dept_no := IF(@prev_dept = department, @dept_no + 1, 1) AS dept_row, name, department, salary FROM ( SELECT name, department, salary FROM employees ORDER BY department, salary DESC ) AS sorted_emp ORDER BY department, salary DESC;

注意这里我用了内层子查询先把数据排好序,这样外层遍历时department和salary的顺序就已经是确定的,再做变量判断时才有稳定的基础。不加这层排序的子查询,直接在外层裸写变量逻辑,大概率会跑出错误的序号。这个经验是我实际排查了无数次问题之后总结出来的。

4.3 分页场景下的序号连续性问题

最后讨论一个高频Bug重灾区:分页情况下加序号。

见过太多人这样写:

SELECT ROW_NUMBER() OVER (ORDER BY id) AS row_no, title, content FROM posts LIMIT 20 OFFSET 40;

这个查询本身没有任何问题,结果集确实会从第41行开始,row_no也会从1排到20。但问题在于:用户看到的列表,每一页都是从1开始编号的。如果产品经理要求的是“上一页结束是40,这一页要从41开始”,那这个写法就不满足需求了。

正确的做法是,把每一页的起始序号加到查询结果里去。在SQL层面,可以用一个前置偏移量:

SELECT ROW_NUMBER() OVER (ORDER BY id) + 40 AS row_no, title, content FROM posts LIMIT 20 OFFSET 40;

这里的“40”就是OFFSET的偏移量。把偏移量加到ROW_NUMBER()的结果上,这一页的第一条数据就会显示为41。注意这个40是动态变化的,在应用层传入时,一定要保证它是整数且经过类型校验,否则会出现字符串拼接导致序号异常。

5. 常见问题与排查技巧实录

加序号这个需求看似简单,但实际运维中遇到的问题五花八门。我把自己多次排查积累下来的经验整理成几个有代表性的问题,供各位直接对照参考。

问题现象一:变量方案的序号出现跳动或错乱,而且只在数据量大时出现。

排查思路:优先确认查询中的ORDER BY是否真正生效。用户变量方案最大的命门就是“排序与变量赋值之间的竞争条件”。解决方案有两个:一是将排序操作放进子查询,让外层查询只负责编号;二是升级到MySQL 8.0使用窗口函数。我强烈建议在数据量超过10万行的场景,不要再使用变量方案。

问题现象二:执行几次之后,序号不是从1开始,而是从上次结束的位置继续。

这是用户变量的“会话级持久性”造成的。MySQL的变量是会话级别的,同一个连接里如果你之前执行过SET @rownum=0,但没执行成功,或者变量值被其他语句修改过,那么下一次查询直接从旧值继续累加。解决方案:在查询前无条件执行一次SET @rownum = 0。如果你用的是连接池,务必确认每次获取连接后变量状态是干净的。

问题现象三:使用了UNION或子查询之后,序号计算不正确。

UNION操作会把两部分结果拼接在一起,变量的计算逻辑会被打断。建议:UNION之前先各自排序,UNION之后再包一层外层查询统一编号。比如:

SET @rownum = 0; SELECT @rownum := @rownum + 1 AS row_no, a.name, a.score FROM ( SELECT name, score FROM table_a UNION ALL SELECT name, score FROM table_b ) AS a ORDER BY a.score DESC;

问题现象四:窗口函数运行速度比预期慢。

ROW_NUMBER()虽然语法简洁,但它在执行时需要在内存或者临时表里维护窗口分区的状态。如果PARTITION BY的字段基数非常高(比如按一个几乎每行都不同的列分区),性能开销会明显上升。优化手段包括:确保PARTITION BY和ORDER BY涉及的字段有合适的索引;缩小结果集范围;如果分区列是冗余的,可以先去重再计算。还有一种办法是,把大表拆成若干个小批次,分批计算后汇总。

问题现象五:NULL值参与排序时,序号和预期不一致。

MySQL里,NULL默认按最小值参与排序——在ASC排序时NULL排在最前,在DESC排序时NULL排在最后。如果你部门内排序时某员工的salary为NULL,他的序号可能会跑到最前面,引发业务方的质疑。解决方案是使用ORDER BY salary IS NULL, salary DESC,把NULL强制排到最后再参与编号:

SELECT ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary IS NULL, salary DESC) AS dept_row, name, department, salary FROM employees;

这类NULL排序问题,在报表场景里几乎必现,处理起来虽然简单,但如果不知道这个语法,排查起来可能要好几个小时。

6. 加序号操作的前置准备与执行建议

写SQL之前先想清楚下面几个问题,能帮你省掉很多返工时间。

第一,确认你的MySQL版本。这决定了你能不能用窗口函数。执行SELECT VERSION();就可以查看。版本高于或等于8.0,优先走窗口函数路线。低于8.0,老老实实走用户变量路线,并且提前告知业务方这套逻辑有迁移风险。版本低于5.5的极端情况,建议直接推动应用层方案或者升级数据库,因为变量方案在5.1、5.5时代的执行计划更加不可控。

第二,确认你的业务排序字段是否稳定。所谓稳定,指的是这个字段的值不会频繁变化、不会出现大量重复。比如你按“创建时间”排序,如果同一秒内创建了多行数据,排序结果里谁先谁后就无法保证。这种情况下,建议排序字段带上主键作为二级排序条件,比如ORDER BY created_at DESC, id DESC。这么做的好处是:即便时间戳完全相同,主键也能保证一个确定性的先后顺序,从而保证序号的稳定。

第三,控制结果集返回量。给一百万行查询结果加序号,然后全部返回给前端,这件事本身在绝大多数情况下就是一个设计错误。考虑是否需要在SQL端分页、聚合或过滤。加序号本身不是一个昂贵的操作,但把百万行级别的数据集通过网络传输到应用层,才是真正的性能瓶颈。

第四,添加索引的策略。如果查询中频繁使用窗口函数的分区列和排序列,建议为这些列建立联合索引。比如上文那个按部门加薪水分区序号的查询,可以考虑建立(department, salary DESC)的联合索引。MySQL虽然不能完全避免窗口函数引起的临时表操作,但合适的索引能显著减少排序阶段的开销。

实际操作中,我一般会在本地搭建一个和生产环境版本一致的测试库,预先准备几组典型数据(包括NULL值、重复值、大批量数据),把计划执行的SQL全部跑一遍,验证序号结果是否符合预期。这个习惯帮我避免了至少五次生产事故。

7. 从序号需求延伸出的几个思考

回过头来看,加序号只是一道开胃菜,它背后真正考验的是对SQL执行逻辑和数据库版本特性的理解程度。

窗口函数这套玩法其实是SQL标准里的通用能力,不限于MySQL。Oracle、PostgreSQL、SQL Server都有对应的实现。你今天在MySQL 8.0里学会了ROW_NUMBER(),将来切换到其他数据库,语法几乎是通用的,迁移成本极低。反过来,如果长期停留在用户变量这种“土办法”里,一旦换数据库或者升级版本,所有代码都要推倒重来。

我个人的体会是:数据库领域,尽量靠近标准能力,少依赖特殊写法。用户变量在MySQL里是一个历史遗留的特殊工具,它确实解决了一些问题,但它不是一种值得长期依赖的编程方式。能升级版本就升级版本,能用窗口函数就用窗口函数。如果你的团队还在维护大量基于变量的老SQL,找一个业务低峰窗口分批迁移,把核心查询逐步改写成窗口函数方案,你会发现后续维护的轻松程度是几何级别上升的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进