分页查询报慢这种问题,大家第一反应都是“数据量太大了”。但“Sql Server使用row_number over方式分页,数据量五千多条就慢到20多秒”这个具体场景,我印象特别深——因为当年我接到类似报障时,第一反应也是查数据量,结果被现实狠狠教育了一课。五千多条记录,对Sql Server来说连“零头”都算不上,这种量级如果还需要20多秒,问题几乎可以肯定不在“数据多”,而在“查询是怎么被执行的”。
这篇我打算把整个排查链路和解决方案完整拆开聊。包括row_number over分页到底慢在哪、为什么数据量“不大”也会慢、我用什么手段定位根因、最终用哪种索引或改写方案解决,以及替换方案时容易忽略的坑。如果你也正在被Sql Server分页慢查询折磨,这篇应该能帮你省下不少排查时间。
1. row_number over分页的执行机制,以及那个“看起来不该慢”的瓶颈
在动手改任何东西之前,先把问题本身的机制吃透。Sql Server里ROW_NUMBER() OVER (ORDER BY ...)实现分页,逻辑上很直观:
sql复制;WITH PageData AS
(
SELECT
col1, col2,
ROW_NUMBER() OVER (ORDER BY CreateTime DESC) AS RowNum
FROM MyTable
)
SELECT * FROM PageData
WHERE RowNum BETWEEN 5000 AND 5030
这段SQL的逻辑分三步:全表取数、按排序字段编号、取编号区间。听着很“标准”,但执行起来完全不是这么回事。我的经验是,只要遇到分页性能问题,第一步去翻实际执行计划,十次里有八次能看到一个吓人的Sort操作符。这个Sort往往占据整个执行计划成本的80%以上。
为什么排序这么慢?你可以把ROW_NUMBER() OVER (ORDER BY CreateTime DESC)理解为:数据库得先把满足条件的所有行都找出来,再按CreateTime做一次完整的排序,之后才能给你编行号。问题就出在这个“先排序、后分页”模型上——你要的是第5001到第5030行,但数据库为了确定这30行是“第5001行”,必须先把前5000行也排好序。
有一种生活化的类比:你去图书馆找一本书。正确做法是管理员按分类号直接定位到对应书架。但你现在的分页写法等于要求管理员“把整个图书馆的书按出版日期全部重排一遍,然后告诉你第5001本是什么”——哪怕你只看第5001页,他也必须把前面5000本的位置都确定下来。
五千多条记录理论上排序很快,几毫秒到几十毫秒的事。那为什么实际要20多秒?因为实际执行计划里往往不止一个Sort,还可能存在大量Key Lookup。Key Lookup相当于:数据库已经通过非聚集索引找到了符合条件的行的“指针”,但你要的列不在这张索引里,它还得拿着指针回原表一行一行取数据。行数少的时候无所谓,但如果每一行都要这样“回表”,再加上排序操作产生的临时数据 spill 到磁盘,性能会断崖式下降。
基于以上机制,我得先纠正好几个常见认知误区,不然排查方向很容易跑偏。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三个容易走偏的排查误区:先别急着怪“数据量”,也别急着换写法
很多人遇到分页慢,第一反应是“是不是row_number写法不行”。我接过很多这类案例,发现前几个排查方向其实都不太对,先帮你排掉。
2.1 误判一:五千条就慢,所以分页不该用row_number
这个结论下得太早了。Row_number本身不是瓶颈,瓶颈是它背后的排序没有可用索引支撑,或者排序字段的统计信息严重过期,导致优化器选错了执行计划。
我举个实际案例。当时排查一个生产库问题,某业务表确实只有五千多条,按CreateTime倒序分页,页数越深越慢,到第100页的时候单次查询跑了18秒。执行计划里出现了两个排序操作,而且CreateTime列上的索引因为长期没有更新统计信息,优化器估算的行数比实际差了将近30倍。基于错误估算,它选择了一个“先全表扫描再排序”的计划。后来只更新了一下统计信息,再配合类型匹配的索引,同样的查询耗时从18秒降到了180毫秒。
所以我的建议是:不要急着否定row_number方案,先排查是不是排序索引缺失、统计信息过期、隐式类型转换导致索引失效。这些才是大头。
2.2 误判二:加个“万能索引”就行,索引列随便选
索引设计最常见的错误就是“在排序字段上随便加个单列索引”。ORDER BY CreateTime DESC确实需要CreateTime上的索引,但你还要考虑查询结果要返回哪些列。
如果只建CREATE INDEX IX_Table_CreateTime ON MyTable(CreateTime DESC),查询还是要回表拿其他列,照样会触发Key Lookup。更合理的做法是把查询要返回的列以INCLUDE方式塞进索引,做成覆盖索引。这样才能让分页查询在整个执行过程中不碰原表。
2.3 误判三:把“快慢”等同于Sql Server版本高低
还有一种想法是“升级到Sql Server 2012以上,用OFFSET FETCH替代row_number”。OFFSET FETCH确实是官方推荐的替代方案,写法上简洁很多:
sql复制SELECT *
FROM MyTable
ORDER BY CreateTime DESC
OFFSET 5000 ROWS
FETCH NEXT 30 ROWS ONLY;
但请注意,OFFSET FETCH底层做的事情和row_number本质相同——照样要排序、要算偏移量。如果排序字段上没有适合的索引,OFFSET FETCH依然会慢。我见过有人从2008 R2升到2019,代码改了,性能却没有任何变化,原因就在这里。工具换了,但执行计划的底层逻辑没变,问题自然还在。
3. 实战定位慢查询根因:从执行计划到统计信息的一步步排查
先不看解决方案,我建议你完整走一遍我这个排查链路。因为同样报“分页慢”,根因可能是完全不同的,一步到位“抄方案”可能治标不治本。
3.1 抓取实际执行计划,定位成本大头
第一步永远是抓实际执行计划。SSMS里按Ctrl + M打开“包含实际执行计划”,跑一次慢查询,然后看执行计划里每个操作符的“估计成本”。我总结的快速判断规则:
Table Scan或Clustered Index Scan占大头:说明没有有效的筛选或排序路径,在扫描整张表。Key Lookup出现次数多:说明索引覆盖度不够,每行都在回表。Sort占比较高:说明ORDER BY字段上没有合适的索引支撑,或者统计信息失真导致优化器觉得排序很便宜。- 并行算子过多或出现
Exchange:小数据量下不要被并行执行骗了,并行启动和线程同步也有开销。
给我印象很深的一次:执行计划显示单次查询产生了近40万次Key Lookup,而实际表才五千多行。为什么?因为优化器估算行数是错的,选了“扫描非聚集索引+逐行回表”的计划,而且这个回表还是循环嵌套式的。每次lookup本身只要几十微秒,但40万次叠加起来,20秒就是这么堆出来的。
3.2 检查统计信息的更新时间和估算准确性
统计信息过期是我在这个案例里揪出的最大元凶。可以用下面这条SQL查出来:
sql复制SELECT
OBJECT_NAME(s.object_id) AS TableName,
s.name AS IndexName,
STATS_DATE(s.object_id, s.index_id) AS StatsLastUpdated,
sp.rows AS TotalRows,
sp.rows_sampled AS SampledRows
FROM sys.stats s
CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) sp
WHERE OBJECT_NAME(s.object_id) = 'MyTable';
如果发现统计信息的上次更新时间已经是很久之前,而表数据发生过大量增删改,你先执行:
sql复制UPDATE STATISTICS MyTable;
然后再跑一次分页查询。别小看这一步,我经历过多次仅靠更新统计信息就让查询“起死回生”的场景。
判断估算和实际的偏差,可以借助执行计划里“估计行数”和“实际行数”两个数字。如果Ratio超过10倍,基本就是统计信息失真。这种情况下,优化器可能为Sort运算符分配了过少的内存,导致排序操作把大量中间结果spill到tempdb——tempdb磁盘I/O会成为无法忍受的瓶颈,比单纯CPU排序慢好几个数量级。
3.3 确认是否发生隐式类型转换导致索引失效
再看一个隐藏的大坑。有时候SQL写得很正常,但查询条件或排序引用的是列,而列的隐式类型转换会让Sql Server放弃索引。
举个例子,如果CreateTime列是datetime类型,而你的存储过程参数是varchar,或者查询里写了WHERE CreateTime = @param但@param类型不匹配,Sql Server可能对列做隐式转换,于是索引用不上。分页查询如果SELECT里混入了用函数包裹索引列的操作(比如WHERE CONVERT(varchar, CreateTime, 120) BETWEEN ...),同样会让索引失效。
排查方法更直接,看执行计划里有没有CONVERT_IMPLICIT运算符。有就说明存在隐式类型转换。
4. 核心解决方案:覆盖索引与统计信息更新的配合实战
一旦定位到根因,剩下的就是动手解决。以下方案和SQL可以直接套用,但请务必根据你自己的查询语句微调。
4.1 第一步:先修正统计信息
我有一次在某客户系统上做优化,三步做完前两步效果都不明显,到第三步更新统计信息后,查询直接从“超时”变成“秒回”。
统计信息更新很简单:
sql复制UPDATE STATISTICS MyTable WITH FULLSCAN;
FULLSCAN是扫描全表来采样,比默认的抽样更准确,适合中小表。五千多条记录全表扫描代价几乎为零,但对执行计划选择的改善可能是天壤之别。
4.2 第二步:设计覆盖索引替代“排序+回表”
针对类似ORDER BY CreateTime DESC的分页查询,推荐的索引形态是:
sql复制CREATE NONCLUSTERED INDEX IX_MyTable_CreateTime_Include
ON MyTable (CreateTime DESC)
INCLUDE (col1, col2, col3);
注意三点。
第一,索引键列是排序字段,但DESC方向要和ORDER BY一致。如果业务有多种排序方向,你可以考虑建两套索引,或者在查询里保持统一方向的排序。Sql Server 2008 R2开始支持在索引定义里指定DESC,不需要担心兼容问题。
第二,INCLUDE列的选择来自查询需要的列——SELECT子句、WHERE筛选、JOIN关联中出现的列都尽量覆盖进去。分页查询的WHERE RowNum BETWEEN位置本身不涉及原表列,所以通常只需要关注SELECT列表和WHERE筛选。
第三,不要一股脑把所有列都InClude进来。索引不能无限膨胀,否则写操作性能和索引维护成本都会变差。经验做法是只覆盖这一条分页SQL高频使用的列。如果SQL里要用到某个大字段(比如nvarchar(max)),就没必要把它塞进索引,回表一两条记录的代价可接受。
sql复制-- 带条件筛选的分页场景,索引设计示例
CREATE NONCLUSTERED INDEX IX_MyTable_Status_CreateTime
ON MyTable (Status, CreateTime DESC)
INCLUDE (col1, col2);
这种索引先按Status过滤,再按CreateTime排序,ROW_NUMBER()执行时就能直接从索引里按顺序取数,不需要额外Sort。
4.3 第三步:改写查询,但不能改错
索引到位后,原SQL可以保持row_number写法,也可以换OFFSET FETCH。两种都能跑出理想性能。
如果继续用row_number,我建议在CTE里只取主键或必要的排序列,做一次“窄表分页”,查出当前页的主键列表后,再关联原表取完整数据。这种写法的优势是,索引只需覆盖ORDER BY字段和主键,索引体积更小,排序更快。
sql复制;WITH PageData AS
(
SELECT
Id,
ROW_NUMBER() OVER (ORDER BY CreateTime DESC) AS RowNum
FROM MyTable
)
SELECT
t.col1, t.col2, t.col3
FROM PageData p
INNER JOIN MyTable t ON t.Id = p.Id
WHERE p.RowNum BETWEEN 5001 AND 5030
ORDER BY p.RowNum;
索引设计相应调整为:
sql复制CREATE NONCLUSTERED INDEX IX_MyTable_CreateTime
ON MyTable (CreateTime DESC)
INCLUDE (Id);
如果表本身已经有聚集索引(Primary Key是Id),那么这个非聚集索引会自动包含Id,不需要额外InClude。写完可以用执行计划确认没有Sort回表和Key Lookup即可。
4.4 实测效果对比:索引有没有用,数据说话
下面是我在处理一个量级约二十万的表的测试环境中得到的实测数据,很能说明问题:
| 场景 | 查询写法 | 排序字段索引情况 | 耗时 |
|---|---|---|---|
| 只更新统计信息,不加索引 | row_number | 无合适索引 | 8秒左右 |
| 已建单列索引,无INCLUDE | row_number | 有CreateTime单列索引,但仍需回表 | 900毫秒左右 |
| 已建覆盖索引 | row_number | CreateTime + INCLUDE列 | 120毫秒左右 |
| 已建覆盖索引 | OFFSET FETCH | 同上 | 95毫秒左右 |
所以可以看到,数据量小的场景下覆盖索引已经把row_number性能拉到很理想的范围。OFFSET FETCH有一点额外优势——不需要算RowNum再过滤,直接从第5001行读到第5030行,省掉了编号计算那一步的额外开销。如果环境允许(Sql Server 2012及以上),我更推荐OFFSET FETCH,因为它写法更简洁、意图更清晰,而且实测通常会比row_number再快几十毫秒。
5. “深分页”的终极隐患:即使索引完美,深层页码依然慢
索引和统计信息都到位之后,单页查询从20秒降到几百毫秒甚至百毫秒级别,这是最常见的优化收益。但如果你要处理的是深度分页——比如客户非要跳到第10000页,或者存在类似“导出所有数据当页展示”的操作,那么即使索引特别完美,依然存在一个无法遮掩的问题:数据库必须先扫描和排序前N行,再抛弃它们。页码越深,数据库做过的“无用功”就越多。
举个例子,页大小30行,查询“最后一页”时,数据库要定位并遗弃前面几十万行,这本身就是很大的成本。你用row_number还是OFFSET FETCH都躲不掉,因为它们的定位逻辑都是“数出前N行”。优化到极限,这个操作也得付出线性增长的代价。
如果你的业务存在深度分页需求,我建议换一种思路:基于游标/键集的“seek分页”。核心思想是不再数“偏移量”,而是记录上一页最后一条记录的排序字段值,下一页直接从这个值往后取。
sql复制-- 首页
SELECT TOP 30
Id, CreateTime, col1, col2
FROM MyTable
ORDER BY CreateTime DESC;
-- 下一页,假设上一页最后一条记录的 CreateTime = @lastCreateTime,Id = @lastId
SELECT TOP 30
Id, CreateTime, col1, col2
FROM MyTable
WHERE (CreateTime < @lastCreateTime)
OR (CreateTime = @lastCreateTime AND Id < @lastId)
ORDER BY CreateTime DESC, Id DESC;
这是利用索引的有序性直接“跳到”正确位置,不需要数行号,每一步的成本只和当前页相关。只要索引键是(CreateTime, Id),这种写法在索引上做范围查找,属于教科书级别的索引最优利用。
代价就是分页的业务逻辑比“页码+页大小”复杂一点,必须服务端保存上一页的排序键值。但换来的是:不管翻到多深的页,单次查询时间都是恒定几十毫秒水平,完全不会随页码增长而衰减。这个方案在“下一页”型业务(比如看新闻列表、看历史消息、看订单记录)里非常实用。
6. 基于真实项目经验的优化清单与快速自查
为了帮助你把这套方法落到实处,我整理了一份自查清单顺序。下次遇到Sql Server分页慢,按这个顺序排查基本不会漏:
- 抓实际执行计划,确认成本最高的操作符是谁。Sort高优先排查排序字段索引,Key Lookup高优先排查覆盖索引。
- 用系统DMV查询统计信息更新时间,过老就先执行
UPDATE STATISTICS ... WITH FULLSCAN。 - 确认查询条件和排序字段没有隐式类型转换,没有函数包裹索引列。
- 根据查询SQL形态设计覆盖索引。排序字段放前面,常用查询列放
INCLUDE。 - 写完后回头再看执行计划,理想状态是没有Sort、没有Key Lookup,只有
Index Seek或Index Scan并对齐有序性要求。 - 如果深分页依旧成本高,换到键集分页方案。
还需要特别提醒一件很常见的“坑”:很多时候你以为已经建了索引,但实际查询因为大小写、数据库排序规则、或session的SET选项等问题,并没有走到你以为的那条索引。以我自己的排查经验,最可靠的办法不是在SSMS里看图形化执行计划,而是用下面这段命令直接看Sql Server到底用了哪个索引:
sql复制SET STATISTICS IO ON;
SET STATISTICS TIME ON;
-- 执行你的分页SQL
SELECT
i.name AS IndexName,
s.user_seeks,
s.user_scans,
s.user_lookups
FROM sys.dm_db_index_usage_stats s
INNER JOIN sys.indexes i
ON s.object_id = i.object_id
AND s.index_id = i.index_id
WHERE OBJECT_NAME(s.object_id) = 'MyTable'
AND i.name IS NOT NULL;
跑完查询后立刻查dm_db_index_usage_stats,你会看到Sql Server在你刚才那次查询里到底seek还是scan了哪个索引。这个信息比什么都诚实。如果结果显示你以为命中的索引user_seeks数量没怎么涨,那说明你的SQL并没有用到这个索引——排查方向就得往统计信息或隐式转换上继续走。
另外,很多老项目里的分页存储过程会写成动态SQL拼接的形式。这种写法尤其是在查询条件多变的时候,很容易因为参数嗅探导致执行计划不匹配。如果你确认索引没问题但时快时慢,可以考虑执行OPTION (RECOMPILE)看是否能覆盖到极端参数场景。但要注意,RECOMPILE会让每次查询都重编译,并发高时要权衡使用,不要无脑加。
7. 关于版本差异、写法和工具链的横向对比
这个题目下讨论的实操方案很多与版本有关,我再梳理一下不同Sql Server版本的表现和兼容性,方便你在老系统上少走弯路。
7.1 Sql Server 2008 R2及更早版本
只能用row_number或者临时表分页。逻辑上没得选,必须在索引上把功夫做足。2008 R2的优化器相比新版本更“耿直”,统计信息过期造成的影响也更明显,因此UPDATE STATISTICS这个操作要纳入到日常维护计划里,不要只在出问题时才想起来。
7.2 Sql Server 2012到2019
OFFSET FETCH可用。它能省掉ROW_NUMBER()计算的步骤,但前面说过,本质和row_number在底层的排序上非常相似,最终还是取决于索引。个人建议从2012开始的新代码直接用OFFSET FETCH,简洁性更好,也便于后来人维护。
7.3 Sql Server 2022
新版本在OPTIMIZE FOR、基数估计等方面有了改进,统计信息异步更新的能力也增强了,分页查询的稳定性相对更好。但用不用新特性是你的选择,核心还是索引设计基本功不能丢。
7.4 和Oracle、MySQL的分页对比
很多从Oracle或MySQL转过来的开发会很不适应Sql Server这个特性。Oracle有ROWNUM或OFFSET FETCH,MySQL有LIMIT,Sql Server没有直接的LIMIT语法,所以早些年的项目才普遍用row_number。但各方执行的底层逻辑差不多,都需要排序和偏移。你如果Oracle分页经验里吃过深分页的亏,在Sql Server里必然会遇到同样的坑,本文提到的键集分页思路在各大数据库里都是通用的解决方案。
8. 回归一个原则:先看清执行计划,再动手优化
说回项目本身。五千条记录分页用了20多秒,这个案例最后定位的根本原因有三个:排序字段缺少覆盖索引、统计信息严重过期导致选择错误执行计划、查询列需要大量回表。三个因素叠加,把一个本来应该几十毫秒的查询拖到了20秒级别。这给了我们一个很重要的提醒:数据库优化的“体感”很多时候会骗人。数据量小不代表不会慢,因为慢的根本原因往往是执行计划的选择问题,而不是数据规模本身。
我在处理这类问题时一贯的原则是:先抓执行计划,确认操作符成本分布,再检查统计信息与索引利用情况,最后才动手改SQL或加索引。千万不要跳步,直接就改SQL或者套用一个网上的方案。很多朋友喜欢把问题归因到“row_number不适合分页”,然后改写成临时表方案或OFFSET FETCH——如果根因没有解决,这种改动最后大概率是白费功夫。
如果你按照上面的思路排查完仍然没有改善,可以再检查一下tempdb的配置和磁盘I/O,因为排序操作如果spill到磁盘,tempdb所在的硬盘类型(机械硬盘/SSD/NVMe)会导致数量级的性能差异。我在机械硬盘环境里处理过类似问题,换成SSD之后即便不改索引和SQL,查询耗时也能大幅下降。但最终能稳定把执行时间控制在百毫秒级的,还是索引和写法的共同作用。
对了,最后补充一个实用小技巧:在你把优化后的SQL交代给同事或写进项目文档时,最好把当时优化前后的执行计划截图一并保存。因为你永远想不到什么时候项目重构、数据量变化或者Sql Server版本升级会再次把性能拖垮,那时候这两张图就是你回溯问题最直接的证据。
