ROW_NUMBER()深度分页更稳因其按逻辑序号过滤、不依赖物理偏移,避免重复排序值导致的漏行或重读;OFFSET则可能因相同排序值跳过或重读记录,结果不可重现。

ROW_NUMBER() 分页为什么在深度翻页时更稳
因为 ROW_NUMBER() 按逻辑序号过滤,不依赖物理偏移——遇到重复排序值(比如多个 created_at 相同的记录)时不会漏行或重复;OFFSET 则可能跳过或重读某几条,翻页结果不可重现。
典型场景:ORDER BY created_at DESC 下同一秒插入 5 条订单,第 100 页用 OFFSET 1980 ROWS 可能漏掉其中 1 条,而 ROW_NUMBER() 给每条都分配唯一序号,WHERE rn BETWEEN 1981 AND 2000 总是精确命中。
- 必须确保
ORDER BY后接唯一列兜底,例如ORDER BY created_at DESC, id DESC - 外层
WHERE条件要写成rn BETWEEN @start AND @end,不能用rn > @start AND rn —— 后者可能让优化器放弃 Seek 计划 - 别名
AS rn不能省,否则外层引用报错Invalid column name 'rn'
OFFSET/FETCH 在什么情况下真香
OFFSET/FETCH 语法干净、语义清晰,适合中小规模分页(前 100 页、每页 20–100 条),且 ORDER BY 字段有高效索引时效果最佳。
但要注意:SQL Server 2012+ 才支持;旧版本直接报错,不是兼容性警告;OFFSET 值超 int 范围(2147483647)会炸;@page_size 过大(如 > 500)可能触发内存 grant 不足或 tempdb 排序溢出。
- 必须带
ORDER BY,否则报错Invalid usage of the option NEXT in the FETCH statement - 偏移量计算必须是
(@page_index - 1) * @page_size,写成@page_index * @page_size会导致第 1 页就跳过前@page_size行 - 参数化传参时得兜底:
DECLARE @page_index INT = ISNULL(NULLIF(@input_page, 0), 1),防 0 或负数触发OFFSET -100 ROWS报错
性能拐点在哪?别只看“平均耗时”
测试数据里 OFFSET 第 20 万页平均 396ms、ROW_NUMBER() 1257ms,但这只是表象。真实瓶颈不在函数本身,而在执行计划是否能走索引有序扫描 + 提前终止(StopAt)。
当 ORDER BY 字段没索引,两者都会强制 Sort,性能雪崩;当有覆盖索引且查询条件可下推,ROW_NUMBER() 的三层嵌套反而更容易被优化器识别为 Seek + Top。
-
ROW_NUMBER()写法中,WHERE必须下推到内层子查询或 CTE,否则全表扫 -
OFFSET在 page_index > 10 万时,B+ 树遍历开销呈线性增长,ROW_NUMBER()靠 StopAt 可提前退出 - 别盲目信“第 1 页谁快”,重点看第 5000 页之后的 P95 延迟和连接池占用
最容易被忽略的稳定性细节
很多人以为只要语法对就能稳定分页,其实真正踩坑的都是边界逻辑。
比如 @page_size 设为 100,但用户传了 10000,FETCH NEXT 10000 ROWS 会拖垮连接池;又比如用 GETDATE() 作排序字段,时间精度只有毫秒级,大量并发插入必然产生重复值,这时 OFFSET 就不可靠。
- 建议硬限制
@page_size ≤ 100,并在存储过程开头加校验 - 对超深页码(如
@page_index > 5000)主动拒绝:IF (@page_index > 5000) RAISERROR('Page too deep', 16, 1) - 排序字段尽量选高基数、低更新频次的列,避免用
datetime类型单独排序

















