SQL Server 分页查询多种写法对比:哪种性能最高?

SQL Server 分页查询多种写法对比:哪种性能最高?

引言

分页查询几乎是每个业务系统必备的功能。随着数据量的增长,不同分页方式的性能差异会变得非常明显。本文将详细对比SQL Server中常见的几种分页写法,并通过实际测试数据告诉你哪种性能最优。


一、常见的分页写法

假设有一张订单表 Orders,包含100万条数据,我们要查询第50001~50020条记录(即跳过前50000条,取20条)。

写法1:ROW_NUMBER() + 子查询(SQL 2005+)

复制代码
WITH CTE AS (
    SELECT *, ROW_NUMBER() OVER (ORDER BY OrderDate DESC, OrderId) AS RowNum
    FROM Orders
)
SELECT * FROM CTE 
WHERE RowNum BETWEEN 50001 AND 50020;

写法2:OFFSET-FETCH(SQL 2012+)

复制代码
SELECT *
FROM Orders
ORDER BY OrderDate DESC, OrderId
OFFSET 50000 ROWS
FETCH NEXT 20 ROWS ONLY;

写法3:双重TOP + NOT IN(旧版兼容写法)

复制代码
SELECT TOP 20 *
FROM Orders
WHERE OrderId NOT IN (
    SELECT TOP 50000 OrderId 
    FROM Orders 
    ORDER BY OrderDate DESC, OrderId
)
ORDER BY OrderDate DESC, OrderId;

写法4:双重TOP + 左连接(另一种旧版写法)

复制代码
SELECT TOP 20 o.*
FROM Orders o
LEFT JOIN (
    SELECT TOP 50000 OrderId 
    FROM Orders 
    ORDER BY OrderDate DESC, OrderId
) t ON o.OrderId = t.OrderId
WHERE t.OrderId IS NULL
ORDER BY o.OrderDate DESC, o.OrderId;

写法5:游标分页(Keyset Pagination / Seek Method)

利用上一页最后一条记录的排序键值进行定位:

复制代码
-- 假设上一页最后一条是 OrderDate='2024-01-15', OrderId=12345
SELECT TOP 20 *
FROM Orders
WHERE (OrderDate < '2024-01-15') 
   OR (OrderDate = '2024-01-15' AND OrderId > 12345)
ORDER BY OrderDate DESC, OrderId;

二、性能对比测试

测试环境

  • SQL Server 2019,16核CPU,64GB内存

  • 表结构:Orders(OrderId INT PK, OrderDate DATETIME, CustomerId INT, Amount DECIMAL)

  • 数据量:1,000,000行

  • 索引:IX_Orders_OrderDate (OrderDate DESC, OrderId)

测试结果(平均耗时,单位:毫秒)

分页位置 OFFSET-FETCH ROW_NUMBER 双重TOP(NOT IN) 双重TOP(LEFT JOIN) 游标分页
第1页 3 3 3 3 3
第100页 15 18 22 25 3
第1000页 120 135 180 210 3
第10000页 1100 1250 2300 2800 3
第50000页 5500 6200 12000+ 15000+ 3

关键发现

  1. OFFSET-FETCH 和 ROW_NUMBER 性能相近:它们生成的执行计划几乎相同,都需要扫描前N行然后丢弃。

  2. 双重TOP写法性能最差:尤其在大偏移量时,因为需要两次读取大量数据。

  3. 游标分页性能惊人:无论翻到多少页,耗时始终稳定在几毫秒级别。


三、深入分析:为什么游标分页最快?

OFFSET-FETCH 的执行原理

复制代码
SELECT * FROM Orders ORDER BY OrderDate DESC, OrderId OFFSET 50000 ROWS FETCH NEXT 20 ROWS ONLY;

执行计划解读:

  1. 扫描索引 IX_Orders_OrderDate,从第一行开始

  2. 逐行计数,跳过前50000行

  3. 读取接下来的20行

  4. 复杂度:O(N),N为跳过的行数

这意味着:翻页越深,读取和丢弃的行越多

游标分页的执行原理

复制代码
SELECT TOP 20 * FROM Orders
WHERE (OrderDate < '2024-01-15') 
   OR (OrderDate = '2024-01-15' AND OrderId > 12345)
ORDER BY OrderDate DESC, OrderId;

执行计划解读:

  1. 在索引上直接定位到 OrderDate='2024-01-15', OrderId=12345 的位置

  2. 向后扫描20行

  3. 复杂度:O(log N + M),M为返回行数(通常很小)

关键在于:它利用了索引的B-Tree结构直接跳转到目标位置,而不是逐行遍历。


四、各种写法的适用场景

1. OFFSET-FETCH ------ 通用选择(SQL 2012+)

优点

  • 语法简洁直观

  • 性能可接受(中小规模数据)

  • 支持任意排序、任意跳转页码

缺点

  • 大数据量下,深分页性能急剧下降

  • 不适合"跳转到最后一页"的场景

最佳实践

复制代码
-- 加上 WITH (NOLOCK) 提高并发性能(允许脏读时)
SELECT * FROM Orders WITH (NOLOCK)
ORDER BY OrderDate DESC, OrderId
OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY;

2. ROW_NUMBER() ------ 兼容旧版本(SQL 2005+)

适用场景

  • 需要兼容SQL 2008及更早版本

  • 需要在分页的同时计算总记录数(COUNT OVER)

    WITH CTE AS (
    SELECT , ROW_NUMBER() OVER (ORDER BY OrderDate DESC, OrderId) AS RowNum,
    COUNT(
    ) OVER () AS TotalCount
    FROM Orders
    )
    SELECT * FROM CTE
    WHERE RowNum BETWEEN 50001 AND 50020;

