☰
SQL Server分页优化实战:row_number慢查询从20秒到毫秒级
2026/10/14 4:41:23 网站建设 项目流程

做了这么多年的数据库优化,有个现象我见得最多:一张表拢共才几千行,一个分页查询跑出了二十多秒。开发同事跑过来说“数据量太大了,要分库分表”,等我一查执行计划,好家伙,根本不是量的问题,是执行计划在裸奔。今天就把这个典型的案例掰开揉碎讲清楚——Sql Server 里用 row_number over() 做分页,为什么五千多条记录就能慢到离谱,以及怎么一步步把它从20秒干到毫秒级。

先说结论:五千行数据在数据库里连“入门”都算不上,全表扫描也不该这么慢。慢的根源几乎总是藏在执行计划的某个节点里——要么在索引设计上,要么在查询写法上,要么在统计信息上。这篇文章从问题定位、原理拆解、实操优化到避坑技巧,完整走一遍排查和解决流程,遇到类似问题的朋友可以直接照着做。

1. 先别骂数据量,五千条记录慢20秒这事非常反常

很多人一看到“数据量大了就慢”,第一反应都是“要不要分库分表”“要不要上缓存”。但你冷静想一下:五千条记录,就算一条一条读,在磁盘上也花不了一秒。Sql Server 跑全表扫描的话,这种数据量级的表扫描也就是几毫秒到几十毫秒的事。

那二十多秒到底花在哪了?花在了重复执行次数上。

我打个比方。你要从一本书里找十个段落抄下来,如果整本书在桌上摊开,你翻几页就找到了;但如果你每找一个段落,都要先跑到隔壁房间的书架上把整本书拿过来,翻到那一页抄下来,再把书放回去——重复十趟,时间就上去了。分页查询慢,往往是“把书拿回去再拿过来”这个动作被反复执行了几万次甚至几百万次。

row_number over() 分页的典型执行过程是这样的:先按 order by 条件排序,然后给每一行编一个序号,最后从结果集里切出你需要的那一页。这个逻辑本身没什么问题,但执行计划如果选择了错误的数据访问路径,就会导致排序和查找环节出现灾难级的开销。

还有一个被我反复验证过的点:五千条记录这个数字本身是干扰项。真正让人掉坑里的是——你以为慢是因为“要处理五千行”,实际上慢是因为“为了找出符合条件的那几行,数据库在底层把其他几十万行数据都翻了个遍”,或者“每一行都通过书签查找去主表上反复取其他列”。这些开销是乘法级的,不是加法级的。

所以第一步,不是急着加缓存、改架构,而是先搞清楚:这条查询的执行计划长什么样子?IO 耗费集中在哪里?有没有大量的逻辑读?有没有排序溢出到临时库?只有把问题定位到具体节点,优化才有意义。

1.1 一个典型案例的现场还原

我把之前的优化案例简化一下。有一张订单表,大概一万行左右,表结构里有订单编号、客户编号、订单日期、状态、备注文本等字段。业务方写了一个分页查询:

SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (ORDER BY CreateTime DESC) AS RowNum FROM Orders ) AS T WHERE T.RowNum BETWEEN 21 AND 40

这段 SQL 是不是看着眼熟?网上很多教程就这么写。问题在于:外层套了一个全量子查询,内层对全表做排序,然后才切页。在数据量只有几千行的时候,Sql Server 可能还给你走个简单的表扫描加排序,勉强能接受;但只要表里加了一个大字段,或者 order by 字段上没有索引,执行计划立刻恶化。

我当时看执行计划,排在第一位的开销节点是“排序”,占比超过80%,而且伴随大量逻辑读。原因是:CreateTime 上没有索引,排序必须把所有行先读出来再做 Sort 操作。加上查询里用了SELECT *,备注字段又是几百个字符的文本,每一行都要带着这坨数据参与排序,内存里放不下就溢出到 tempdb,这一下就全完了。

五千行数据听着不多,但在 tempdb 里做 spill 排序,一次 20 秒还算快的。

1.2 问题背后的执行计划逻辑

要理解这种慢,必须看懂两个关键概念:RID / Key Lookup(书签查找)和排序溢出。

书签查找的意思是,当你使用非聚集索引去查找数据,索引里只包含了少数几列,其他列还得拿着索引里的指针回主表去取。如果一个查询要返回十列,索引只覆盖了三列,那其余七列就要对每一行都回表一次。一次回表成本不高,但外层已经过滤出来的几百行、几千行,每行都要回表,成本就是行数级的乘法。

