1. 分页需求的本质与场景分析
1.1 为什么分页查询这么受关注
做后端开发的几乎都绕不过分页。别管你是写管理后台的列表页、给移动端提供接口、还是做报表系统的数据浏览,分页都是标配功能。而且我敢说,大多数团队在分页这块都没有认真研究过,用的是最朴素的写法,等数据量上来之后才发现查询越来越慢,再去改往往就要动不少代码了。
SQL Server里分页其实不太复杂,但网上资料比较杂,有讲ROW_NUMBER的,有讲TOP的,有讲OFFSET FETCH的,还有一堆讲存储过程分页的。这章就把SQL Server分页这件事从原理到写法,从性能对比到坑点,一次性讲透。
先说清楚一个概念:分页要解决的核心问题,不是怎么“切”数据,而是怎么“高效地切”数据。前10条很好取,前10000条之前的第1000条到1010条,就不是那么简单的事了。因为数据库要把前面的数据全都数一遍,才知道该从哪开始取,这个“数一遍”的成本,才是分页性能变差的根源。
1.2 适合阅读本笔记的读者
这篇笔记适合三类人看。第一类是刚接触SQL Server的学生或转行开发者,需要把分页语法和原理弄明白;第二类是写了好几年业务代码但一直没深入研究过分页性能的.NET/Java开发,看完能对自己的代码做一些优化;第三类是DBA或偏后端的工程师,想要系统梳理分页方案,至少能搞清楚不同场景下该用哪种写法。
我尽量把这块内容写得既有理论又有实操。理论部分会解释SQL Server是怎么执行分页语句的,实操部分会给出可以直接抄的脚本,中间还会穿插一些我自己踩过的坑和性能测试数据。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SQL Server分页技术的演进与选型解析
2.1 三种主流分页写法的时间线
SQL Server 2005之前,分页主要靠TOP加NOT IN或者临时表,写起来很别扭。2005开始引入ROW_NUMBER() OVER()窗口函数,这才有了比较像样的通用分页写法,也是目前很多老项目里最常见的。到了2012,SQL Server正式引入OFFSET FETCH子句,语法大幅简化,这才是现代SQL Server分页的官方推荐姿势。
我还见过用游标分页的,但这属于反面教材,基本只存在于考古级别的老系统里,就不推荐了。
2.2 三种写法的核心逻辑拆解
先说TOP + NOT IN,这是最古老的一批写法。思路是先取前N条,再取不在这些ID范围里的前M条,SQL大致长这样:
sql复制SELECT TOP 10 *
FROM dbo.Orders
WHERE OrderId NOT IN (
SELECT TOP 20 OrderId FROM dbo.Orders ORDER BY OrderId
)
ORDER BY OrderId;
这种写法的数据量一大,NOT IN子查询会把前20个ID全部列出来,数据越多临时计算量越大。而且遇到NULL值还有坑,NOT IN的后半段子查询一旦结果集里包含NULL,整个查询一行都查不出来。这也是我建议所有还在用这种写法的人尽快迁移的原因。
然后是ROW_NUMBER写法,核心是用窗口函数给每一行编一个序号,然后在外面套一层查询,筛选出序号在两个边界之间的行:
sql复制SELECT *
FROM (
SELECT *, ROW_NUMBER() OVER (ORDER BY OrderId) AS RowNum
FROM dbo.Orders
) AS t
WHERE RowNum > 20 AND RowNum <= 30
ORDER BY OrderId;
这种写法是2005到2012之间的标准答案。但它有个隐藏问题:外层查询拿RowNum筛选时,SQL Server往往要在内层先完成全表的排序和编号,这意味着随着页码增大,排序消耗会越来越高。
OFFSET FETCH是2012及以后版本的推荐写法。语法本身像英语一样直白:
sql复制SELECT *
FROM dbo.Orders
ORDER BY OrderId
OFFSET 20 ROWS
FETCH NEXT 10 ROWS ONLY;
OFFSET表示跳过多少行,FETCH NEXT表示取多少行。这语法的好处是逻辑清楚,SQL Server的优化器对它的处理也比老写法更直接。不过要注意一点:OFFSET FETCH必须搭配ORDER BY使用,否则SQL Server会直接报错。这个限制其实仔细想也合理,分页本身必须定义顺序,没有顺序的分页没有任何意义。
2.3 为什么现在优先推荐OFFSET FETCH
经过我反复压测,OFFSET FETCH在中等数据量和简单排序场景下,执行计划和ROW_NUMBER写法其实是一样的。但它的优势在于代码简洁、写起来不容易出错,而且对未来的SQL Server版本兼容性更好。微软从2012年引入这个语法后,一直作为官方主推的分页方式,后续版本也在持续优化。
但要注意版本兼容:如果你的项目还跑在SQL Server 2008 R2或者更老的版本上,OFFSET FETCH是用不了的,只能选ROW_NUMBER。我在实际工作中确实见过还有不少企业用SQL Server 2008 R2,甚至2005的也不是完全绝迹。所以具体选哪个方案,先看版本再决定,不要拍脑袋。
3. 分页查询的性能瓶颈与优化策略
3.1 深分页问题是怎么产生的
分页慢,最典型、最痛的表现就是页数越深越慢。第一页可能几毫秒就返回了,翻到第100页、第1000页的时候就出现秒级延迟。为什么?因为数据库没有“跳到第1000页开头”这种能力,它必须前前后后扫描、排序,把第1页到第999页的数据全部过一遍才能定位到第1000页。
我用日常场景打个比方,OFFSET FETCH这种方式相当于一本没有目录也没有页码的书,你要读第1000页,就得从第1页一页页翻到第1000页。而我们要做的优化,要么是给书加一个“页码标记”直接翻过去,要么是想办法让翻页过程更快。
3.2 索引对分页的影响到底有多大
分页查询的ORDER BY字段有没有索引,几乎决定了这条查询的命运。先看一个反面案例:
sql复制SELECT *
FROM dbo.Orders
ORDER BY CreatedAt DESC
OFFSET 100000 ROWS
FETCH NEXT 20 ROWS ONLY;
如果CreatedAt没有索引,SQL Server只能先把整张表按CreatedAt做完排序,然后再跳过去取20条。排序是内存和CPU的大户,表数据量大的时候,这条语句跑个几十秒都不奇怪。
加了索引之后,情况完全不同。SQL Server可以直接沿着索引的顺序去扫描,省掉一次全表排序。在一些压测场景中,同样的深分页语句,建了合适索引之后能快几十倍甚至上百倍。这不是夸张,是实打实能复现的差距。
但还有更隐蔽的问题——书签查找。我们写SELECT *的时候,SQL Server即使走了索引,也还要根据索引里的主键或其他关键列回表取一次完整的数据行。索引页和表数据之间来回跳,性能消耗同样不容小觑。后面我会专门讲这个。
3.3 常见性能优化方案横向对比
我在网上也常看到有人讨论分页慢怎么优化,说用Redis提前缓存分页结果解决的。这种做法在少数固定查询场景下管用,比如排行榜前100页几乎不变。但更多业务场景下,数据频繁变动、翻页条件动态变化,Redis缓存反而容易给一致性挖坑。所以我一般是把它们分开处理:动态查询走数据库分页优化,极少数高热、静态的内容才考虑用Redis加速。网上那些“用Redis优化分页查询”的说法,都要基于具体业务判断,不要照搬。
这里我整理了一个对比表格,方便你快速选择方案:
| 方案 | 适用版本 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| TOP + NOT IN | 2000及以后 | 基本不推荐 | 没有额外复杂度 | 性能差,NULL坑多 |
| ROW_NUMBER | 2005及以后 | 老版本兼容 | 通用性好,写法灵活 | 深分页性能一般 |
| OFFSET FETCH | 2012及以后 | 多数业务分页 | 语法简洁,性能可接受 | 深分页仍需优化 |
| 键集分页 | 所有版本 | 海量数据+高频深分页 | 深分页性能稳定 | 只支持单字段稳定排序 |
| 临时表/表变量 | 所有版本 | 超大数据量复杂查询 | 可配合复杂业务逻辑 | 额外IO开销,维护成本高 |
键集分页这个词很多人不熟,下一节我详细讲。它是深分页场景下我认为最值得掌握的方案。
4. 实操:一套完整的分页方案落地过程
4.1 建表与数据准备
实操阶段,先用一张模拟订单表来做测试。表结构尽量贴近真实业务,包含主键、订单号、用户ID、订单金额、创建时间几个字段。然后往表里插入大约50万条模拟数据。
建表脚本如下:
sql复制CREATE TABLE dbo.Orders (
OrderId INT IDENTITY(1,1) PRIMARY KEY,
OrderNo NVARCHAR(32) NOT NULL,
UserId INT NOT NULL,
Amount DECIMAL(18,2) NOT NULL,
CreatedAt DATETIME2 NOT NULL DEFAULT SYSDATETIME()
);
插入数据的脚本可以用递归CTE或循环来批量生成。我习惯用一条递归CTE快速插入几十万行测试数据,这样能模拟比较真实的数据量级。这里需要注意,递归CTE默认有100层的限制,需要加OPTION (MAXRECURSION 0)解除。
4.2 三种写法的性能实测对比
数据准备完成后,我写了三条对应的分页语句,统一取第50000条附近的10条数据,也就是OFFSET 500000,FETCH 10那种层级。然后开启统计开关看执行时间。
开启统计的命令:
sql复制SET STATISTICS TIME ON;
SET STATISTICS IO ON;
首先测TOP + NOT IN,为了取第50000页附近的数据,NOT IN里要查前50万条ID,这种写法在几十万数据下已经明显吃力,逻辑读取次数飙升到几万,执行时间在几百毫秒甚至秒级以上。
然后测ROW_NUMBER版本,它的执行计划里多了一个“Sequence Project”计算行号的算子,加上Sort排序,执行时间比TOP+NOT IN有明显改善,但仍然会全量排序,耗时依然不算乐观。
最后测OFFSET FETCH,在执行计划里能看到的是表的扫描或索引扫描加TopN Sort,逻辑读取次数和耗时都优于前两种。由于这里只按OrderId排序,主键是有序的,SQL Server能直接利用聚集索引的顺序,所以性能差距会更加明显。
为了公平,再补充一个场景:按CreatedAt排序并按时间范围过滤统计某个月的数据,这更贴近真实生产环境。这种情况下OFFSET FETCH依然更稳,但差距没有前面那么夸张,说明在复杂查询里,索引的影响会比语法本身更大。
4.3 键集分页:深分页场景的终极大招
要说深分页,最稳、最推荐的是“键集分页”,也叫seek method。核心思想是:不记录“我要翻到第几页”,而是记录“上一页最后一条数据的主键或排序键值”,然后下一页的查询直接定位到这个值之后的数据。
举个例子,前端翻页时,把上一页最后一条OrderId传回来,下一页的SQL这样写:
sql复制SELECT TOP 10 *
FROM dbo.Orders
WHERE OrderId > @lastOrderId
ORDER BY OrderId
OFFSET 0 ROWS
FETCH NEXT 10 ROWS ONLY;
这里的@lastOrderId就是上一页最后一条记录的OrderId。因为WHERE条件里已经用到了主键的大小过滤,SQL Server可以直接利用聚集索引跳到指定位置附近,然后顺序向后读取,完全不需要跳过前面几十万行。这就好比给书加了一个“书签”,翻页时直接从书签位置开始往下读。
这个方案的有一个前提:排序字段必须唯一且稳定。实际业务中,直接用主键排序最省事。如果业务还要按创建时间排,那通常要把“创建时间+主键”组合起来,利用复合索引保证排序唯一,避免因为排序字段重复导致分页数据错乱或重复。
键集分页代码示例(多字段排序版本):
sql复制DECLARE @lastCreatedAt DATETIME2 = '2024-06-01 10:30:00';
DECLARE @lastOrderId INT = 100234;
SELECT TOP 10 *
FROM dbo.Orders
WHERE CreatedAt > @lastCreatedAt
OR (CreatedAt = @lastCreatedAt AND OrderId > @lastOrderId)
ORDER BY CreatedAt ASC, OrderId ASC;
这种情况要注意一个点:上一页最后一条数据和这一页第一条数据之间,如果在新数据插入后有变化,页面上的总数量和跳页逻辑会稍微复杂一点。但很多业务场景其实只需要“下一页”和“上一页”,不需要直接跳数字页码,键集分页用起来就很顺手。
4.4 数据量再大:临时表和分页缓冲池的取舍
有些场景下,查询本身就非常复杂,比如要关联七八张表、还要做各种聚合计算,即使加了索引,性能依然上不来。这时候可以考虑把符合条件的数据先灌到临时表或表变量里,然后对临时表做分页。
做法是先建一个临时表,把要展示的字段和排序键都放进去:
sql复制CREATE TABLE #PageData (
RowId INT PRIMARY KEY,
OrderId INT,
OrderNo NVARCHAR(32),
UserId INT,
Amount DECIMAL(18,2),
CreatedAt DATETIME2
);
INSERT INTO #PageData (RowId, OrderId, OrderNo, UserId, Amount, CreatedAt)
SELECT ROW_NUMBER() OVER (ORDER BY CreatedAt DESC, OrderId DESC),
OrderId, OrderNo, UserId, Amount, CreatedAt
FROM dbo.Orders
WHERE CreatedAt >= '2024-01-01';
SELECT *
FROM #PageData
WHERE RowId > 50000 AND RowId <= 50010
ORDER BY RowId;
临时表方案的优点是把复杂的排序计算只做一次,后续每次翻页都只查这张小表,翻页速度极其稳定。缺点也很明显,数据量特别大时,临时表本身的构建和存储也会消耗不少IO和时间。所以它适合那些“一次查询、多次翻页”的场景,比如复杂的报表查询。如果每次翻页都重新构造临时表,那反而会慢。
至于“分页缓冲池占用很高”这个网上经常搜到的话题,本质上说的是SQL Server内存中数据页缓存被大量占用。分页查询本身跟这个没有直接关系,真正吃缓冲池的是频繁扫描大表或者大量读取未命中索引的数据页。优化方向还是落回索引和查询本身的命中率,没必要单独为分页去调缓冲池。
4.5 一个容易被忽略的细节:ORDER BY的稳定性
这算是分页里最隐蔽的坑之一了。如果你的ORDER BY字段不是唯一的,那么同一个查询,翻到下一页时,可能会出现上一页最后一条数据跑到下一页的情况,或者反过来重复显示一条数据。
举个例子,ORDER BY CreatedAt,而同一秒内可能有几十条订单。SQL Server在处理时,这些相同时间的记录谁先谁后没有严格保证,翻页边界就变得模糊。解决方式很简单,排序字段永远带一个主键作为次级排序,保证顺序唯一且可预测:
sql复制ORDER BY CreatedAt, OrderId
这是个成本极低但收益极大的改动,项目里凡是分页相关的查询,我都建议遵循这个规则。
5. 常见问题与排查技巧实录
5.1 分页查询总超时的排查思路
真遇到线上分页慢的问题,先别急着改代码。我的排查顺序是:第一看执行计划,第二看IO统计,第三看索引。
执行计划里最需要关注的是有没有大面积的扫描,以及是否存在Sort操作。如果Order By字段没有索引,Sort操作会消耗大量内存和CPU。如果看到的是RID Lookup或Key Lookup,说明索引覆盖度不够,查询正在频繁回表。
IO统计这个工具很实用。看逻辑读取次数,如果超过几千甚至上万,大概率是索引失效或者走了全表扫描。一般通过加索引或者改写成键集分页都能把逻辑读降下来。
还有一种常见情况,搜索结果里有类似“wait on the database engine recovery handle failed”这种报错,这不是分页本身的问题,是SQL Server服务启动时恢复流程卡住了,通常是数据库日志损坏或磁盘异常导致。分页查询慢和这种启动错误不要混淆,排查方向完全不同,别把时间浪费在错误的方向上。
5.2 老版本SQL Server没有OFFSET FETCH怎么办
很多老系统的运行环境还是SQL Server 2008 R2甚至2005。OFFSET FETCH语法写了直接报错,看到“Incorrect syntax near 'OFFSET'”这类错误就知道是版本不支持。这种情况下老老实实用ROW_NUMBER方案,别为了“新语法”强行升级生产环境。升级数据库版本是个大工程,牵扯到兼容性、授权、迁移,不是单纯为了一个分页语法就值得冒险的。
5.3 使用DBeaver或其它工具连接分页查询的区别
有些开发习惯用DBeaver或Navicat这类可视化工具维护数据。工具层面看到的查询结果,和你在代码里执行的SQL没有本质区别,只是客户端会额外显示受影响的行数和耗时。有一个小坑是:DBeaver默认有时会自动给大结果集套上它自己的分页机制,导致你执行一条没有ORDER BY的查询时,工具端看到的结果集顺序可能跟你预期不一致。排查环境问题的时候,先在SSMS里跑一遍,能排除很多干扰因素。
5.4 高频分页场景前端搭配问题
这里也顺带提一句,热词里出现了Element UI的分页组件、el-select的数据量大是否能分页之类的关键词。前端分页组件和后端的分页写法,表面上看起来是两回事,但本质上是一次“数据请求约定”的配合。前端需要把pageSize和pageIndex传给后端,后端按照约定返回total和records。如果后端接口分页SQL写得烂,前端组件再漂亮,页面体验照样卡成幻灯片。Element UI的el-select遇到海量选项时,也要靠后端接口支持远程搜索和分页,不要把上万条数据一次性塞到前端下拉框里去“分页”。
说到前端,打印分页也常有人问,那个是CSS媒体查询和分页符控制的问题,和数据库分页不是一个层面。做报表打印时,后端返回全量数据给前端,前端再按A4纸高度自动切页,这种场景下不要直接用数据库的分页接口一封页一次请求,效率太低了。
6. 分页写法的日常最佳实践
6.1 我平时写分页遵循的几个准则
总结下来,我的分页代码基本遵循这么几个准则:一是优先用OFFSET FETCH,但要先确认版本在2012以上;二是排序字段一定带上唯一键,保证顺序稳定;三是深分页场景不要硬跳,改用键集分页;四是所有分页查询都去检查执行计划,有没有Sort、有没有Lookup;五是表结构设计上尽量让常用排序字段参与索引覆盖。
这些准则每一条都踩过不少坑才沉淀下来。以往在项目里看到深分页卡顿,十次有九次都是没做键集分页还在硬用OFFSET。把排序字段和索引调整好之后,查询速度的提升是肉眼可见的。
6.2 扩展场景:Oracle分页和MyBatis Plus分页的互相参考
顺带说一下网上经常一起搜的Oracle分页和MyBatis Plus分页。Oracle的经典分页是用ROWNUM或者新的FETCH FIRST语法,跟SQL Server的ROW_NUMBER思路其实是同一个逻辑。MyBatis Plus的分页插件底层,如果是MySQL就是LIMIT,如果是SQL Server就是OFFSET FETCH,但它帮你封装好了,Java开发基本不用手写分页SQL,只要配置好分页插件和Dialect就行。
这里踩过一个印象很深的坑:MyBatis Plus配置分页后,如果排序字段在实体类里没有正确映射成数据库列名,会出现“列名无效”的报错。排查时多看一下控制台打印的SQL,多半能定位到是排序字段映射的问题。
如果你在项目里还集成了Redis,并且真的想用Redis做分页加速,我的建议是要么存已经组装好的页面结果,并设置合理的过期时间;要么用有序集合ZSet存储ID列表,分页时先从Redis取ID,再回数据库查明细。直接存全量数据对象会大量浪费内存,而且业务数据一旦频繁更新,缓存一致性问题会让人头大。
6.3 分页功能的测试关注点
写分页功能时,测试的关注点也要到位。一般我至少会测这几类场景:第一页返回是否正常;最后一页返回的数据量是否小于页面大小;总页数计算是否正确;删除中间数据后翻页是否出现数据重复或丢失;多字段排序下翻页是否稳定。这些场景在真实业务里太容易出问题了,不是代码没报错就代表分页是对的。
6.4 最后补一个常用脚本速查
为了方便日常查阅,我把常用的分页SQL汇总成一个速查表:
| 场景 | 推荐写法 |
|---|---|
| SQL Server 2012+普通分页 | ORDER BY ... OFFSET x ROWS FETCH NEXT y ROWS ONLY |
| SQL Server 2005-2008 R2 | ROW_NUMBER() OVER (ORDER BY ...) 实现分页 |
| 深分页 / 大数据量 | 键集分页,WHERE排序键 > 上一页最后一条 |
| 复杂查询多次翻页 | 先灌临时表,再对临时表分页 |
| 前后端配合 | 确认pageIndex/pageSize/total字段约定,接口返回统一结构 |
| 排序字段唯一性 | 排序字段追加主键,避免重复数据乱序 |
我个人在实际操作中最深的体会是:分页方案的选型一定要比业务数据量“提前半步”。不要等到表里几百万行数据才想起优化分页,那会儿改造成本已经翻倍了。写查询的时候多看一眼执行计划,多比一次IO开销,比事后加班救火划算得多。硬要翻到几百页之后还在用OFFSET猛跳的,真心建议换成键集分页,哪怕改动一下前端翻页组件的交互逻辑,也值得。
