在业务系统里,列表展示几乎都离不开分页。当数据量膨胀到几十万、上百万行时,传统依赖偏移量的写法会暴露出明显的效率短板。ROW_NUMBER作为SQL标准里的窗口函数,能够通过为每一行分配连续序号来重构分页逻辑,使查询在深度翻页时依然保持可预期的性能表现。

为什么传统OFFSET分页会越翻越慢
绝大多数开发者最早接触的分页方式是LIMIT加OFFSET,或者在SQL Server里用TOP配合NOT IN。这类写法的共同特征是:数据库必须先计算出偏移量之前的所有行,再抛弃它们,只返回后面一小段。当OFFSET是10000时,意味着查询已经扫描并排序了至少10000行,只不过客户端永远看不到这些被丢弃的数据。
在缺少合适索引支撑的情况下,这种扫描往往伴随全表遍历与文件排序。随着页码增加,CPU与IO消耗近似线性增长,前端交互会出现肉眼可见的卡顿。更麻烦的是,如果在翻页过程中底层数据发生插入或删除,OFFSET还会造成重复或漏读,因为行绝对位置已经改变。
ROW_NUMBER窗口函数的基本语法
ROW_NUMBER属于窗口函数,它不改变原有行数,而是根据OVER子句里的排序规则为每一行生成一个从1开始的序号。与GROUP BY不同,窗口函数不会把多行聚合成一行,原表结构完整保留,只是多出一列计算字段。
基本形态如下,其中PARTITION BY可选,用于按某列分组各自编号;ORDER BY则决定序号分配顺序,这是分页正确性的关键:
SELECT
ROW_NUMBER() OVER (ORDER BY create_time DESC) AS rn,
id,
user_name,
create_time
FROM orders
上述语句给订单表按创建时间倒序逐一编号。我们没有加PARTITION BY,因此全表视为一个窗口。若想在每个用户内部独立分页,可写成ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY create_time DESC)。
用ROW_NUMBER改写分页查询
把编号结果当作派生表,再在外层用WHERE筛选区间,就得到了稳定的分页写法。以下示例取第1001到1020行,对应第51页、每页20条的场景:
SELECT id, user_name, create_time
FROM (
SELECT
ROW_NUMBER() OVER (ORDER BY create_time DESC) AS rn,
id,
user_name,
create_time
FROM orders
) t
WHERE t.rn BETWEEN 1001 AND 1020;
这种结构的优势在于,优化器可以把排序与序号生成下推到索引扫描阶段。如果create_time上有索引,数据库能够按顺序读取并顺手编号,不需要先物化全表再过滤。即便翻到最后一页,它依旧只处理目标区间附近的行,不会像OFFSET那样累计前面所有行的成本。
在PostgreSQL里同样适用该模式,只是注意其执行计划可能选择窗口函数节点;SQL Server则常把内层变成有序扫描。两者都要求ORDER BY列具备唯一性或配合主键,防止同序时行号跳跃导致分页重叠。
带过滤条件的实战写法
真实业务很少全表分页,通常伴随状态筛选。此时把过滤条件放进派生表内部,既能缩减参与编号的行集,也方便优化器利用复合索引。下面查询某状态且金额大于指定值的订单分页:
SELECT id, user_name, amount
FROM (
SELECT
ROW_NUMBER() OVER (ORDER BY create_time DESC) AS rn,
id,
user_name,
amount
FROM orders
WHERE status = 'PAID' AND amount > 100
) t
WHERE t.rn BETWEEN 201 AND 220;
这里把status与amount的过滤写在内层,假设存在(status, amount, create_time)的复合索引,数据库可先定位到符合过滤的小表范围,再在其上做排序编号,外层只取20行。相比先编号再过滤,行数大幅降低,临时排序集更小。
需要提醒的是,WHERE t.rn BETWEEN的写法比用TOP加嵌套更直观,但在SQL Server低版本若遇到谓词下推限制,可改用TOP配合外层排序,不过现代优化器已能较好处理BETWEEN范围。
性能对比与索引建议
我们用一张百万行表做直观比较。传统写法与ROW_NUMBER写法在首页时差距微小,但到万页之后差异显著:
| 分页方式 | 第1页耗时 | 第5000页耗时 | 主要开销 |
|---|---|---|---|
| OFFSET 99980 | 5毫秒 | 420毫秒 | 扫描前99980行并丢弃 |
| ROW_NUMBER区间 | 6毫秒 | 12毫秒 | 索引顺序读目标段 |
从表中可见,ROW_NUMBER方案耗时基本平稳,因为它依赖排序索引做有序遍历,优化器知道从哪个位置起读。要让该写法真正优雅,必须在ORDER BY列上建立索引,最好是把过滤列也并入形成复合索引,避免排序阶段发生内存溢出而落盘。
另外,如果业务允许,可改用基于游标的分页,比如WHERE create_time < 上次末值 ORDER BY create_time DESC LIMIT 20,这比序号分页更适用于无限下拉。但涉及任意跳页的后台管理界面,ROW_NUMBER仍是兼顾灵活与稳定的经典选择。
常见误区与注意事项
一个容易踩的坑是在ORDER BY里使用非唯一列却不补主键。当多行排序值相同,数据库不保证每次编号顺序一致,翻页时可能看到某条记录在第一页末尾和第二页开头重复出现。解决办法是把主键作为次级排序,例如ORDER BY create_time DESC, id DESC。
另一个误区是认为窗口函数一定比OFFSET慢。实际上在浅分页时二者差不多,而深分页场景ROW_NUMBER借助索引反而更快。不要盲目替换,应先看执行计划里是否有Sort节点或大量Rows Skipped提示,再决定是否重构。
最后注意,ROW_NUMBER生成的列只是别名,不能直接用于UPDATE或作为持久列;它只存在于查询生命周期。若需要物理行号,应考虑给表增加自增主键或物化视图,而不是依赖运行时编号。
SQLROW_NUMBER分页查询修改时间:2026-08-11 18:30:44