提到 SQL Server 分页,上周还有位老同事在群里吐槽:同一个分页需求,新项目跑在 SQL Server 2019 上,直接写 OFFSET FETCH 就行;老系统还在 SQL Server 2008 R2,只能翻出 ROW_NUMBER() OVER(...) 的老黄历。更离谱的是,还有人拿 2000 时代 TOP + NOT IN 的写法来指导,理由是“我们一直这么写”。数据库分页这个主题能在我的笔记里排到第 80 章,一点也不奇怪——它表面上是“第几页、每页多少条”的简单问题,背后却连着排序稳定性、索引设计、并发隔离、前端组件和 ORM 翻译,哪一环脱节都会在线上炸。
这篇文章给准备写分页的人一个足够落地的参考。不管你是新手还是已经在 SQL Server 上折腾过几年的开发,我都会从最常见的三种写法讲起,再重点解释深分页为什么慢、Keyset 分页什么时候能救场、并发下为什么会出现重复和跳行,以及我实际排查过的慢分页问题。文章里的 SQL 示例都基于 SQL Server 2008 R2 到 2022 都能跑的范围内分别标注,方便你照着自己的版本选用。
1. 三代分页写法的历史包袱与选型前提
1.1 先定义清楚:分页不是“跳过数据”,是排序后截取片段
刚开始写分页的人最容易犯一个概念错误:以为数据库分页就是把所有数据加载到内存,然后按数组下标切一段。真要在 SQL Server 里做高效分页,核心是只把当前页面需要的那一段结果集返回给应用,而“段”这个概念必须建立在稳定的排序之上。
也就是说,分页 SQL 一定要有明确的 ORDER BY。没有 ORDER BY,SQL Server 并不保证两次查询返回相同的顺序。别以为“反正我按主键排序就行”,等查询并行跑起来、统计信息变一下、执行计划换成并行扫描,行顺序就可能漂移。前端就会看到第一页和第二页内容重合,或者某些行永远看不见。这不是并发问题,是逻辑设计问题。
所以,我接到分页需求的第一件事就是问:业务上到底按哪个字段排序?这个字段有没有重复值?如果没有唯一性,我会顺手把主键加在排序字段末尾,比如 ORDER BY create_time DESC, id DESC。这个习惯能避免掉绝大多数的“翻页重复/漏行”投诉。
1.2 从 TOP + NOT IN 到临时表:老代码为什么让人头疼
在 SQL Server 2000 和早期的 2005 里,没有 ROW_NUMBER,也没有 OFFSET,最早实现分页的思路就是把“当前页之前的所有主键”先查出来,再往外排除:
sql复制SELECT TOP 20 order_id, order_no, amount
FROM dbo.orders
WHERE order_id NOT IN (
SELECT TOP 40 order_id
FROM dbo.orders
ORDER BY order_id DESC
)
ORDER BY order_id DESC;
这段代码逻辑没错,但性能极差。子查询里要先把前 40 个 order_id 全部取出来,外层再和整表做 NOT IN 比较。如果表的行数到了几十万,这个 NOT IN 的代价会直线上升。更麻烦的是 NOT IN 遇到 NULL 时会出现“结果集为空”的语义问题,虽然 order_id 是主键一般不会为 NULL,但这个坑确实存在。
同一时期还流行用临时表分页,先给临时表加一个自增序号,再按序号范围取:
sql复制CREATE TABLE #tmp (
rn INT IDENTITY(1,1),
order_id INT
);
INSERT INTO #tmp (order_id)
SELECT order_id FROM dbo.orders ORDER BY order_id DESC;
SELECT * FROM #tmp WHERE rn BETWEEN 21 AND 40;
临时表方案在小数据量下能用,问题是每次翻页都要把全部主键灌进临时表,数据量大以后磁盘 IO 和 tempdb 压力都受不了。这类写法现在没必要再用,但如果你维护的是十几年的老系统,看到这些 SQL 时至少要知道它为什么慢。
1.3 ROW_NUMBER 与 OFFSET/FETCH:版本决定的“能不能用”
SQL Server 2005 引入了窗口函数,这才是现代分页的起点。用 ROW_NUMBER 分页的标准写法是:
sql复制;WITH paged AS (
SELECT order_id, order_no, amount,
ROW_NUMBER() OVER (ORDER BY create_time DESC, order_id DESC) AS rn
FROM dbo.orders
)
SELECT order_id, order_no, amount
FROM paged
WHERE rn BETWEEN 21 AND 40
ORDER BY create_time DESC, order_id DESC;
这个写法的好处是逻辑清晰,内层给整个结果集编号,外层截取想要的行号区间。但它的潜在问题也在这里:只要结果集有 100 万行,它基本就要先算出这 100 万行的编号,再扔掉前 20 行取目标段。能不能快,完全取决于 ORDER BY 是否能走索引、SELECT 的列是否都在索引里。
SQL Server 2012 起增加了标准化的 OFFSET/FETCH 子句,代码缩短成:
sql复制SELECT order_id, order_no, amount
FROM dbo.orders
ORDER BY create_time DESC, order_id DESC
OFFSET 20 ROWS
FETCH NEXT 20 ROWS ONLY;
OFFSET 表示跳过多少行,FETCH 表示取多少行。这套语法接近 PostgreSQL、Oracle 12c 等数据库的 limit 语法,可读性比 ROW_NUMBER 高出一大截。需要特别记住的是,OFFSET/FETCH 在 SQL Server 里强制要求前面必须有 ORDER BY,否则直接报错。
版本边界是硬性的:SQL Server 2012 以下只能老老实实 ROW_NUMBER。如果公司还在用 2008 R2,别拿 OFFSET 去炫,编译都过不了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. OFFSET/FETCH 和 ROW_NUMBER 的取舍:版本、排序字段与执行计划
2.1 OFFSET/FETCH 也不是“秒开”的万能药
很多人看到 OFFSET/FETCH 简洁,就以为它能像数组下标那样直接定位到百万行之后。不是这样。SQL Server 执行 OFFSET/FETCH 时,会把排序后的行集从头开始计数,跳过 OFFSET 指定的行数。如果 ORDER BY 能走索引,数据库是沿着索引顺序一段段扫过去的,跳过的行不会通过随机 IO 逐行回表,但扫描量依旧随着页数增加而增加。
如果 ORDER BY 字段没有匹配的索引,那么更糟。SQL Server 要先做一个完整的 Sort 操作,才能知道哪 20 行属于目标页。排序的数据量可能是整张表或某个大分区。这种情况下,第 1 页和第 1000 页的性能可能相差几百倍。
我见过很典型的生产事故:表里只有 300 万行,但 WHERE 条件过滤性很差,ORDER BY 又是一个普通非索引字段。业务方翻到第 500 页时,单条查询吃了 8 秒。有人误以为是数据库配置问题,实际上就是 Sort + 大偏移量搞出来的。
所以我的判断标准是这样的:如果分页深度浅,或者 WHERE 条件后返回结果集很小,OFFSET/FETCH 完全够用;如果业务里真的允许用户翻到上万页,就必须考虑 Keyset 甚至限制深度。
2.2 ROW_NUMBER 仍然有用的三类场合
ROW_NUMBER 看起来是“上一代方案”,但在不少场景里它依然无可替代。
第一,SQL Server 2008 R2 及以下没有 OFFSET/FETCH,只能用它。第二,当分页之前还要做去重、打序号、计算总和时,窗口函数往往能在一次扫描里完成,比如先给明细行标号,再取每个客户的前 10 条订单。这种情况不是单纯分页,而是“分组 TopN”,ROW_NUMBER 是正解。第三,很多后台批处理任务需要分批更新数据,用 ROW_NUMBER 把主键切段,比每次 OFFSET 要稳。
对老库做代码评审时,我一般不主张把能跑的 ROW_NUMBER 全部改成 OFFSET/FETCH。只有当你发现它确实成为性能瓶颈、而且 SQL Server 版本支持的时候,才值得改。技术选型最怕为了“新语法好看”去动线上稳定代码。
2.3 直接抄作业的选型表
| 对比维度 | OFFSET/FETCH | ROW_NUMBER |
|---|---|---|
| 最低版本 | SQL Server 2012 及以上 | SQL Server 2005 及以上 |
| 语法可读性 | 高,接近标准 SQL | 中,需要 CTE/子查询包一层 |
| 排序稳定性 | 依赖 ORDER BY 完整性 | 依赖 ORDER BY 完整性 |
| 深分页性能 | 偏移量大时成本线性上升 | 结果集大时同样线性上升 |
| 复杂窗口计算 | 不支持 | 支持 |
| ORM 兼容性 | 较新框架通常生成此语法 | 老框架常见生成方式 |
| 我推荐的场景 | 新项目、翻页不深、支持 2012+ | 老库、分组 TopN、窗口聚合 |
实际写的时候还有一个通用原则:所有分页查询的排序字段都应带上唯一值兜底。比如 ORDER BY create_time DESC 看起来没问题,但同一秒创建的数据非常多,翻页就可能抖。改成 ORDER BY create_time DESC, order_id DESC 后,每行顺序唯一,翻页才稳定。
3. 深分页为什么会越来越慢:Keyset 分页的实战姿势
3.1 OFFSET 的时间成本不是 O(1),而是 O(N)
深分页的性能问题常年排在 SQL Server 慢查询榜单前列。原因在于 OFFSET 的行数和扫描量成正比。假设订单表有 1000 万行,每页显示 20 条,翻到第 50 万行,也就是第 25000 页时,数据库至少需要跳过 499980 行才能返回数据。如果这些行在一个紧凑索引上还好,一旦中间夹着大量回表操作,逻辑读会迅速膨胀。
我经常用一句话解释给产品经理听:数据库不是书本,页码不会印在纸面上,它必须从第一行开始数到目标位置。听到这个解释,不少产品经理就理解了为什么“翻到最后一页”这种功能要找产品方案化解。
3.2 Keyset 分页:基于上一页的最后一行继续往后找
要解决深度翻页,最有效的手段不是加索引硬扛 OFFSET,而是改成 Keyset 分页,也叫 Seek 分页或游标式分页。它不计算偏移量,而是带着上一页最后一条记录的排序键值去查下一页。
假设我们按 create_time DESC, id DESC 排序,上一页最后一行是 @last_create_time = '2024-06-01 10:00:00',@last_id = 123456,下一页就是:
sql复制SELECT TOP (20)
order_id, order_no, create_time, amount
FROM dbo.orders
WHERE create_time < @last_create_time
OR (create_time = @last_create_time AND order_id < @last_id)
ORDER BY create_time DESC, order_id DESC;
这里利用索引直接定位到上一页结束的位置,然后顺序向下取 20 条。翻页深度对查询成本的影响微乎其微,因为每一页都只需要做一个范围 Seek。我在测试环境里把一张 1000 万行表的偏移量从 0 增大到 900 万,普通 OFFSET 的逻辑读从几百涨到几万,Keyset 基本稳定在几十到几百。
上一页的实现也别慌,倒过来做就行:
sql复制SELECT TOP (20)
order_id, order_no, create_time, amount
FROM dbo.orders
WHERE create_time > @last_create_time
OR (create_time = @last_create_time AND order_id > @last_id)
ORDER BY create_time ASC, order_id ASC;
注意最后要把结果集在程序里反转顺序,再展示给用户,因为物理上是按升序取回的,升序取回是为了复用同一个索引范围。
3.3 Keyset 分页什么时候不能用
Keyset 不是银弹,它有非常明显的边界:不支持“跳到任意页”。前端如果给了用户一个页码输入框,或者用 Element UI 那种带“跳到第 5 页”组件的产品,Keyset 就尴尬了,因为你手里并没有第 5 页上一页的最后一行。
再有就是排序字段频繁更新。如果用户按“更新时间”排序,而表中已有记录的更新时间会不断变化,Keyset 条件可能把原本应该出现的行漏掉,或者让某一行在相邻页面重复出现。选择排序字段时必须选业务上几乎不变且有唯一索引约束的字段组合。实际项目里最稳妥的组合是 created_time + id 或单纯的 id。
所以我在产品评审时会先确认一个问题:你是真的需要“跳页”,还是只需要“下一页/上一页”?社交信息流、日志列表、通知中心这类场景本质上只需要无限滚动,Keyset 几乎是最优解。对账报表这类业务却常需要精确跳转,那时候宁可限制可翻页数,也别轻易抛弃 OFFSET 乱上 Keyset。
4. 并发写入下的分页边界:重复行与跳行问题的本质
4.1 为什么第一页和第二页会“打架”
开发分页功能时,很多人只盯着单条 SQL 的性能,忽略了并发写入会造成页面重复或跳行。我举个例子。表里有 30 条数据,按 id 升序每页 10 条。第一页查询返回 id 1 到 10。用户看完第一页,点击第二页前,另一个事务删除了 id=10 的行,同时插入一条 id=31 的新行。此时再执行 OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY,数据库从当前快照里排序后取数,得到的很可能是 12 到 21,而不是期望的 11 到 20。id=11 这一行就这么被“跳”过去了。
反过来也一样。如果用户在翻页间隙恰好插入了一条排在最前面的记录,第二页里就可能再次出现第一页已经见过的老数据。这不是 SQL Server 的 bug,而是分页请求天然无法保证跨语句一致性。两次 HTTP 请求之间的数据本来就发生了变化。
4.2 调整隔离级别能救吗?大多数时候不能
有人尝试用隔离级别去解决。先说结论:对于无状态 Web 接口,这问题基本无解,除非把两次查询放进同一个显式事务,并配合可重复读或快照隔离。
可重复读能保证事务内同一查询多次读取结果一致,但普通翻页请求是两次独立事务,谈不上“重复读”。快照隔离时,一个事务内的所有读取基于事务开始时的数据版本,如果前端能在一个长事务里翻页,确实能避免中间写入导致的漂移,但让一个 HTTP 请求去维持长事务,不仅连接池吃不消,还会放大 tempdb 的版本存储压力。生产上没人这么干。
更危险的是 WITH (NOLOCK)。它不等于“快照”,而是允许读到未提交的数据和处于移动过程中的行。在索引页分裂的瞬间,无锁扫描可能重复读某一行或直接漏掉某一行。搜索热词里“非分页缓冲池占用过高”是另一类内存问题,但 NOLOCK 引发的分页异常才是业务上最难排查的。
4.3 实践中的三种妥协方案
第一种,让排序键只依赖不会变化的字段。比如始终带上主键 id,并采用 append-only 风格的表结构。这样即使业务数据被修改,行的逻辑位置也基本固定。
第二种,接受“最终一致”的语义。对信息流、列表页而言,用户翻页时看到一条重复或漏掉一条,通常不会造成严重后果。提前在接口文档里写清楚“列表非实时精确”,产品团队也能接受。
第三种,真的不能接受时,就把结果集做快照。把用户翻页涉及的结果集主键缓存到 Redis 或临时表,页面不再实时查全量表。代价是缓存一致性和存储成本,但金融对账这类场景确实需要。
5. count(*)、隐式转换和执行计划:分页慢的三个隐形杀手
5.1 分页接口慢,往往先慢在 totalCount
大多数分页接口除了返回当前页,还要返回总条数 total,前端才能算出一共多少页。很多后端开发只优化了分页主查询,却放任 SELECT COUNT(*) FROM dbo.orders WHERE ... 裸奔。一张大表,WHERE 条件没有可用索引,count 要对所有满足条件的行做统计,慢起来比 OFFSET 还夸张。
如果总数只是用来显示“共 12345 条”,可以接受估算值时,SQL Server 有低成本的系统办法。比如不带 WHERE 时,用系统视图估算:
sql复制SELECT SUM(p.rows) AS estimated_rows
FROM sys.partitions p
JOIN sys.tables t ON p.object_id = t.object_id
WHERE t.name = 'orders'
AND p.index_id IN (0, 1);
但这种方法算不出复杂 WHERE 下的精确结果。大部分业务场景其实不需要精确 total,尤其无限滚动的产品只需要知道“还有没有下一页”。前端与其拿 total 去算,不如让后端多返回一页数据来探测 hasMore。也就是说,查询时取 pageSize + 1 条,如果取出 21 条,说明还有下一页,然后只返回前 20 条。这个方案能直接砍掉一次 count 查询,深翻页场景收益非常明显。
5.2 排序字段的隐式转换会让索引彻底失效
另一个隐形杀手是隐式转换。SQL Server 里最典型的是表中字段类型是 VARCHAR,查询参数却是 NVARCHAR。由于两种类型的排序规则优先级不同,SQL Server 会偷偷给列加一层 CONVERT_IMPLICIT,导致这一列上的索引无法被正常 Seek,只能 Scan。
分页查询里如果 WHERE 条件或 ORDER BY 字段命中隐式转换,执行计划会出现一个显眼的 Scan,后续还要 Sort。我曾经遇到过一张只有 80 万行的表,分页查询慢到 3 秒,去掉一个隐式转换后直接变成 50 毫秒。差别就在那个看不见的转换上。
排查方法很简单:抓实际执行计划,搜索 “CONVERT_IMPLICIT”,或者看谓词里是不是有 @param 包裹了列而不是 col 直接比较。如果发现字段是 VARCHAR,请确保传入参数也用 VARCHAR,或者干脆把表字段升级成 NVARCHAR,别让数据库每次都在比较时做转换。
5.3 用三条标准快速判断分页执行计划好坏
我拿到一张分页执行计划,不会先看图形上花哨的百分比,而是按顺序找三样东西。
第一,有没有 Sort 操作符。分页查询天然需要排序,但这个排序最好由索引的有序扫描来提供,而不是显式的 Sort。一旦看到 Sort,第一反应就是 ORDER BY 字段没走在索引上。
第二,有没有 Key Lookup 或 RID Lookup。查询返回的列如果不在索引里,SQL Server 每取到一行还要回表。回表率一高,就算索引 Seek 很准,总逻辑读还是压不下来。优先用覆盖索引把 SELECT 的列 INCLUDE 进去。
第三,有没有扫描行数远大于返回行数。比如取了 20 行,实际扫描了 300 万行,这就是深分页或势态不佳的典型迹象。用命令更能量化:
sql复制SET STATISTICS IO ON;
SET STATISTICS TIME ON;
执行完看逻辑读次数和 CPU 时间,逻辑读从几百涨到几万,基本就是扫描范围出了问题。
6. 一次线上“翻到第 300 页就超时”的完整排查过程
6.1 现场现象和第一波取证
去年排查过一个订单查询接口,运营后台按条件查历史订单,每页 20 条。前 50 页响应都在 200 毫秒左右,翻到 300 页以后经常超过 5 秒,到 500 页直接把连接池吃满,后续请求集体排队。
我先用扩展事件抓了那段时间最慢的 20 条 SQL,发现模板是:
sql复制SELECT order_id, order_no, customer_name, amount, create_time
FROM dbo.orders
WHERE shop_id = @shop_id
AND create_time BETWEEN @start_time AND @end_time
ORDER BY create_time DESC
OFFSET @offset ROWS
FETCH NEXT 20 ROWS ONLY;
产品为了“用户可以回到任意一页”,前端允许跳到第 300 页,所以 @offset 会到 5980。这是我第一轮看到的表面原因,但 offset 才 6000 行,理论上不至于慢到 5 秒,根因显然不只在偏移量。
6.2 定位 Sort、回表和参数嗅探
我打开实际执行计划,果然第一眼就看到两个大家伙:一个 Sort,一个 Key Lookup。表虽然 300 万行,WHERE 条件里 shop_id 有索引,但 ORDER BY create_time 的排序没法直接走同一个索引,SQL Server 选择先按 create_time 做一个大排序,再回表取 customer_name 和 amount。
继续看统计信息,发现这个 shop_id 下满足条件的数据有 25 万行。为了每页 20 条,数据库不得不先把这 25 万行全部做排序,再按偏移量跳过。页数越深,Sort 的累积 CPU 成本越高,于是出现 300 页后超时。
另外还隐含一个参数嗅探的坑:用户第一次查询时选的时间范围只有一天,SQL Server 为这个小结果集生成了计划,后来换到时间跨度一个月的查询,同一份计划依然在用,进一步放大了回表代价。
6.3 修复方案与前后对比
修复分了两步。第一步,新建复合索引,让 WHERE、ORDER BY 和回表列尽量都在一个索引里:
sql复制CREATE NONCLUSTERED INDEX ix_orders_shop_time
ON dbo.orders (shop_id, create_time DESC, order_id DESC)
INCLUDE (order_no, customer_name, amount);
这里刻意加上 order_id,是为了让排序唯一;加上 INCLUDE,是为了避免 Key Lookup。第二步,和产品确认翻页行为,把“跳页”限制在前 200 页,200 页之后禁止跳转并提示用户缩小时间范围。日期筛选控件也做了限制,默认最多查最近 90 天。
改完后同样的深翻页查询,逻辑读从原来的 6 万多次降到几百次,响应时间从 5 秒压到 100 毫秒以内。执行计划里的 Sort 和 Key Lookup 都消失了,变成了 Index Seek + Range Scan。
6.4 这次事故给我留下的排查习惯
以后再遇到分页慢,我不会先急着骂 OFFSET,而是把 SQL 模板拆开看三件事:WHERE 条件和 ORDER BY 能不能共用索引;SELECT 的列有没有被索引覆盖;深翻页是不是被业务允许的。很多时候,问题根本不在于分页语法,而在于索引设计和产品交互没有跟着数据量长大。
7. 和前端分页组件/ORM 打交道时,我坚持的几个约定
7.1 分页组件在 SQL Server 内部到底干了什么
前端常见 Element UI 的 el-pagination、Ant Design 的分页器,本质上只传 currentPage 和 pageSize。后端用 ORM 或分页插件把这两个参数翻译成 SQL。MyBatis-Plus、PageHelper 这类插件在识别到数据库是 SQL Server 时,会自动生成 ROW_NUMBER 或 OFFSET 的分页语句。
问题高发点在于数据库方言没配置对。同一个插件,配 MySQL 和配 SQL Server 生成的 SQL 完全不一样。如果项目里没指定 DbType.SQL_SERVER,插件可能按默认方言拼出奇怪的 SQL,甚至分页不生效。线上见过“分了页但返回全部数据”的案例,十有八九是方言配置缺失或者拦截器顺序不对。
7.2 常见 ORM 分页失效的排查顺序
被问得最多的问题是“MyBatis-Plus 分页突然失效”。我的排查顺序很固定:
第一,先打印最终执行的 SQL,看有没有 LIMIT/OFFSET/ROW_NUMBER。如果 SQL 里完全没有分页语法,那一定是分页插件没被拦截到,检查 MyBatis 拦截器配置、插件版本和 DbType。
第二,如果 SQL 分页语法有,但返回总条数不对,查 total 查询是否被插件拦截。部分插件对自定义 SQL 的 count 改写支持不好,遇到多表 JOIN、DISTINCT、UNION 时会统计错。
第三,排序字段有没有被参数化。如果排序字段是前端直接拼上来的字符串,插件不会帮你做白名单校验,SQL 注入风险也跟着来了。我建议后端接收的排序字段永远是一个枚举映射,比如 sort_field = 'createTime' 映射成 o.create_time,而不是直接拼接列名。
7.3 我和前端约定的分页接口规范
合作多了以后,我会把所有分页接口统一成下面这套规则,能让后面的排查省心很多。
页码从 1 开始,如果传 0 也当 1 处理。pageSize 不能无限放大,服务端强制 MIN(pageSize, 200)。正常情况下,响应结构包含 rows、total、pageNum、pageSize,同时额外给一个 hasMore。hasMore 的值可以是 pageNum * pageSize < total,也可以按“多取一条”的方式判断。对性能敏感且不需要跳页的列表,我推荐后者,因为可以少执行一次 count。
对于用户可搜索、可跳页的后台表格,我还会在查询条件里强制一个时间范围。没有范围限制的历史数据查询就是给深分页埋雷。产品上允许用户一年前的数据翻到几千页,数据库再优化也只能缓解,不能根治。
至于 PostgreSQL、Oracle 这些数据库,分页语法各有差异,但设计思路同样适用:先确定唯一排序键,再看是否需要 Keyset,最后用覆盖索引把回表干掉。这些原则放之四海都能减少半夜被叫起来看慢查询的次数。
