当前位置:首页 > 问答 > 正文

SQL Server里头怎么用存储过程来搞分页,性能和写法那些事儿

在SQL Server里,用存储过程来实现分页是一个非常经典的需求,主要是为了提升大数据量查询时的性能,避免一次性返回全部数据导致前端卡死或者网络传输压力过大,这里咱们就聊聊几种常见的写法以及它们背后的性能考量,参考了网络上大量的开发者实践和官方文档中的优化建议。

最古老也最通用的方法:使用TOP和临时表

在SQL Server 2005之前,没有ROW_NUMBER()函数,这是主流做法,思路是分两步走。

第一步,先查询出当前页之前的所有数据的主键ID(假设主键是ID),并存入一个临时表,比如你要查第3页,每页10条,那就先找出前20条记录的ID。

第二步,再用这个临时表里的ID作为条件,去主表里查询详细的记录,并且只取前10条(也就是当前页的大小)。

SQL Server里头怎么用存储过程来搞分页,性能和写法那些事儿

写法大致是这样的(来源:早期SQL Server开发社区常见模式):

CREATE PROCEDURE Paging_OldSchool
    @PageIndex INT, -- 当前页码,从1开始
    @PageSize INT   -- 每页记录数
AS
BEGIN
    -- 计算要跳过多少条记录
    DECLARE @StartRow INT
    SET @StartRow = (@PageIndex - 1) * @PageSize
    -- 创建一个临时表来存储主键
    CREATE TABLE #TempTable (RowID INT IDENTITY(1,1), MainID INT)
    -- 第一步:先插入排序后的主键,注意这里的ORDER BY很重要
    INSERT INTO #TempTable (MainID)
    SELECT ID FROM YourMainTable
    ORDER BY YourOrderByColumn -- 按照你需要的排序字段
    -- 第二步:通过RowID的范围和临时表的主键,关联回主表获取所有数据
    SELECT t.*
    FROM YourMainTable t
    INNER JOIN #TempTable temp ON t.ID = temp.MainID
    WHERE temp.RowID > @StartRow AND temp.RowID <= (@StartRow + @PageSize)
    ORDER BY temp.RowID
    DROP TABLE #TempTable
END

这种方法的好处是思路清晰,兼容老版本,但缺点也很明显:性能瓶颈在于需要先把所有符合条件数据的主键都插入到临时表,并生成一个自增的RowID,如果总数据量有上百万,即使只取一页,这个插入操作的成本也非常高。

现代首选的方法:使用ROW_NUMBER()窗口函数

从SQL Server 2005开始,引入了强大的ROW_NUMBER()函数,分页变得简单多了,这成为了目前最流行、最推荐的方法。

SQL Server里头怎么用存储过程来搞分页,性能和写法那些事儿

它的核心思想是,在一次查询中,先为每一行数据生成一个连续的行号,然后在外层查询中,根据页码和页大小来筛选行号的范围。

写法非常直观(来源:MSDN官方文档及现代SQL Server开发最佳实践):

CREATE PROCEDURE Paging_WithRowNumber
    @PageIndex INT,
    @PageSize INT
AS
BEGIN
    WITH OrderedTable AS (
        SELECT *,
               ROW_NUMBER() OVER (ORDER BY YourOrderByColumn) AS RowNum
        FROM YourMainTable
        -- 可以在这里加上WHERE条件进行筛选
    )
    SELECT *
    FROM OrderedTable
    WHERE RowNum BETWEEN ((@PageIndex - 1) * @PageSize) + 1
                     AND (@PageIndex * @PageSize)
    ORDER BY RowNum
END

这个存储过程的性能相比临时表方法有巨大提升,因为SQL Server的查询优化器可以更高效地处理ROW_NUMBER()的计算和筛选,尤其是在YourOrderByColumn字段上有索引的情况下,它不需要像临时表方法那样进行全表扫描和大量的插入操作,只需要找到对应的行号范围即可。

针对超大数据集的优化:使用OFFSET FETCH

SQL Server里头怎么用存储过程来搞分页,性能和写法那些事儿

如果你使用的是SQL Server 2012或更高的版本,有一个更简洁、语法上更贴近其他数据库(如MySQL的LIMIT)的选项,那就是OFFSET...FETCH子句,这个语法是专门为分页而生的。

写法如下(来源:SQL Server 2012+ 官方语法说明):

CREATE PROCEDURE Paging_OffsetFetch
    @PageIndex INT,
    @PageSize INT
AS
BEGIN
    SELECT *
    FROM YourMainTable
    ORDER BY YourOrderByColumn -- 必须有ORDER BY
    OFFSET (@PageIndex - 1) * @PageSize ROWS -- 跳过前面多少行
    FETCH NEXT @PageSize ROWS ONLY -- 只取接下来的多少行
END

这个写法非常简洁易懂,从性能角度来看,在SQL Server 2012及以后版本中,OFFSET FETCHROW_NUMBER()方法的执行计划和性能通常是相似的,优化器对它们的处理都很优秀,它们都强烈依赖于ORDER BY子句上所建立的索引,如果没有合适的索引,无论是哪种方法,在分页到很靠后的页面时(比如第10000页),性能都会显著下降,因为它仍然需要逻辑上扫描并跳过前面的大量记录。

性能和写法那些事儿总结

  1. 索引是关键:无论你用哪种方法,分页查询的排序列(ORDER BY column)上必须有索引,这是影响分页性能最最关键的因素,如果没有索引,每次分页查询都可能引发全表扫描,数据量一大就慢如蜗牛。
  2. 方法选择
    • 如果环境是SQL Server 2000,可能只能忍痛用第一种临时表方法。
    • 对于SQL Server 2005到2008 R2,ROW_NUMBER()是绝对的主流和最佳选择。
    • 对于SQL Server 2012及以上,OFFSET FETCH在写法上更优雅,性能和ROW_NUMBER()相当,可以优先考虑。
  3. “深度分页”问题:这是一个共性问题,当你查询非常靠后的页面时(例如OFFSET 1000000),数据库引擎仍然需要计算出这100万条被跳过的行,成本很高,对于这种情况,常见的优化思路是使用“上一页/下一页”式的导航,或者使用WHERE id > last_max_id这种基于键集的分页方式,但后者会牺牲掉直接跳转到任意页码的能力。
  4. 返回总记录数:实际应用中,前端通常还需要知道总页数,你可以在同一个存储过程里再写一个SELECT COUNT(*) FROM YourMainTable,但要注意,这会使存储过程多执行一次表扫描,对于大表,可以考虑将总行数缓存起来,或者使用近似值(如sys.dm_db_partition_stats),根据业务对准确性的要求来权衡。

在现在的SQL Server开发中,忘记那个古老的临时表方法吧,优先在排序字段上建好索引,然后根据你的数据库版本,放心地使用ROW_NUMBER()OFFSET FETCH来编写你的分页存储过程。