在不支持窗口函数的 MySQL 版本中,生成行号是一件看似简单却容易踩坑的事情。如果直接在单条 SELECT 里使用用户变量对每一行赋值,有时能得到正确结果,有时却会因为 MySQL 优化器对表达式求值顺序的不确定而产生错乱。解决这个问题的可靠做法是引入嵌套查询:先在内层查询中完成排序,确保数据到达外层时顺序固定,再在外层使用变量进行行号累加。

本文的目标读者是需要在 MySQL 5.7 或更早版本中使用行号功能的开发者,或者是需要在兼容旧语法的环境中维护代码的人。下面将从基本排序行号开始,逐步讨论分组行号、变量初始化的细节以及可能遇到的性能问题。
单列排序下的行号生成
假设有一张员工表 employees,包含 id、name、salary 三个字段,希望按照 salary 从高到低生成排名序号。最直接的想法可能是这样写:
SELECT
@row_no := @row_no + 1 AS row_num,
id, name, salary
FROM employees, (SELECT @row_no := 0) AS init
ORDER BY salary DESC;
这段代码在 MySQL 5.7 的很多执行计划下确实能返回正确结果,但它依赖了“ORDER BY 在 SELECT 列表之后求值”这一假设。实际上,MySQL 对用户变量的赋值时机并没有严格保证,尤其是在涉及临时表排序、索引排序或者合并视图时,变量可能被过早或过晚计算。因此,更稳妥的方式是把排序放进子查询,让外层只做变量累加:
SELECT
@row_no := @row_no + 1 AS row_num,
t.id, t.name, t.salary
FROM
(SELECT id, name, salary FROM employees ORDER BY salary DESC) AS t,
(SELECT @row_no := 0) AS init;
内层查询一定会先执行,并生成一个有序的虚拟表,外层再从这个已经排好序的结果集中逐行读取。由于外层查询不再包含 ORDER BY,变量赋值的顺序严格遵循内层输出的物理顺序,行号的正确性就有了保障。这种嵌套查询的结构也是很多生产环境中模拟 ROW_NUMBER 的标准写法。
需要注意的是,子查询的结果集顺序在理论上并没有被 SQL 标准承诺,MySQL 也保留在将来版本改变内部实现的权利。不过在实际使用中,只要内层查询包含 ORDER BY,并且外层没有再次排序,MySQL 通常会保留这个顺序返回给客户端。对于需要跨版本稳定性的场景,可以在外层也加上相同的 ORDER BY 作为兜底,虽然这会增加一次排序开销,但能进一步降低结果集顺序被破坏的风险。
使用变量实现分组行号
仅仅生成全局行号还不够,很多业务需要按某个字段分组,在每个组内独立编号。例如按照部门编号 dept_id 分组,组内按工资排序,生成每个部门内部的排名。这种需求在 MySQL 8.0 中对应 ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC),而在旧版本中需要用两个变量配合完成。
基本思路是记录上一行的分组字段值,当当前行与上一行属于同一分组时,行号加一;当分组发生变化时,行号重置为 1。同时还需要一个变量保存当前分组的标识。内层查询必须按照分组字段排序,再按组内排序字段排序,否则分组的连续性会被破坏。完整 SQL 如下:
SELECT
@row_no := IF(@prev_dept = t.dept_id, @row_no + 1, 1) AS row_num,
@prev_dept := t.dept_id AS dept_id,
t.id, t.name, t.salary
FROM
(SELECT id, name, dept_id, salary
FROM employees
ORDER BY dept_id, salary DESC) AS t,
(SELECT @row_no := 0, @prev_dept := NULL) AS init;
这里的关键点有三个:第一,内层查询必须包含 ORDER BY dept_id, salary DESC,先按分组字段排序,再按组内排序字段排序;第二,外层 SELECT 列表中先计算 @row_no,再更新 @prev_dept,利用 MySQL 从左到右的表达式求值顺序,确保行号判断使用的是上一行的部门值;第三,变量的初始值分别设为 0 和 NULL,如果 dept_id 可能为 NULL,需要额外注意 NULL 比较行为,建议将 @prev_dept 初始化为一个不可能出现的值,或者使用 <=> 安全等于操作符。
这种分组行号的写法比单纯全局行号更容易出错,尤其是列的顺序。如果把 @prev_dept := t.dept_id 写在 @row_no 之前,或者把两个变量赋值调换位置,就会导致行号判断错误。有些开发者在写这类查询时习惯在 SELECT 列表末尾统一更新变量,但那样往往得不到期望的结果,因为 MySQL 并不保证 SELECT 列表中不同表达式对同一行数据的求值顺序,只有依赖从左到右的顺序才是当前版本下普遍有效的做法。
分区排序中的额外处理与性能考量
当分组字段和排序字段之间存在索引关系时,MySQL 可能通过索引直接返回有序数据,此时变量赋值的行为相对稳定。但如果数据量很大,内层查询的排序需要用到临时表和文件排序,那么外层变量赋值的顺序依然取决于内层的输出。MySQL 的临时表排序结果在返回给外层之前会存储在临时文件中,然后顺序读取,因此变量赋值仍然按照内层排序后的顺序进行,这通常没有问题。需要注意的是,有时候查询优化器会合并外层和内层查询,导致变量赋值顺序被打乱,此时可以通过给内层查询增加 LIMIT 或派生表物化提示来避免合并。
从性能角度看,变量模拟行号的方式比 MySQL 8.0 内置的窗口函数要差一些,主要原因是它依赖了查询执行时的物理行序,而窗口函数是在逻辑层面定义的,优化器可以更自由地选择执行计划。此外,变量方式必须在查询中引入额外的笛卡尔积初始化派生表,虽然这个派生表只有一行不会产生实际代价,但也增加了查询的复杂度。对于需要同时生成多个不同排序规则的行号时,变量方法需要写多个嵌套查询或者关联子查询,远不如窗口函数清晰高效。
另一个容易忽视的问题是结果集的最终顺序。外层查询没有 ORDER BY 时,MySQL 返回行的顺序通常与内层输出的顺序一致,但并不被官方文档保证。如果客户端代码依赖行号与某排序字段的对应关系,建议在最外层显式加上相同的 ORDER BY,虽然会多一次排序,但能让结果确定下来。对于大数据量场景,如果外层已经按相同字段排序,MySQL 可能会识别出来并消除重复排序,实际代价不一定很高。
最后需要提醒的是,MySQL 从 8.0 开始已经原生支持 ROW_NUMBER、RANK、DENSE_RANK 等窗口函数,除非环境无法升级,否则强烈建议直接使用原生窗口函数。本文提供的变量模拟方法主要面向遗留系统维护、临时数据加工或者需要在低版本中快速实现行号的场景。
MySQL窗口函数ROW_NUMBER修改时间:2026-10-03 23:40:50