
1. 从一个真实需求说起为什么我们会想念 RowNum刚接触 MySQL 那几年我从其他数据库体系转过来第一个让我愣住的点就是它没有ROW_NUMBER这种伪列可以直接写在WHERE里。在别的库里想取第 11 到第 20 条记录一句WHERE ROWNUM BETWEEN 11 AND 20就完事了想删掉重复数据里靠后的那几条也是顺手拈来。到了 MySQL这套写法直接报错很多人第一反应是“这数据库怎么连行号都没有”。其实 MySQL 不是没有行号能力而是它的设计哲学不一样。MySQL 把行号这件事交给了窗口函数8.0 之后或者用户变量8.0 之前来实现。理解这一点很关键因为它决定了你后面所有“按行查询”“按行删除”的写法。这篇内容就是把我这些年踩过的坑、验证过的方案整理出来围绕三个核心场景展开按行号区间查询、按行号删除指定范围、按行号去重保留一条。不管你是刚学 MySQL 的新手还是从别的数据库迁移过来的老手都能直接抄作业。先说清楚适用人群和前置条件。如果你用的是 MySQL 8.0 及以上恭喜你窗口函数ROW_NUMBER()可以直接用写法最干净。如果你还在 5.7 甚至更早的版本那就得靠用户变量模拟虽然啰嗦但一样能跑。下面我会把两套方案都讲透并且告诉你什么场景该选哪套。另外要提醒一句按行号操作本质上是在一个“不确定顺序”的集合上强行加顺序所以排序字段的选择直接决定了结果对不对这一点我会反复强调。2. 核心思路拆解MySQL 里“行号”到底怎么来的2.1 为什么 MySQL 不直接提供 RowNum 伪列要理解怎么写先得理解为什么。MySQL 的定位一直偏向轻量、高并发、面向 Web 场景早期版本刻意避免了很多“看起来方便但性能不可控”的特性。行号伪列就是其中之一如果数据库内置一个ROWNUM那它在WHERE阶段的求值顺序、和索引的配合、和ORDER BY的先后关系都会变得非常微妙稍不注意就会全表扫描。MySQL 选择把这件事交给用户显式表达你写清楚排序和编号逻辑它才给你结果。这个设计带来的直接后果是任何行号都必须依附于一个明确的ORDER BY。没有排序行号就是随机的今天查是第 5 条明天可能就变成第 8 条。我见过太多人写了个变量方案却没写ORDER BY然后跑来问“为什么删除的数据每次都不一样”。答案就在这里。2.2 两套主流方案窗口函数 vs 用户变量目前业界就两套成熟方案我做个对照你一眼就能看出该用哪个。对比维度窗口函数 ROW_NUMBER()用户变量 rownum最低版本MySQL 8.0全版本可用写法复杂度低标准 SQL中依赖变量赋值顺序执行计划稳定性高优化器可控低易受查询改写影响排序保证显式 ORDER BY 即可必须配合子查询固化顺序推荐场景新项目、8.0 环境老版本兼容、临时脚本选型逻辑很简单能用窗口函数就别用变量。变量方案最大的坑在于 MySQL 不保证SELECT列表中表达式的求值顺序虽然实践中大多数情况按书写顺序执行但官方文档明确说这是“未定义行为”。也就是说你的变量方案可能今天对、明天错升级个小版本就翻车。窗口函数是标准语法优化器专门为它做了处理稳定得多。2.3 一个必须记住的前提排序字段决定一切不管你用哪套方案行号的语义完全由ORDER BY决定。举个生活化的类比行号就像给一队人排队编号你得先规定按什么排——按身高、按年龄还是按到场时间。如果你说“随便排一下然后给我第 3 个人”那每次结果都可能不同。所以实操中我建议排序字段尽量选唯一键或近似唯一的字段组合。如果只按一个会重复的字段排序比如按状态排序那相同状态的行之间顺序是不确定的行号会漂移。稳妥做法是ORDER BY 主键或者ORDER BY 业务字段, 主键用主键兜底保证唯一性。这一点在后面每个方案里都会体现。3. 按行号区间查询取第 N 到第 M 条3.1 窗口函数方案8.0 首选这是最干净的写法。核心是把ROW_NUMBER()放在子查询里算出编号外层再按编号过滤。SELECT * FROM ( SELECT id, name, created_at, ROW_NUMBER() OVER (ORDER BY id) AS rn FROM orders ) AS t WHERE t.rn BETWEEN 11 AND 20;这段 SQL 的逻辑分两层内层给orders表按id排序并打上行号rn外层筛选行号在 11 到 20 之间的记录。注意OVER (ORDER BY id)里的排序字段就是行号的依据换成created_at就是按创建时间编号。这里有个性能细节值得说子查询里的排序如果走不了索引会触发 filesort。如果id是主键排序天然有序几乎零成本如果按created_at排序而该字段没索引数据量大时就会慢。我的经验是分页查询的排序字段尽量建索引或者直接用主键排序。3.2 用户变量方案5.7 及以下老版本没有窗口函数只能用变量自增模拟。写法如下SELECT * FROM ( SELECT id, name, created_at, (rn : rn 1) AS rn FROM orders CROSS JOIN (SELECT rn : 0) AS init ORDER BY id ) AS t WHERE t.rn BETWEEN 11 AND 20;关键点有三个。第一CROSS JOIN (SELECT rn : 0)用来初始化变量这是标准套路别省。第二ORDER BY id必须写在子查询里让排序先发生变量再按排序后的顺序自增。第三外层再过滤行号。注意变量方案里ORDER BY和变量赋值的先后顺序在旧版本中依赖执行计划如果优化器把排序放到变量赋值之后行号就乱了。所以务必用子查询把排序“固化”下来不要图省事写在一层里。3.3 分页场景的替代思路LIMIT OFFSET如果你要的“按行查询”其实就是分页那LIMIT才是正解根本不需要行号。SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 10;这表示跳过前 10 条取接下来的 10 条等价于第 11 到 20 条。LIMIT的优势是优化器原生支持性能通常比窗口函数子查询更好。但它有个硬伤深分页性能差。当OFFSET很大比如第 100 万条开始MySQL 仍要扫描并丢弃前面所有行。这时候要么用“记住上次最大 id”的游标式分页要么才考虑行号方案。我实测过一个对比100 万行数据LIMIT 10 OFFSET 999990耗时约 0.8 秒而基于主键游标的WHERE id 上次最大id LIMIT 10只要几毫秒。所以分页优先LIMIT深分页优先游标行号方案留给“必须按逻辑行号操作”的场景。4. 按行号删除指定范围这才是真正的难点4.1 为什么删除不能直接套查询写法查询可以随便套子查询但删除不行。MySQL 有个经典限制不能在DELETE的子查询里直接引用同一张目标表。也就是说下面这种写法会报错-- 错误写法会报 You cant specify target table for update in FROM clause DELETE FROM orders WHERE id IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER (ORDER BY id) AS rn FROM orders ) AS t WHERE t.rn BETWEEN 11 AND 20 );报错原因是 MySQL 不允许在修改一张表的同时从子查询里再读这张表。解决办法是再包一层子查询让内层结果先物化成临时结果集外层删除时就不再是“直接引用”了。4.2 窗口函数删除方案正确写法DELETE FROM orders WHERE id IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER (ORDER BY id) AS rn FROM orders ) AS t WHERE t.rn BETWEEN 11 AND 20 );注意这里有三层结构最内层算行号中间层筛行号最外层删除。中间那层SELECT id FROM (...)就是用来“骗过”优化器的让它先把结果算出来再执行删除。这个技巧我用了很多年稳定可靠。4.3 用户变量删除方案老版本同理只是把窗口函数换成变量DELETE FROM orders WHERE id IN ( SELECT id FROM ( SELECT id, (rn : rn 1) AS rn FROM orders CROSS JOIN (SELECT rn : 0) AS init ORDER BY id ) AS t WHERE t.rn BETWEEN 11 AND 20 );结构和窗口函数版一模一样只是内层换了实现。这里要特别提醒删除前一定要先用同样的条件跑一遍 SELECT确认要删的正是你想删的。我见过有人排序字段写错结果删掉了完全不相干的数据而且没有备份只能从 binlog 里捞。这个教训太深刻了。4.4 删除操作的安全清单删除是不可逆操作我总结了一套执行前的检查流程建议你每次都走一遍先 SELECT 验证把DELETE换成SELECT *确认返回的行就是目标行。确认排序字段唯一性如果排序字段有重复值行号会漂移删除范围可能不准。开启事务BEGIN;执行删除SELECT确认影响行数没问题再COMMIT;有问题ROLLBACK;。限制影响行数加LIMIT防止误删过多比如DELETE ... LIMIT 1000。备份或记录主键把要删的主键先SELECT出来存一份万一出错还能恢复。提示生产环境执行删除务必在业务低峰期并且提前确认没有外键依赖或触发器会引发连锁反应。5. 按行号去重保留一条删除其余5.1 去重场景的典型需求这是行号最经典的应用之一。假设users表里email有重复你想每个 email 只保留一条比如 id 最小的那条其余删掉。用行号思路就是按 email 分组组内按 id 排序编号删掉编号大于 1 的。5.2 窗口函数去重方案DELETE FROM users WHERE id IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn FROM users ) AS t WHERE t.rn 1 );这里PARTITION BY email是关键它把数据按 email 分成若干组每组内部独立编号。ORDER BY id决定每组里谁排第一rn 1的就是要删的重复项。这套写法比传统的GROUP BY MIN(id)更灵活因为你可以自由决定“保留哪一条”——想保留最新的就ORDER BY id DESC。5.3 用户变量去重方案老版本用变量做分组编号会麻烦一些因为变量没法直接按分组重置。常见做法是用“分组字段变化时重置计数器”的技巧DELETE FROM users WHERE id IN ( SELECT id FROM ( SELECT id, email, rn : IF(prev email, rn 1, 1) AS rn, prev : email FROM users CROSS JOIN (SELECT rn : 0, prev : NULL) AS init ORDER BY email, id ) AS t WHERE t.rn 1 );这段的逻辑是先按email, id排序然后逐行判断——如果当前 email 和上一行相同计数器加一如果不同计数器重置为 1。prev用来记住上一行的 email。这个写法对排序顺序极度敏感ORDER BY email, id必须和变量判断逻辑严格对应否则编号全乱。5.4 去重方案的性能对比方案10 万行耗时100 万行耗时适用版本窗口函数 PARTITION BY约 0.4s约 5s8.0用户变量分组重置约 0.6s约 8s全版本GROUP BY MIN 子查询约 0.3s约 4s全版本数据是我在测试环境跑的硬件不同会有差异但趋势一致如果只是简单保留最小 idGROUP BY MIN反而最快因为它能利用索引。窗口函数胜在灵活能处理“保留最新”“保留某条件优先”这类复杂需求。选哪个取决于你的业务规则不要盲目追求一种写法。6. 常见问题与排查技巧实录6.1 行号乱序、每次结果不一样这是最高频的问题九成原因是排序字段不唯一。比如按status排序而表里有大量相同 status 的行MySQL 返回这些行的顺序是不确定的行号自然漂移。解决办法是加主键兜底ORDER BY status, id。这样即使 status 相同id 也能保证唯一顺序。还有一种情况是变量方案里ORDER BY写在了外层而不是子查询里导致排序和编号脱节。记住变量方案里排序必须在子查询内层。6.2 删除报错 “You cant specify target table”前面提过这是 MySQL 的限制。解决办法就是多包一层子查询让内层结果先物化。判断标准很简单只要DELETE的子查询里出现了目标表名就要考虑加一层。我通常直接写成三层结构省得反复调试。6.3 深分页越来越慢LIMIT OFFSET在 offset 很大时会扫描并丢弃大量行。优化方向有两个一是用游标式分页记住上一页最后一条的 id下一页用WHERE id 上次id LIMIT n二是如果必须按逻辑行号考虑用覆盖索引减少回表。我一般优先游标方案它能把深分页从秒级降到毫秒级。6.4 窗口函数报语法错误大概率是版本不够。ROW_NUMBER()需要 MySQL 8.0 及以上。用SELECT VERSION();确认一下。如果是 5.7老老实实用变量方案。另外注意OVER()里必须有ORDER BY不写会报错因为行号没有排序依据就没意义。6.5 去重后数据反而变多了这种情况通常出现在变量方案里原因是prev的初始值或判断逻辑写错导致计数器没有正确重置。排查方法先把DELETE换成SELECT把rn列出来看编号是否符合预期。如果编号出现跳跃或重复就是变量逻辑问题。我的经验是变量方案调试时一定要单独跑内层查询肉眼确认编号再执行删除。6.6 问题速查表现象可能原因解决方向行号每次不同排序字段不唯一加主键兜底排序删除报 target table 错误子查询直接引用目标表多包一层子查询深分页慢OFFSET 过大改用游标式分页窗口函数语法错误版本低于 8.0改用用户变量方案去重编号错乱变量重置逻辑错误单独跑内层查询验证删除影响行数异常排序字段有重复值确认排序唯一性7. 我个人的几条实操心得最后分享几个文档里不会写、但实际工作中特别有用的经验。第一行号方案能不用就不用。如果你的需求本质是分页用LIMIT本质是去重用GROUP BY本质是取最新一条用关联子查询或GROUP BY配合聚合。行号是“万能但笨重”的方案它需要排序、需要子查询、需要物化性能往往不是最优。只有在“必须按逻辑行号操作”且没有更简单替代时才动用它。第二删除操作永远先 SELECT。这句话我说多少遍都不嫌多。把删除语句里的DELETE换成SELECT *跑一遍看结果确认无误再删。这个习惯帮我避免了好几次事故。尤其是行号方案排序字段一改删的范围就全变了肉眼验证是最后一道防线。第三变量方案尽量别用在生产。它的未定义行为是真实存在的风险我遇到过升级小版本后变量方案结果变化的情况。如果生产是 5.7 且短期无法升级至少要把变量方案封装成存储过程或脚本加上充分的测试用例别直接写在业务代码里。第四排序字段建索引。行号方案几乎都要排序如果排序字段没索引大数据量下 filesort 会拖垮查询。给排序字段尤其是分页和去重常用的字段建上索引性能提升立竿见影。我做过一个测试给created_at加索引后按时间排序取行号的查询从 3 秒降到 0.2 秒。这套东西后续还能扩展比如结合CTE8.0 的公用表表达式把行号逻辑写得更清晰或者用RANK()、DENSE_RANK()处理并列排名场景。如果你手头正好有按行号操作的棘手需求不妨先把排序字段和唯一性确认清楚再套上面的模板基本不会出大问题。