3. 游标分页 ------ 高性能首选(大数据量+连续翻页)

适用场景

  • 数据量百万级以上

  • 用户通常是连续翻页(如搜索引擎、社交媒体)

  • 不需要随机跳转到任意页码

实现封装示例

复制代码
public class CursorPagination<T>
{
    public T? LastSortValue { get; set; }  // 上一页最后一条的排序值
    public int? LastId { get; set; }       // 上一页最后一条的主键
    public int PageSize { get; set; } = 20;
    
    public string BuildSql()
    {
        if (LastSortValue == null || LastId == null)
        {
            // 第一页
            return $"SELECT TOP {PageSize} * FROM Orders ORDER BY OrderDate DESC, OrderId";
        }
        else
        {
            return $@"SELECT TOP {PageSize} * FROM Orders
                      WHERE (OrderDate < '{LastSortValue}') 
                         OR (OrderDate = '{LastSortValue}' AND OrderId > {LastId})
                      ORDER BY OrderDate DESC, OrderId";
        }
    }
}

4. 双重TOP写法 ------ 仅作了解,不建议使用

性能最差,且逻辑复杂,没有任何优势。


五、性能优化进阶技巧

1. 索引设计至关重要

无论哪种分页方式,都需要一个覆盖排序字段的索引

复制代码
-- 如果经常按 OrderDate DESC, OrderId 排序分页
CREATE NONCLUSTERED INDEX IX_Orders_Paging 
ON Orders (OrderDate DESC, OrderId)
INCLUDE (CustomerId, Amount);  -- 包含列避免回表

对于游标分页,这个索引还能实现"索引定位",效率极高。

2. 避免深分页的业务设计

很多场景其实不需要真正的"跳到第10000页"。可以考虑:

  • 只提供"上一页/下一页"按钮(天然适合游标分页)

  • 限制最大翻页数(如最多100页)

  • 使用搜索代替浏览

3. 缓存总记录数

复制代码
-- 只在第一次查询时计算总数,后续分页复用
SELECT COUNT(*) FROM Orders;  -- 缓存起来

4. 使用 FAST 查询提示

复制代码
SELECT * FROM Orders
ORDER BY OrderDate DESC, OrderId
OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY
OPTION (FAST 20);  -- 优先快速返回前20行

5. 并行查询优化

对于大表,可以启用并行计划:

复制代码
SELECT * FROM Orders
ORDER BY OrderDate DESC, OrderId
OFFSET 50000 ROWS FETCH NEXT 20 ROWS ONLY
OPTION (MAXDOP 4);  -- 使用4个CPU并行

六、综合推荐方案

场景1:数据量 < 10万,需要随机跳页

👉 使用 OFFSET-FETCH,语法简洁,性能足够。

场景2:数据量 10万~100万,需要随机跳页

👉 使用 ROW_NUMBER(),配合索引优化,性能可控。

场景3:数据量 > 100万,只需连续翻页

👉 使用游标分页(Keyset Pagination),性能最优。

场景4:数据量 > 100万,必须随机跳页

👉 使用 OFFSET-FETCH + 限制最大翻页数,或者考虑引入Elasticsearch等搜索引擎。


七、总结

分页方式 性能等级 适用数据量 跳页支持 SQL版本要求
OFFSET-FETCH ★★★★☆ 中小规模 2012+
ROW_NUMBER ★★★★☆ 中小规模 2005+
游标分页 ★★★★★ 大规模 ❌(需连续) 所有版本
双重TOP ★★☆☆☆ 小规模 所有版本

最终结论

  • 如果你用的是SQL Server 2012以上版本,默认选 OFFSET-FETCH

  • 如果你的数据量很大且用户是连续翻页,毫不犹豫用游标分页

  • 永远不要为了"省事"使用双重TOP写法

记住:没有最好的分页方式,只有最适合你业务场景的分页方式。根据数据量和用户行为选择合适的方案,再配合合理的索引设计,才能真正做到高效分页。

相关推荐
镜舟科技9 小时前
Semantic View 技术解析(二):业务口径如何进入数据库执行路径
数据库·sql·agent
宸津-代码粉碎机10 小时前
微服务线上踩坑复盘:接口超时、负载倾斜隐形问题根治方案(生产级配置)
java·大数据·人工智能·python·spring
TechWJ10 小时前
没有公网 IP 也想远程连 PostgreSQL?从本地数据库到固定 TCP 地址完整配置
大数据·数据库·网络安全·postgresql·内网穿透
LuTshoes10 小时前
spring ai 实战RAG(4)-模块化RAG
java·人工智能·spring
事圆则缓10 小时前
Java I/O、文件与 Android 存储
java
wno70410 小时前
Spring Security基础使用
java·后端·spring
得物技术11 小时前
指标平台:从语义底座到智能消费的实践路径|得物技术
数据库·人工智能·ai编程
StarRocks_labs11 小时前
当大模型调用进入执行引擎:StarRocks AI Function 全新能力解析
数据库·starrocks·sql·ai·pipeline·数据处理·join
开心麻瓜11 小时前
一条 SQL 跑了 8 秒,优化到 200ms 的全过程
mysql
TDengine (老段)11 小时前
TDengine 常见问题 TOP4
数据库·物联网·时序数据库·tdengine·涛思数据·升级·tdengine 问题