排序溢出就更直接:Sql Server 排序时,如果内存不够,就会把中间结果写进 tempdb。tempdb 写入和读取都是磁盘操作,一旦发生溢出,查询速度不是慢一点,而是慢几个数量级。你永远不知道那 20 秒里有多少秒是在等磁盘转。

接下来我们就对症下药,看看修复一条慢分页查询,到底要从哪几个维度动手。

2. 慢查询的几大真凶,你的 sql 里大概率藏了其中一个

分页查询慢,翻来覆去跑不出下面这几个原因。我按出现的概率排个序,你可以逐一对照排查。

2.1 索引缺失:最经典的高成本操作

索引缺失是分页慢的第一大元凶。具体表现为:order by 的字段上没有索引,排序就要全表扫;查询要返回的列没有覆盖索引,每一行都要书签查找。

你可能会问:五千行的表,建不建索引差别有那么大吗?实测给你看:

  • 无索引 + SELECT * + ROW_NUMBER 排序:逻辑读可能上千次,耗时十几秒到二十几秒
  • 有覆盖索引 + 只查需要的列:逻辑读个位数,耗时几十毫秒

索引的本质不是“让数据变小”,而是“让数据库少干活”。建了合适的索引后,排序可以直接走索引有序结构,书签查找被覆盖索引消掉,查询路径瞬间缩短。

那为什么不建索引呢?很多项目到了后期,索引管理完全是失控状态。开发同学遇到慢了就建个索引,效果不行就换字段再建,导致一张表上好几条重复的、几乎没人用的索引。还有一种是业务上线初期数据量小,没暴露问题,等到数据涨到几千行(在索引面前,几千行已经足够触发问题了),查询计划就开始摆烂。

2.2 隐式转换:让索引失效的隐形杀手

还有一种经常被忽略的问题,就是隐式转换。常见场景:表里订单编号字段是 varchar,查询条件传了一个数字;或者传入的参数类型和字段类型在排序规则上不一致,导致 Sql Server 在字段上做了函数或类型转换,索引直接失效。

比如:

WHERE OrderId = 10086

而 OrderId 实际是 varchar 类型。Sql Server 会把常量转换成 varchar 再比较,这种转换如果发生在列上,索引就没法用了。听起来很小的事,但一旦这个查询被高频调用,整体性能就会烂掉。

更隐蔽的是排序字段的隐式转换:ORDER BY CreateTime如果字段是 varchar 存时间字符串,排序规则就不是时间顺序,而是字符串字典序。用这种字段做分页排序,慢只是表象,数据对错才是大问题。

2.3 统计信息失真与基数估计偏差

这个问题比较“高级”,但非常影响执行计划。Sql Server 靠统计信息估算每个步骤会返回多少行,然后选择对应的连接策略和访问路径。如果统计信息过期,估算的行数可能和实际相差百倍,执行计划就会做出灾难性选择。

比如它可能认为这个表只有 100 行,于是选了嵌套循环连接,结果实际是 5000 行,那就等于外层 5000 次循环,每次循环还要走一遍索引查找,20 秒就是这么来的。

统计信息过期的最典型场景就是大量插入、删除之后没有更新统计信息。在数据量小的时候影响不明显,但配合 row_number 分页这种复杂查询,问题就被放大了。

2.4 大字段参与查询和排序

然后说一个新手基本想不到的点:你 SELECT 的字段列表,直接影响分页速度。

ROW_NUMBER 分页写法里,最忌讳的就是SELECT *。一方面是因为*会把所有列都带出来,包括 varchar(max)、text、image 这种大对象字段;另一方面是这些大字段如果不在索引覆盖范围内,就必须走书签查找回表取数。

更麻烦的是,内层子查询的排序阶段,这些大字段也会跟着排序操作走一遍。内存本来就不够,大字段一进来,立刻触发 worktable。

所以分页查询的正确姿势是:内层只查必需的少量字段,外层需要展示大字段时再用 JOIN 回原表补充。

2.5 参数嗅探与缓存计划问题

最后一个不那么显眼但实际很常见的:参数嗅探。第一次执行时传入的参数值产生的执行计划被缓存了,后续不管传入什么参数,都复用这个计划。如果两个参数的数据分布差异巨大,就会出现“有时候秒回,有时候二十秒”的现象。

注意,这种情况在 row_number 写法里特别容易踩中。因为分页查询的页码不同,对应的 offset 不同,第一次执行可能只翻第一页,数据库选了一个适用于页码小的计划;等页码大了,还是用旧计划,性能就开始恶化。

3. 实操:从20秒到毫秒级的完整优化过程

下面是我的实际优化过程。不讲虚的,直接上步骤。

3.1 诊断阶段:先拿到执行计划和 IO 统计

