offset/fetch最简洁但深分页慢;row_number()兼容老版本且可返回总数,但排序不稳定易翻车;动态拼接必须用quotename()防注入;深分页应改用游标分页。

OFFSET/FETCH 在 SQL Server 2012+ 中最简洁,但页码越大越慢;ROW_NUMBER() 兼容老版本且能一并返回总条数,但排序不稳定就翻车;动态拼接表名或条件时不用 QUOTENAME(),等于直接交出数据库控制权。
用 OFFSET/FETCH 做基础分页(SQL Server 2012+)
这是语法最干净、执行计划最透明的写法,但有两个硬约束必须满足:
-
ORDER BY不可省——缺了直接报错:Msg 107, Level 15, State 1, Procedure ..., Line X: The ORDER BY clause is mandatory for this statement. -
OFFSET值必须是(@PageNumber - 1) * @PageSize,不是@PageNumber * @PageSize,否则跳过第一页 - 排序字段最好有索引,否则百万行以上排序开销会吃掉大部分响应时间
- 不支持单次查出总条数,得额外跑一遍
COUNT(*),网络往返和锁竞争都多一次
用 ROW_NUMBER() 实现带总数的分页(SQL Server 2005+)
适合需要“当前页数据 + 总共多少条”一起返回的场景,但稳定性比 OFFSET/FETCH 更敏感:
- 必须显式指定
ORDER BY字段,不能只写ORDER BY (SELECT NULL),否则结果不可预测 - 排序字段组合要能唯一确定每行顺序,比如
ORDER BY CreatedTime DESC, ID ASC,避免时间相同导致ROW_NUMBER()每次生成序号不同 - 子查询里别塞太多字段,尤其别把
TEXT、XML或大VARCHAR放进内层,否则内存占用飙升,执行计划容易退化成spool操作 - 如果表没主键,或排序字段存在大量重复值,
ROW_NUMBER()可能因并行度变化而抖动,导致同一页查两次结果不一致
动态表名或 WHERE 条件拼接时怎么防注入?
用户传入 @TableName 或 @WhereClause 时,字符串拼接就是裸奔:
- 绝对不要写
EXEC('SELECT * FROM ' + @TableName)—— 传入'Employee; DROP TABLE Employee--'就完蛋 - 表名/列名要用
QUOTENAME(@TableName)包裹,它会自动加[ ]并转义内部括号、换行等危险字符 - 过滤条件中的值必须走参数化,比如
WHERE Status = @Status,而不是拼进字符串里 - 哪怕系统完全内网、无外部访问,也别跳过这步——运维脚本、定时任务、调试接口都可能被误用
深分页(比如第 10000 页)性能崩了怎么办?
OFFSET 越大,SQL Server 越得扫描前面所有行再丢弃,这不是优化器能绕开的逻辑限制:
- 真正有效的解法是“游标分页”:记住上一页最后一条的
EmployeeID和CreatedTime,下一页查WHERE CreatedTime - 复合排序字段必须有联合索引支撑,比如
CREATE INDEX IX_Employee_Sort ON Employee(CreatedTime DESC, EmployeeID DESC) - 不能只靠
ID自增做游标——业务删改后 ID 不连续,会漏数据或重复 - 缓存层(如 Redis)存住高频页的
OFFSET对应的物理位置映射,能缓解但不能根治,毕竟缓存失效后第一查还是得扫
真实项目里最容易被忽略的,是排序字段的索引覆盖程度和重复率。哪怕写了 ORDER BY CreatedTime DESC,如果这个字段只有 3 个取值,SQL Server 就很难高效定位第 99999 行——它得先找到所有 “CreatedTime = '2024-01-01'” 的行,再在其中数到第 N 条。











