MySQL给查询结果加序号的三种方案:窗口函数、用户变量与子查询

📅 发布时间:2026/10/20 5:43:16
MySQL给查询结果加序号的三种方案:窗口函数、用户变量与子查询
有没有遇到过这种场景用 MySQL 查订单数据报表工具需要一列从 1 开始的序号你翻了半天文档发现 SELECT 里居然没有现成的行号概念。刚搞开发那会儿我为这事没少折腾后来才弄明白给查出的数据加序号在 MySQL 里至少有三条路可走而且每条路的适用场景、性能和坑完全不一样。这篇就围绕如何给查询结果加序号这个高频需求把窗口函数、用户变量、子查询计数这几种主流方案一次讲透顺便把分组编号、分页连续序号、并列排名这些衍生场景也整理清楚。不管你是还在接手 5.7 老项目、被兼容写法折腾的开发者还是已经全面拥抱 8.0、单纯想找更优雅写法的朋友这篇文章都能给你一份可以直接抄作业的答案。1. 先搞清楚你到底需要哪种序号1.1 序号需求其实分三类先别急着写 SQL不同场景面对的序号含义完全不一样。第一类是物理行号纯粹给结果集按顺序标注 1、2、3常用于数据核对、导入导出、生成 Excel 报表。这种需求最直接你要的就是第几行。第二类是分组内的顺序号。比如每个班级的学生按成绩排名次每个订单明细按金额排序你要的不是全表的行号而是每个分组内部重新从 1 开始的顺序号。这类需求的关键词是分组和排序。第三类是排名。它看上去和序号一样但有并列的处理规则。两个学生同分是都排第 3还是其中一个排第 4选择不同的函数业务结论可能完全不同。这三类需求涉及的函数和方法并不同如果只记住一种写法很容易在换场景后出错。所以我建议任何加序号的需求动笔之前先问自己一句我要的是全表行号、组内编号还是带并列逻辑的排名答案不同方案就不同。1.2 为什么 MySQL 老版本里加序号会变成一个问题MySQL 5.7 以及更早的版本主流关系型数据库基本都有行号函数唯独 MySQL 没有内置的 ROW_NUMBER这就逼着大家用各种土办法。我见过不少老项目的代码里用rownum : rownum 1这种用户变量写法。写得没问题倒还好一旦排序字段、变量初始化顺序出了偏差结果就会变得非常诡异第一行序号不是 1、序号跳号、分组错乱什么怪现象都有。MySQL 8.0 引入窗口函数以后这个问题才算真正从语法层面解决。但直到今天依然有大量生产库跑在 5.7 上所以兼容写法并没有过时两边都得会。这也是为什么我在下面会把每种方案都讲一遍而不是只推其中一种。2. 三种主流实现方案拆解2.1 首选方案ROW_NUMBER() 窗口函数MySQL 8.0 起ROW_NUMBER()是给查询结果加序号最直接的方式。基本语法SELECT ROW_NUMBER() OVER (ORDER BY order_amount DESC, order_id) AS seq, order_id, customer_name, order_amount FROM orders;这段 SQL 的意思是先按金额倒序、订单 ID 正序排序然后在这个结果上生成从 1 开始连续递增的序号。为什么用ORDER BY order_amount DESC, order_id而不只按金额因为金额可能有重复如果并列的两行之间没有额外的唯一字段锁定先后ROW_NUMBER()给哪一行哪个编号结果就不稳定。加上 order_id 作为次级排序可以让序号完全确定后续测试、排查都方便。这是一个很多人忽略但非常实用的习惯。窗口函数的优势在于声明式语法你只管表达我要什么逻辑——按什么排序、怎么分组——不用关心 MySQL 内部怎么实现也不存在会话变量残留的风险。生产环境 8.0 上我遇到加序号的需求默认都用这个方案写除非有特殊原因。要注意的是ROW_NUMBER()依赖ORDER BY做排序如果查询的数据量很大又没法走索引排序成本会体现出来。这个问题到第 4 章再展开。2.2 兼容方案用户变量模拟行号5.7 及更早版本的环境最耳熟能详的是用户变量写法。两段式执行先初始化变量再查询赋值SET rownum : 0; SELECT rownum : rownum 1 AS seq, order_id, customer_name, order_amount FROM orders ORDER BY order_amount DESC, order_id;这里有个极容易踩的坑必须在同一会话里先执行SET rownum : 0再执行查询。如果你在数据库管理工具里新建了一个标签页直接跑查询或者上一次查询已经把这个变量增加到了 1000这次就不会从 1 开始。更隐蔽的问题出在先排序还是先赋值上。上面的 SQL 看起来是先按ORDER BY排序再赋值但 MySQL 对用户变量的求值顺序并没有严格保证。也就是说在某些执行计划下可能会出现变量先增加、然后才排序结果序号恰好是原始表的插入顺序而不是排序后的顺序。为了稳妥推荐的做法是先用子查询把排序做掉再在结果上编号SET rownum : 0; SELECT rownum : rownum 1 AS seq, order_id, customer_name, order_amount FROM ( SELECT order_id, customer_name, order_amount FROM orders ORDER BY order_amount DESC, order_id ) t;虽然多套了一层但保证了排序先完成、编号再执行最终结果一定是我们想要的。写变量法加序号我建议一律用这个带派生表的结构省掉执行计划差异带来的不确定性。2.3 兜底方案关联子查询计数如果既没有窗口函数又因为某些原因没法用用户变量还有一个思路计算当前行之前有多少符合条件的行把数量当成序号。最直观的写法是每行执行一次带条件的 COUNTSELECT o1.order_id, o1.order_amount, ( SELECT COUNT(*) FROM orders o2 WHERE o2.order_amount o1.order_amount OR (o2.order_amount o1.order_amount AND o2.order_id o1.order_id) ) AS seq FROM orders o1 ORDER BY o1.order_amount DESC, o1.order_id;这个查询的排序逻辑和序号逻辑是手工对齐的先比金额金额相同再比订单 IDCOUNT 出来的数字就是序号。因为可以完整描述排序规则所以结果也稳定。但这东西性能是真的差——每一行都要对整表做一次 COUNT整体复杂度约等于 O(n^2)。小数据量比如几百行临时核对一下没问题上万行之后会明显卡顿。我在内部系统用 1 万行订单数据测过窗口函数几十毫秒能出的结果这个方案跑了差不多 8 秒。只适合做兜底或者在一些不支持窗口函数、也不支持变量的特殊环境里应急。3. 进阶场景怎么做3.1 分组内独立编号业务上最常见的场景是每个组内从 1 开始编号。比如一个班级成绩表要给每个学生的成绩在班内排名次。窗口函数写法非常舒服SELECT class_id, student_name, score, ROW_NUMBER() OVER (PARTITION BY class_id ORDER BY score DESC) AS rn FROM student_scores ORDER BY class_id, score DESC;PARTITION BY class_id把班级分开每个班内再按分数降序生成 1、2、3……这就是窗口函数最典型的价值分组内排序编号一行代码搞定。5.7 环境得靠两个用户变量配合先初始化再在 SELECT 里判断SET rn : 0, prev_class : NULL; SELECT class_id, student_name, score, rn : IF(prev_class class_id, rn 1, 1) AS rn, prev_class : class_id AS prev_class FROM ( SELECT class_id, student_name, score FROM student_scores ORDER BY class_id, score DESC ) t;这里有两个细节要是搞反了结果一定是错的。一是 ORDER BY 必须用class_id, score DESC同一个班级的行要先聚在一起并排好序才能正确判断当前行和上一行是否同班从而决定序号是继续累加还是重置为 1。二是rn : ...的赋值必须写在prev_class : class_id之前。如果先更新班级变量再判断序号同一组的第一行会拿到一个错误的序号因为判断时prev_class已经被改成了当前班级。这个顺序性问题非常隐蔽但实际遇到过一次就会长记性。我建议真要写变量法分组编号每次都在 SQL 前面注释里标明变量赋值顺序免得几个月后再看自己都忘了。3.2 分页场景如何保持序号连续分页加序号有两种常见处理法性能差别很大。第一种是先编号再取分页SELECT * FROM ( SELECT ROW_NUMBER() OVER (ORDER BY order_amount DESC, order_id) AS seq, order_id, order_amount FROM orders ) t WHERE seq BETWEEN 11 AND 20;这种写法逻辑没问题但问题在于它为了给任意一页标号要把所有的行都先排序、编号即使你只需要第 2 页的 10 行。数据量一大排序成本就上去了。第二种是我更推荐的先 LIMIT 再补序。当你只需要某一页数据时先把这页取出来再在应用层或前端根据当前页码计算序号SELECT order_id, order_amount FROM orders ORDER BY order_amount DESC, order_id LIMIT 10 OFFSET 10;然后在前端根据当前页码和每页条数计算(page - 1) * size 行号。因为排序和偏移都发生在 LIMIT 之前只对最终那一小部分数据做处理性能好很多。如果业务一定要求 SQL 返回带序号的字段那就用 ROW_NUMBER 包一层但注意外层尽量保持 ORDER BY 一致避免 MySQL 对派生表做优化时改变顺序。在 8.0.14 以上窗口函数和 LIMIT 的优化更完善可以直接配合使用。这个先 LIMIT 再补序号的思路很多报表系统里的导出功能就是这么做的导全部数据时才全量编号导当前页时算好偏移。建议读者根据自己的场景选择合适的策略不要盲目套。3.3 并列时怎么办ROW_NUMBER、RANK、DENSE_RANK有些场景同分的人应该并列。比如奖学金评选同分的人应该共享名次但下一个名次是否跳过取决于业务规则。三个函数的差异函数行为典型用例ROW_NUMBER()不并列序号一定连续物理行号、分页RANK()并列后跳号竞赛排名1,2,2,4DENSE_RANK()并列后不跳号绩效等级1,2,2,3示例SELECT course_id, student_name, score, ROW_NUMBER() OVER (PARTITION BY course_id ORDER BY score DESC) AS row_num, RANK() OVER (PARTITION BY course_id ORDER BY score DESC) AS rank_no, DENSE_RANK() OVER (PARTITION BY course_id ORDER BY score DESC) AS dense_no FROM course_scores;平时加序号用ROW_NUMBER()就够了但如果你发现有两个 98 分老板却希望并列第 1 而不是第 2那就换成DENSE_RANK()。搞清楚这三个函数的差异能让你的 SQL 在排序类需求里覆盖面广很多。4. 方案选型、性能对比与避坑清单4.1 到底选哪个方案一张表说清楚我一般用四个维度判断数据库版本、数据量、是否分组、排序复杂度。选择逻辑整理成一张表场景推荐方案理由MySQL 8.0 普通加序号ROW_NUMBER()语法直观性能稳定MySQL 8.0 分组编号ROW_NUMBER() PARTITION BY一行完成分组排序编号MySQL 5.7 / 老系统用户变量 派生表兼容性最好结果可控数据量极小且无变量可用关联子查询 COUNT逻辑清晰但性能差只需要某一页的序号先 LIMIT 再应用层补偏移避免全量排序我自己在实际项目里只要是 8.0 就闭眼用窗口函数5.7 就直接上派生表 变量。唯一会纠结的是数据量超过百万、排序字段又不能走索引时此时无论哪种方案都会吃排序成本需要从索引和查询结构层面解决而不是换一个加序号函数。关于性能我之前测过一张 20 万行左右的订单表。ROW_NUMBER()按金额倒序大约 150ms 左右用户变量挂派生表大约 160ms两者差别不大。关联子查询直接慢到 5s 以上。这说明在合理数据量下窗口函数和变量法性能接近最主要的差距来自子查询计数这种 O(n^2) 方案。4.2 用户变量法的四个经典坑变量法能完成任务但坑是真的多。我把遇过的、听过的、能想到的都列一遍你对照着避雷。第一个坑忘记初始化变量。会话变量有记忆上一次跑到 8000下一次再执行就直接累加。解决方法是每次执行前SET一下或者在 SELECT 里写成(SELECT rownum : 0) r, ...这种内联初始化方式。第二个坑变量赋值和 ORDER BY 的顺序不确定。大多数资料推荐先排序后变量法的写法但其实不保险稳妥做法就是上面用的派生表套一层让 MySQL 先物化排序结果再统一赋值。第三个坑多条记录同时赋值时的求值顺序。分组编号里先更新rn再更新prev_class顺序写反就全错。这个只能靠代码本身保证没有捷径。第四个坑视图和存储过程里的限制。视图里不能使用用户变量执行会直接报错存储过程里虽然可以用但参数的初始化位置、返回结果集都会受变量残留影响容易出状态问题。所以视图里要么用窗口函数要么用关联子查询。4.3 8.0 里也别忽略索引与排序不管是窗口函数还是变量法加序号的前提都是排序。排序能否走索引直接决定查询耗时。如果 ORDER BY 的字段上建有合适的索引MySQL 可以顺序扫描直接输出有序结果排序成本接近 0。比如订单表经常按创建时间去重查询那就建idx_create_time如果固定按金额排名可以建联合索引idx_amount_id (amount, id)这样排序和唯一性都覆盖到。没有合适索引时MySQL 会生成临时排序文件。数据量一大这个文件要写磁盘后边的窗口函数计算也只能跟在后面等整个接口的响应时间就被拉长了。所以排查加序号查询慢的时候第一件事不是怀疑窗口函数而是看执行计划里有没有Using filesort再看排序字段能不能利用索引。5. 常见问题速查与自查模板5.1 高频问题排查表问题现象可能原因处理方法序号不是从 1 开始会话变量残留执行前先 SET 初始化分页后序号不连续先编号后 LIMIT 或前端重渲染应用层根据页码补偏移分组编号错乱ORDER BY 字段顺序不对确认 ORDER BY 含分组字段和排序字段同分组第一行序号错误变量赋值顺序写反先更新 rn 再更新分组变量视图里加序号报错用户变量不允许在视图中使用改用 ROW_NUMBER 或子查询查询突然变慢排序未走索引产生 filesort检查执行计划优化索引序号不稳定多次执行结果不同没有明确的 ORDER BY补上唯一字段做次级排序这张表基本覆盖了日常开发里会遇到的大部分情况。后面还有两类零散但很常用的小技巧。5.2 几个可以直接照抄的小技巧序号从 1000 开始直接加偏移SELECT ROW_NUMBER() OVER (ORDER BY order_id) 1000 AS seq, ...需要导出 CSV 时序号列可以用CONCAT(, seq, )包装一层避免 Excel 把纯数字列当科学计数法。这个在报表导出时特别实用。如果查询里已经有 WHERE 条件要先做过滤再编号。窗口函数天然支持这一点因为它是在 WHERE 执行之后、SELECT 阶段计算的SELECT ROW_NUMBER() OVER (ORDER BY create_time DESC) AS seq, order_id FROM orders WHERE status PAID;如果是变量法也要注意把过滤条件放在内部子查询里外部只用来编号否则执行顺序一旦不稳定不符合条件的行也可能被计入序号。这里的规则是窗口函数是标准功能执行顺序由 SQL 标准保证变量法就必须自己控制。5.3 排查问题时的标准操作顺序遇到加序号的结果不对我习惯按这个顺序排查基本能定位 90% 的问题。先看版本8.0 优先用窗口函数5.7 检查是不是变量法。再看排序把 ORDER BY 单独拿出来执行一遍确认排序结果本身符合预期。排序错了序号一定跟着错。然后看变量初始化了吗赋值顺序对吗派生表结构对吗最后看执行计划有没有 filesort临时表大小必要时用 EXPLAIN ANALYZE 看真实耗时。这套排查逻辑其实就是把加序号问题分解成排序正确性和编号连续性两个子问题逐个击破。很多看起来玄乎的序号错乱最后基本都是排序期望不一致导致的。说实话给查询结果加序号这个需求单看很难写起来也很简单但真正的价值在于你理解这几种方案背后的排序、版本和执行计划逻辑。我在 8.0 普及之后已经很少写变量法了但每次在 5.7 的老库里碰到报错还是会庆幸自己以前踩过这些坑。如果你刚接手一个老系统从加序号这种小需求开始把版本差异、排序逻辑摸一遍会比背十个写法都管用。最后再补一个实用习惯写 SQL 时永远给排序字段补一个唯一字段做次级排序。这个习惯不只在加序号时有用分页、去重、查最新一条全都会受益。