很多朋友习惯直接改 SQL 然后试跑,这是错误的。你得先量化问题,不然怎么知道优化有没有效果?

我建议每条慢查询,都先执行下面的命令:

SET STATISTICS IO ON; SET STATISTICS TIME ON;

再跑那条慢 SQL。输出里有两个关键信息:逻辑读次数和CPU/耗时。我见过这条分页查询逻辑读在两三千以上,这已经是非常高的数值了。

然后打开实际的执行计划(Ctrl+M 或 include actual execution plan),重点看三件事:

  • 排序(Sort)节点的开销占比
  • 是否存在 Key Lookup(RID Lookup)节点
  • 表的扫描次数是否异常,比如嵌套循环连接的内侧表扫描次数是否等于外层行数

这三个问题,对应了之前说的三类真凶:排序溢出、书签查找、错误连接策略。

拿我那次的问题举例:执行计划里有一个 Key Lookup 节点,代价占比接近70%,下方有一个排序节点,代价占比20%。这已经明明白白告诉你:瓶颈在回表取数和排序上。

3.2 建立覆盖索引:最常见最有效的解法

解决书签查找和排序问题,首选就是建覆盖索引。

很多人对索引有个误区,觉得索引就是“给 where 条件加个索引”。实际上对于分页排序查询,order by 字段的索引比 where 字段的索引还重要。

我当时建的索引是这样的:

CREATE NONCLUSTERED INDEX IX_Orders_CreateTime_Include ON Orders (CreateTime DESC) INCLUDE (OrderId, CustomerId, Status);

拆开解释一下:

  • 键列 CreateTime:让 order by 可以直接走索引的有序结构,省掉 Sort 操作
  • 包含列 OrderId, CustomerId, Status:这三个列是查询里要返回的字段,放进 INCLUDE 后,查询就可以用索引覆盖,不再回表

为什么要用 INCLUDE 而不是把字段都放进键列?因为键列会参与排序维护,太多键列会让索引变得膨胀;包含列只是把数据存在索引叶子节点上,不参与排序,既能覆盖查询,又不会拖累索引维护成本。

建完索引再跑一次,执行计划里的 Key Lookup 直接消失,排序节点也没了,逻辑读从两千多降到了十几次。耗时从 20 秒降到了 200 毫秒以内。这就已经是肉眼可见的“质变”了。

3.3 改写分页写法:OFFSET FETCH 与键集分页

索引优化做完,速度已经能接受。但如果数据量还有增长趋势,或者你希望查询更抗造,建议把分页写法本身也升级一下。

3.3.1 用 OFFSET FETCH 替代 ROW_NUMBER

Sql Server 2012 以上版本,分页可以直接用 OFFSET FETCH,语义更清晰,执行计划也更简洁:

SELECT OrderId, CustomerId, Status FROM Orders ORDER BY CreateTime DESC OFFSET 20 ROWS FETCH NEXT 20 ROWS ONLY;

这个写法本质上是把 ROW_NUMBER 的“先编号再过滤”简化成了“直接跳过前 N 行取 M 行”。配合好索引,性能通常优于 ROW_NUMBER。

需要注意:OFFSET FETCH 必须搭配 ORDER BY,否则会报错。而且在使用 OFFSET 很大时,理论上数据库还是要遍历跳过的那些行,性能依然会随页码增大而下降。所以这只适合中小数据量的分页,大页码还是用键集分页。

3.3.2 键集分页(Keyset Pagination):终极分页方案

如果你要翻到第 1000 页,OFFSET 写法也会慢。因为数据库始终要走到第 20000 行才能返回你要的 20 行。真正抗打的方案是键集分页——用 where 条件代替 offset,让数据库走索引直接定位到起点。

具体思路是:客户端记住上一页最后一条记录的排序键值,下一页查询直接从这个键值之后开始取。

SELECT TOP 20 OrderId, CustomerId, Status FROM Orders WHERE CreateTime < @LastCreateTime ORDER BY CreateTime DESC;

这样每一页查询都只需要走索引去“找起点 + 取20行”,不受翻页深度影响。数据量再大也不怕。

唯一的代价是分页逻辑复杂度上来了,需要把上一页的排序键值传回来。对查询效率要求高的系统,这个代价是值得的。我经手的项目里,只要单表超过十万行,我都推荐键集分页而不是 OFFSET。

3.4 配套优化:统计信息更新与参数化

索引和写法优化完,还有两个坑要填上。

更新统计信息。如果数据经过大量增删,先跑一下:

UPDATE STATISTICS Orders;

