ROW_NUMBER()是SQL Server 2005+最稳妥的分页方案,需三层嵌套、ORDER BY唯一兜底(如created_at DESC, id DESC)、WHERE下推至内层;禁用NOT IN/双TOP等O(n²)写法,总数统计须异步或缓存。

用 ROW_NUMBER() 是当前最稳妥的选择
SQL Server 2005 及以后版本,ROW_NUMBER() 是唯一能兼顾通用性、正确性和可维护性的分页手段。它不依赖主键是否连续、排序字段是否唯一,也不需要动态拼接 NOT IN 或嵌套 TOP —— 这些都是老式写法在千万级数据下容易翻车的根源。
常见错误现象包括:最后一页重复取前一页数据、相同排序值导致行序错乱、NOT IN 子查询无法走索引拖慢至秒级以上。
- 必须把排序字段和主键(或其它唯一列)一起写进
ORDER BY子句,例如ORDER BY CreateTime DESC, ID DESC,否则ROW_NUMBER()在重复值场景下结果不可控 - 不要在
ROW_NUMBER()的子查询里加WHERE条件后直接分页;应先过滤再编号,否则@start_row计算会失准 - 若排序字段含
NULL,需显式声明NULLS LAST行为(SQL Server 不支持该语法),实际要用IS NULL拆解或用CASE调整顺序
sp_executesql 动态拼接时必须参数化
通用分页存储过程必然要接收表名、字段、WHERE 条件等运行时参数,但直接字符串拼接 @sql = 'SELECT ... FROM ' + @table_name 会引发注入风险,且执行计划无法复用。
正确做法是只对**结构固定**的部分用变量拼接(如表名、字段列表),而**值类条件**全部交给 sp_executesql 的参数占位符处理。
- 允许拼接的:表名(需用
QUOTENAME()包裹)、排序字段名(同样QUOTENAME())、WHERE片段(确认已过滤掉单引号和注释) - 禁止拼接的:任何用户输入的值(如日期、ID、关键词),必须作为
@param_definition和参数传入 - 性能影响:未参数化的动态 SQL 每次都生成新执行计划,缓存碎片严重;千万级分页下,首次编译耗时可能超 500ms
避免 NOT IN 和双 TOP 嵌套
这类写法在早期博客中泛滥,但它们在千万级数据上本质是 O(n²) 复杂度。比如 NOT IN (SELECT TOP X id...) 中的子查询每次都要全表扫描,且无法利用索引跳过已读行。
典型错误示例:SELECT TOP 10 * FROM t WHERE id NOT IN (SELECT TOP 990 id FROM t ORDER BY id) —— 第 100 页就要扫 1000 行主键,第 10000 页就扫一千万行。
-
NOT IN遇到NULL直接返回空结果集,静默失败,极难排查 - 双
TOP(掐头去尾)在排序字段非唯一时结果不稳定,同一语句多次执行可能返回不同行 - 即使加了索引,SQL Server 优化器也常放弃索引查找,改走聚集索引扫描
总记录数统计必须异步或缓存
每次分页都执行 SELECT COUNT(*) 是千万级场景下的最大性能黑洞。它强制全表扫描,且与分页主查询无法共享执行计划。
真实业务中,用户并不需要精确总数(比如“共 9,876,543 条”),而是需要“是否有下一页”。所以应区分对待:
- 前端只需“下一页”按钮开关?那就查
SELECT TOP (@page_size + 1) ...,取到@page_size + 1行即知有下一页 - 必须显示总数?考虑用近似值:
sys.dm_db_partition_stats中的row_count字段(误差可接受,快 100 倍) - 高频访问的总记录数,可单独起作业每 5 分钟更新一次到配置表,查分页时连表 JOIN 即可
最容易被忽略的是:即使用了 ROW_NUMBER(),如果还在同一个存储过程中同步跑 COUNT(*),那前 10 次分页请求就会把 I/O 打满。分页不是原子操作,总数和数据要拆成两个逻辑路径。

















