提到 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 B