再重新执行查询,看执行计划有没有变化。有时候光是重建统计信息,就能让查询计划从“嵌套循环灾难”变成“Index Seek 直达”。

参数化查询。不管用哪种分页写法,都建议用参数而不是拼接字符串。参数化不仅防注入,更重要的是让 Sql Server 有机会复用缓存计划,减少编译开销。

EXEC sp_executesql N'SELECT OrderId, CustomerId, Status FROM Orders ORDER BY CreateTime DESC OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY', N'@Offset INT, @PageSize INT', @Offset = 20, @PageSize = 20;

如果你遇到了参数嗅探带来的计划缓存问题,可以在查询末尾加OPTION (RECOMPILE),强制每次生成新计划。注意这不是银弹,高频小查询不要这么用,因为每次编译也有开销。

4. 常见问题与排查技巧实录

理论讲完了,下面把我在实战里反复踩过的坑和排查技巧直接列出来。这些内容一般在文档里看不到,都是真金白银换来的。

4.1 问题速查表

你可以把这张表当成排查手册,遇到类似的慢分页,按图索骥。

症状首选排查方向解决方案
数据量不大但分页极慢执行计划中是否有 Key Lookup建立覆盖索引
排序字段变化后变慢新排序字段无索引为常用排序字段建索引
相同 SQL 时快时慢统计信息过期或参数嗅探更新统计信息 / 考虑 RECOMPILE
翻页越深越慢OFFSET 大导致遍历过多改键集分页
WHERE 字段加了函数/转换隐式转换导致索引失效改写 SQL,让列独立
SELECT * 分页查询大字段导致回表和溢出只查必需列,外层再补
临时表大量插入后变慢统计信息缺失UPDATE STATISTICS

4.2 独家避坑技巧

再补几个不常见但你早晚会碰到的点。

逻辑读不是越低越好,但一定是敏感指标。我见过有人优化后逻辑读从 2500 降到 80,耗时还是没降,为什么?因为耗时主要花在了网络传输上——查询返回了几万行数据。这种就别调 SQL 了,改业务逻辑去。优化之前先确认瓶颈在 IO 还是在传输。

注意索引列的排序方向。索引键列的 DESC/ASC 要和你的 ORDER BY 方向一致。如果你建了(CreateTime ASC)却一直按CreateTime DESC分页,索引扫描依然要反向,效率会打折。Sql Server 虽然能反向扫描,但效率不如正向,而且某些复杂场景下干脆就不走这个索引了。

INCLUDE 列不是越多越好。很多人一听覆盖索引这么好,就把二十个列全塞进 INCLUDE。结果索引比表还大,页分裂和写入开销全上来了。记住一个原则:INCLUDE 只放查询需要返回的列,尽量少。

小心 OFFSET 写法里的重复排序。如果你把 OFFSET FETCH 写进子查询里再用SELECT *包一层,Sql Server 可能还会额外引入排序节点。分页逻辑最好写在最终最外层,别嵌套太多层,嵌套越深执行计划越复杂。

别忽略 tempdb 的压力。前面提到了排序溢出,还有一种情况:你的临时表或者表变量操作本身就消耗 tempdb。如果一个实例上多个数据库都在大量溢出排序,整体性能都会受影响。检查 tempdb 的等待时间,如果有大量的SOS_SCHEDULER_YIELD或PAGEIOLATCH等待,可能就是溢出导致的连带效应。

最后说一个高并发场景的坑:翻页请求的高频命中。分页查询往往是高频接口,一个页面刚加载后端可能就发了三五次分页请求。这种场景下,即使单次查询 100 毫秒,并发上来后 CPU 也可能被打满。我建议在应用层做结果集缓存——常用页码的数据缓存 30 秒,比依赖数据库硬扛要划算得多。数据库能解决 80% 的问题,但最后的 20% 可能需要把压力放到更上层。

结尾

个人实际做数据库性能排查这几年,最深的体会是:大多数分页慢的问题,都不是数据量的问题,而是执行计划的质量问题。项目代码里那些看似“很合理”的写法——子查询套 ROW_NUMBER、排序字段裸奔、SELECT * 满天飞——在数据量到达临界点之前都不会暴露,让你误以为自己写得很稳。五千条记录只是一个引子,真正重要的是你手里能不能有一套“先看执行计划、再建覆盖索引、最后优化写法”的完整方法论。

如果你手头也有类似的分页慢查询,别急着改架构。先按这篇文章的顺序走一遍:开统计信息、抓执行计划、定位排序和回表、建覆盖索引、必要时改写为键集分页。80% 的场景到索引这一步就已经能解决问题了。剩下 20% 的疑难杂症,拿着执行计划回来,我们继续聊。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询