前阵子帮同事做 SQLServer 的慢查询审核,发现系统里一条很常见的分页语句成了头号性能瓶颈。用的是很多团队都在用的 ROW_NUMBER() 写法:从一张 3000 万行的订单表里取第 100 万行附近的 10 条数据,单次查询跑了 9 秒多。业务那边反馈列表页越来越卡,翻到后面基本就是在转圈。今天干脆把这次 SQLServer 分页优化的完整过程整理出来,顺便把 ROW_NUMBER() 分页存在的几个典型问题一次说清楚。如果你平时写后端接口、维护老系统,或者正在为列表深分页发愁,这篇应该能帮你省不少事。
1. 这场分页优化的起因与核心诉求
1.1 业务背景:一个看起来很普通的分页需求
这个项目是一个订单管理系统,核心列表是“订单查询”,每天有大量运营人员使用。原始需求不复杂:订单列表按创建时间倒序排列,每页 10 条,用户可以通过页码跳转,也可以点“上一页/下一页”。数据量在早期只有几十万行,那时用 ROW_NUMBER() 分页完全没问题,查询基本是毫秒级返回。但随着系统运行时间变长,订单表累积到 3000 万行以上,列表接口的响应时间开始肉眼可见地变差。
最典型的报障场景是:运营人员翻到列表第 5000 页以后,页面要等很久才能加载出来,甚至直接超时。开发同学一开始以为是前端渲染问题,后来发现接口本身就在数据库层卡住了。定位到具体 SQL 之后,看到的是这样一个典型写法:
SELECT * FROM ( SELECT ROW_NUMBER() OVER (ORDER BY CreateTime DESC) AS RN, OrderId, OrderNo, CustomerId, Status, CreateTime FROM dbo.Orders ) t WHERE RN BETWEEN 1000001 AND 1000010 ORDER BY CreateTime DESC;这个写法在几十万条数据时确实好用,逻辑也清晰,但一旦表变大、页码变深,性能会呈线性甚至超线性下降。我当时看到这条 SQL 的第一反应是:这又是一个被 ROW_NUMBER() 的“表面简单”坑到的典型案例。
1.2 定位问题的第一现场:耗时与执行计划
我先在测试环境复现了问题,开了三个标准的分析开关:
SET STATISTICS IO ON; SET STATISTICS TIME ON; SET STATISTICS PROFILE ON;实际执行后拿到的数据让我印象深刻:逻辑读超过 8 万次,CPU 耗时接近 9 秒。执行计划里最显眼的两个算子,一个是针对排序列的Sort(排序),另一个是对整张表或大范围索引的Scan(扫描)。由于ROW_NUMBER()需要先按CreateTime DESC给所有满足条件的数据排好序、编好号,再交给外层子查询过滤RN BETWEEN 1000001 AND 1000010,数据库必须把排序列和所有参与查询的列都处理一遍,才“舍得”丢弃前面那 100 万行。
更严重的是,这条查询在CreateTime上居然没有合适的索引支撑。因为在原表上,CreateTime只存在于一个复合索引的非首列位置,排序时优化器评估后认为走索引还不如直接扫描整张聚集索引来得快。于是每次深分页查询都会触发一次全表级别的排序动作。这种场景下,逻辑读的数量几乎是随着页数线性上涨,数据量越往后翻,代价越高。
当时我心里也清楚,这个问题的核心不在“要不要分页”,而在于“用什么方式分页”。下面就把 ROW_NUMBER() 分页的机制和坑拆开聊。
2. Row_Number()分页的机制与隐蔽短板
2.1 一条ROW_NUMBER()分页SQL的执行路径拆解
先看语法层面。ROW_NUMBER()是一个窗口函数,它的作用是按照OVER()子句里指定的排序规则,给每一行分配一个从 1 开始的递增序号。分页场景中,开发人员通常用一个子查询把它包起来,然后通过BETWEEN过滤序号区间,达到“取第 N 页”的效果。
逻辑上很简单,但数据库执行时是另一回事。以 SQL Server 为例,ROW_NUMBER() OVER (ORDER BY CreateTime DESC)在绝大多数情况下会触发排序运算。优化器拿到这个查询后,至少要做三件事:先定位到符合 WHERE 条件的数据范围,再按排序列做全量排序,最后把排序完的数据逐行标号。也就是说,即使你最终只需要返回第 100 万页的 10 条数据,数据库也必须先算出前 100 万条数据的完整顺序。
这个逻辑用生活里的例子类比就是:你需要翻到一本 5000 页书的第 4900 页,但系统不允许你直接翻到那一页,必须从第 1 页开始逐页数过去。数据量小的时候“逐页翻”还能接受,一旦表里有几千万行,这个操作就变成了一场灾难。
2.2 “大偏移量”为什么会拖垮性能
不少开发同学对 ROW_NUMBER() 分页的性能问题有耳闻,但说不清到底为什么慢。我把数据层面上的原因归纳成三点:
第一,排序代价无法规避。只要ORDER BY的列上没有合适的索引,SQL Server 就需要在内存或 tempdb 里维护一个排序结构。深分页时,参与排序的数据行不是 10 条,而是所有满足条件的数据行。数据量越大,内存授予可能不够,还会把中间结果溢写到磁盘,这个时候查询会慢到你怀疑人生。
第二,行号计算必须全量完成。窗口函数与普通聚合不同,ROW_NUMBER()必须知道每一行的相对位置。尤其在RN BETWEEN 1000001 AND 1000010这个条件下,数据库没有办法先“跳过前 100 万行”,再给后面的行编号。它只能老老实实生成完整的行号集合,再交给外层过滤。
第三,SELECT 列过多会放大IO开销。很多生产 SQL 会直接SELECT *,导致排序之外还要做大量的键查找或数据页读取。即使你的逻辑读只有 3 万次,但如果每次都要读取大字段(比如备注、JSON 配置),IO 消耗也会成倍增加。
2.3 除了性能,还有什么隐藏风险
性能只是最明显的问题。实际工作中,我还发现 ROW_NUMBER() 分页还有几个容易忽略的隐患。
第一个隐患是排序结果不稳定。ROW_NUMBER()要求排序字段尽量唯一。如果ORDER BY CreateTime DESC,而CreateTime存在大量相同值,那每一行之间的相对顺序是不确定的。SQL Server 不保证在这种情况下的分页结果是稳定的,跨页时容易出现“重复数据”或“丢失数据”,也就是用户翻页时偶尔会看到之前已经看过的记录。
第二个隐患是深分页对缓存和内存不友好。每次深分页都要触发大范围的排序和扫描,这不仅拖慢了当前查询,还可能把 buffer pool 里原本的热点数据挤出去,造成整个库的并发性能波动。换句话说,单个分页查询慢只是表象,它还会拖累其他正常查询。
第三个隐患是与 NOLOCK 等隔离级别组合时的连锁反应。有些老系统为了减少阻塞,给分页查询加了WITH(NOLOCK)。但 ROW_NUMBER() 分页本身读取范围大,NOLOCK 下遇到页分裂或数据变更,更容易出现重复读或跳读,让分页结果看起来“随机漂移”。这个问题排查起来相当隐蔽,经常被误报为“系统有 bug”。
3. 分页方案选型:对比主流做法的取舍
3.1 常见分页方案速览
既然 ROW_NUMBER() 分页有问题,那用什么替代?我把实际项目中接触过的几种主流方案整理出来,放在一起对比一下。
| 方案 | 写法特点 | 深分页性能 | 适用场景 | 注意事项 |
|---|---|---|---|---|
| ROW_NUMBER()+子查询 | 灵活,兼容 SQL Server 2005+ | 随偏移量线性下降 | 通用小表、简单中等数据量 | 大偏移量慎用,排序字段必须唯一 |
| OFFSET/FETCH | 语法简洁,SQL Server 2012+ | 性能依然会随偏移量下降 | 中小数据量、需求简单 | 不要与 TOP 混用,逻辑容易乱 |
| 键集分页(Keyset) | WHERE 条件加游标值,配合 TOP | 性能稳定,基本不受页码影响 | 大表、高并发列表、无限滚动 | 无法按页码随机跳转,需要传参 |
| 临时表/表变量 | 先存结果再分页 | 首查开销大,重复查询快 | 报表、复杂聚合、多步处理 | 临时表要合理建索引,注意 tempdb |
| 应用层缓存 | 把数据页缓存到 Redis 等 | 最快,但增加一致性成本 | 数据量小、更新不频繁 | 缓存失效策略要设计好 |
从表里能看出,没有绝对完美的分页方案。Row_Number() 分页适合的是“数据量可控、查询条件简单、能够接受大偏移量代价”的场景。一旦表数据量到了千万级别,而且用户确实会翻到很深的页码,键集分页往往是最务实的方案。
3.2 键集分页(Keyset Pagination)为什么值得优先考虑
键集分页的核心思路是:不告诉数据库“我要第几页”,而是告诉数据库“我要从哪条记录之后开始取”。用户点击“下一页”时,前端把当前列表最后一条记录的排序键值传给后端,后端再把查询改写成类似这样:
SELECT TOP (10) OrderId, OrderNo, CustomerId, Status, CreateTime FROM dbo.Orders WHERE CreateTime < @lastCreateTime -- 向下翻页 ORDER BY CreateTime DESC;这种写法下,SQL Server 可以借助索引,直接定位到@lastCreateTime这条记录的位置,然后从那个位置往后继续扫描即可。前面已经看过的数据完全不需要处理,性能也不会随着翻页次数增加而线性恶化。
键集分页的代价是:不能再像传统分页那样提供“跳转到第 N 页”的功能。大多数 to B 系统的业务场景其实都不需要精确跳到第 5000 页,用户更常用的就是“下一页”和“加载更多”。把产品需求从“页码跳转”改成“流式加载”,收益远大于损失。
3.3 OFFSET/FETCH 并不是万能解
SQL Server 2012 之后提供的OFFSET/FETCH语法,对比 ROW_NUMBER() 减少了一层子查询,写起来更清爽:
SELECT OrderId, OrderNo, CustomerId, Status, CreateTime FROM dbo.Orders ORDER BY CreateTime DESC OFFSET 1000000 ROWS FETCH NEXT 10 ROWS ONLY;但要注意一个事实:OFFSET/FETCH底层执行时,依然需要扫描并跳过前面的 100 万行。它省掉的是行号计算和外层过滤的开销,省不掉“跳过偏移量”的物理读取。所以,如果偏移量达到百万级,OFFSET/FETCH 也只是比 ROW_NUMBER() 快一些,不会改变性能随页码增加而下降的总趋势。
我实际测试过,在同一个 3000 万行表上,取出中间位置的数据,OFFSET/FETCH 大概比 ROW_NUMBER() 快 20% 到 30%。这个提升在小数据量上可能感知不明显,但在深分页场景下依然是杯水车薪。想彻底解决深分页问题,必须把目光从“页码偏移”转向“游标定位”。
还有一个容易踩的坑:OFFSET/FETCH要求有ORDER BY,而ORDER BY的列同样需要索引支撑。如果你不加索引,就会看到执行计划里出现排序运算,性能照样很差。
3.4 索引设计才是分页的根基
无论你最终选择哪种分页写法,都绕不开索引。分页查询最理想的状态,是通过一个精确定义的索引,直接以范围扫描的方式拿回数据,全程没有排序、没有查找。要达到这个状态,需要满足两个条件:第一,WHERE 条件里用于过滤的列,最好就是排序键本身或其前缀;第二,查询要返回的所有列,尽量都包含在这个索引里,避免回表。
比如按CreateTime DESC排序分页,最直接的索引设计就是:
CREATE NONCLUSTERED INDEX IX_Orders_CreateTime_Include ON dbo.Orders (CreateTime DESC) INCLUDE (OrderId, OrderNo, CustomerId, Status);这里把常用的返回列放进INCLUDE,查询时就能走覆盖索引,逻辑读会低到不可思议。当然,索引不是越多越好,每增加一个索引都会牺牲写入性能和存储空间。实际操作时,我会先看业务列表页到底需要展示哪些列,再决定要不要把全部列塞进 INCLUDE,而不是无脑套模板。
4. 实操过程:一次完整的优化落地
4.1 现状梳理与目标设定
前面铺垫了这么多,下面进入这次优化的实际操作环节。我当时给自己定的目标是:把深分页查询从 9 秒优化到 200 毫秒以内,同时不能明显影响写入性能。
先梳理现状,出问题的表结构大致如下:
CREATE TABLE dbo.Orders ( OrderId BIGINT IDENTITY(1,1) PRIMARY KEY, OrderNo VARCHAR(32), CustomerId INT, Status TINYINT, CreateTime DATETIME, Remark NVARCHAR(500) );现有的索引只有一个基于OrderId的聚集索引,以及一个IX_Orders_CustomerId的非聚集索引。也就是说,按CreateTime排序时没有现成索引可用。当时的查询除了分页条件,还经常附带CustomerId = @cid之类的筛选。业务上也存在“按客户查订单列表”“按状态查订单列表”等场景,但这次优化的主线是全局列表的分页。
4.2 第一步:为排序字段打造匹配索引
我决定先解决排序列没有索引的问题。综合考虑业务中“大多数列表都按 CreateTime 倒序”的特点,创建了这样的覆盖索引:
CREATE NONCLUSTERED INDEX IX_Orders_CreateTime_Desc ON dbo.Orders (CreateTime DESC, OrderId DESC) INCLUDE (OrderNo, CustomerId, Status);这里把OrderId追加到索引键里,是为了让排序结果更稳定。因为CreateTime可能重复,OrderId作为唯一递增列可以确保排序顺序完全确定,避免翻页内容漂移。同时OrderNo、CustomerId、Status放到 INCLUDE 里,让大多数分页查询都能直接覆盖,不需要回聚集索引。
创建这个索引后,我重新跑了一遍原来的 ROW_NUMBER() 分页 SQL,逻辑读立刻从 8 万多次降到了 2000 多次,耗时也从 9 秒降到了 500 毫秒左右。这说明索引对 ROW_NUMBER() 分页同样有巨大帮助。但我也知道,一旦页码继续往后翻,逻辑读还是会继续涨上去。索引帮它“延寿”了,却没法根治。
4.3 第二步:改写分页查询为键集分页
为了让性能不随页码线性衰减,我把接口的分页逻辑改成了键集分页。前端不再传当前页码,而是传“上一页最后一条记录的 CreateTime 和 OrderId”。后端收到的参数类似:lastCreateTime = '2024-06-01 12:00:00'、lastOrderId = 98999123。
查询改写成:
SELECT TOP (10) OrderId, OrderNo, CustomerId, Status, CreateTime FROM dbo.Orders WHERE CreateTime < @lastCreateTime OR (CreateTime = @lastCreateTime AND OrderId < @lastOrderId) ORDER BY CreateTime DESC, OrderId DESC;注意这个 WHERE 条件的写法。因为CreateTime可能重复,不能只写CreateTime < @lastCreateTime,否则会漏掉那些创建时间相同、但 OrderId 更小的记录。我把CreateTime和OrderId组成复合游标条件,CreateTime与上一页最后一条相同记录的内部排序也能稳定衔接。
上一页的逻辑也类似,但查询方向相反。把ORDER BY改成升序,然后条件反向取,查出结果后在应用层倒序返回即可:
SELECT TOP (10) OrderId, OrderNo, CustomerId, Status, CreateTime FROM dbo.Orders WHERE CreateTime > @firstCreateTime OR (CreateTime = @firstCreateTime AND OrderId > @firstOrderId) ORDER BY CreateTime ASC, OrderId ASC;在我这次的实践中,键集分页改写之后,不管用户翻到第几页,单次查询的逻辑读始终保持在 100 次以内,耗时稳定在 20~50 毫秒,和页码深浅完全无关。这才是真正的根治。
4.4 第三步:验证效果与回归
优化完成后,我在同一台测试库上对几种方案做了对比,数据如下:
| 方案 | 深分页耗时时长 | 逻辑读 | 备注 |
|---|---|---|---|
| 优化前 ROW_NUMBER() | 约 9 秒 | 8 万+ | 无排序索引,全表排序 |
| ROW_NUMBER() + 索引 | 约 0.5 秒 | 约 2000 | 有覆盖索引,但偏移量影响仍在 |
| OFFSET/FETCH + 索引 | 约 0.4 秒 | 约 1800 | 写法更简洁,本质仍是跳过偏移 |
| 键集分页 + 索引 | 约 0.03 秒 | 约 80 | 深翻页性能稳定,无累积开销 |
这个结果也在预期之内。索引能解决“排序”的痛点,但无法解决“偏移量越大、扫描越多”的物理事实。键集分页真正避开了深偏移量问题,所以性能才能稳定。
回归测试时我特别关注了两个点:一是各种带筛选条件的列表(比如按客户查、按状态查)是否仍然能走索引;二是写入性能是否因为新增索引而明显劣化。实测下来,新增一个非聚集索引对写入的影响在可接受范围内,关键业务的 INSERT 耗时增加不到 5%。对千万级表来说,这个代价换来核心列表页的稳定体验,是很划算的。
5. 常见问题与排查技巧实录
5.1 按非唯一字段排序导致分页内容乱跳
这个问题我遇到不止一次。有团队用ORDER BY CreateTime DESC做 ROW_NUMBER() 分页,结果用户反馈“翻下一页后,上一页的某条数据又出现了一次”,或者“有些数据永远等不到”。
原因就是排序键不唯一。CreateTime精确到秒,同一秒内可能插入多条订单。当排序键相同时,SQL Server 不保证行的顺序是稳定的。每次查询的执行计划可能不同,数据分布和并行度也可能不同,行号顺序就可能在多个值之间抖动。
解决办法很简单:在排序键后面追加一个唯一列,比如ORDER BY CreateTime DESC, OrderId DESC。这样每一行的位置是完全确定的,分页结果才稳定。这个经验同样适用于键集分页的游标条件设计。
5.2 加了索引却没走,为什么
有同学在做优化时加完索引,一看执行计划,发现还是扫描。常见原因有三个:
第一,统计信息过期。数据量大增后,旧的统计信息可能让优化器误判索引选择率太低,于是认为扫描更便宜。解决办法是用UPDATE STATISTICS更新对应表的统计信息,或者让统计信息的自动更新阈值更敏感。
第二,隐式类型转换。比如表里OrderId是 BIGINT,查询条件却写成WHERE OrderId = '98999123',字符串常量会和字段类型不一致,SQL Server 会在比较时对字段做隐式转换,这会导致索引失效。排查时看执行计划里有没有CONVERT_IMPLICIT运算符即可。
第三,查询返回了过多列。如果索引只覆盖了 3 列,而 SELECT 需要 10 列,SQL Server 发现每条记录都要回表,评估后可能选择直接扫聚集索引。这时候要么把查询列收窄,要么把高频返回列加到索引的 INCLUDE 中。
我在实际优化中遇到最多的是第三种情况。很多老系统喜欢SELECT *,这对分页查询的索引设计非常不友好。优化的第一步往往不是加索引,而是先和业务方确认列表到底需要展示哪些字段。
5.3 深分页的另一种解法:缓存与游标
键集分页是最通用的方案,但有些场景不能改业务逻辑,必须支持页码跳转。这种时候如果数据量到了千万级,我会建议从应用层另想办法。
一种办法是对排序键做分页映射缓存。单独建一张小表,按顺序存储排序键值,比如把每页最后一条记录的OrderId缓存起来,用户请求第 N 页时直接读取缓存的位置信息,然后通过键集方式查数据。这种方案既能保留页码跳转,又不让数据库承受大偏移量扫描。
另一种办法是在 Redis 里缓存当前用户的前若干页结果。这个办法适合列表数据更新不频繁的场景。用户在短时间内来回翻页时,数据直接从缓存返回,数据库压力会小很多。但要注意缓存失效机制,避免用户看到陈旧数据。
5.4 排查分页慢的检查清单
实操中遇到分页变慢,我一般按下面的顺序排查。建议你把它存下来,碰到问题直接照着做:
- 看 SQL 文本:是不是用了
ROW_NUMBER()且偏移量巨大;是不是有SELECT *。 - 看执行计划:有没有
Sort、Scan、Key Lookup,有没有隐式转换。 - 看 SET STATISTICS IO:逻辑读是否随页码增长;如果逻辑读高,优先加覆盖索引。
- 看排序键唯一性:行顺序不稳定会让分页结果错乱。
- 看业务需求:是否真的需要“跳到第 N 页”,能否改成“下一页/加载更多”。
- 看表数据增长趋势:如果每月新增百万行,即使今天性能还行,半年后也会出问题。
这套流程下来,大多数分页慢问题都能定位到根因。
我个人在实际操作中的体会是:分页优化这件事,最重要的不是背住某个函数用法,而是理解数据库在“跳过偏移量”这件事上付出的物理代价。如果你想在千万级数据量上做稳定的分页,键集分页配合覆盖索引,几乎是当前最省心也最可靠的一条路。当然,具体到每张表、每个业务场景,还是要结合数据分布和查询频率去权衡,没有银弹,但有好的排查思路和落地手段,至少能让你在踩坑时快速走出来。