傳統 OFFSET/FETCH (SQL 2012+) 或 ROW_NUMBER() 分頁在百萬級資料深分頁 (如第 10000 頁) 效能急劇下降,因需掃描前 N 筆資料。**二分法分頁** 利用索引鍵值範圍跳躍,僅掃描目標頁附近資料,效能與總資料量、頁碼無關,是大量資料分頁的經典解法。
傳統分頁:掃描前 (Page-1)*PageSize 筆 → 再取 PageSize 筆 → 掃描量隨頁碼線性增長 二分法分頁: 1. 先取得總筆數、總頁數 2. 判斷目標頁在前半段 或 後半段 3. 前半段:從最小值向上跳 (WHERE ID > (SELECT MAX(ID) FROM 前N頁)) 4. 後半段:從最大值向下跳 (WHERE ID < (SELECT MIN(ID) FROM 後N頁)) 5. 僅掃描目標頁附近的索引鍵範圍
CREATE PROCEDURE dbo.usp_Page_BinarySearch
(
@TableName NVARCHAR(200), -- 表名或 JOIN 查詢 (需為單一結果集)
@FieldList NVARCHAR(500) = N'*', -- 選取欄位
@PageSize INT = 20, -- 每頁筆數
@PageIndex INT = 1, -- 目標頁碼 (1-based)
@SortField NVARCHAR(200) = NULL, -- 排序欄位 (必填,建議有索引)
@SortDesc BIT = 0, -- 0=ASC, 1=DESC
@WhereClause NVARCHAR(1000) = NULL, -- WHERE 條件 (不含 WHERE 關鍵字)
@PKField NVARCHAR(150), -- 主鍵/唯一鍵欄位 (用於範圍跳躍)
@Distinct BIT = 0, -- 是否 DISTINCT
@TotalPages INT OUTPUT, -- 總頁數
@TotalRows INT OUTPUT -- 總筆數
)
AS
BEGIN
SET NOCOUNT ON;
DECLARE @SQL NVARCHAR(MAX), @Where NVARCHAR(1000), @OrderDir NVARCHAR(4), @RevDir NVARCHAR(4);
-- 參數驗證
IF @SortField IS NULL OR @SortField = N'' THROW 51000, N'@SortField 不可為空,需有索引支援排序', 1;
IF @PKField IS NULL OR @PKField = N'' THROW 51000, N'@PKField 不可為空,需為唯一鍵', 1;
IF @PageSize <= 0 SET @PageSize = 20;
IF @PageIndex <= 0 SET @PageIndex = 1;
SET @OrderDir = CASE @SortDesc WHEN 1 THEN N'DESC' ELSE N'ASC' END;
SET @RevDir = CASE @SortDesc WHEN 1 THEN N'ASC' ELSE N'DESC' END;
SET @Where = CASE WHEN @WhereClause IS NULL OR @WhereClause = N'' THEN N'' ELSE N' WHERE ' + @WhereClause END;
-- 1. 取得總筆數
SET @SQL = N'SELECT @Total = ' + CASE @Distinct WHEN 1 THEN N'COUNT(DISTINCT ' + QUOTENAME(@PKField) + N')' ELSE N'COUNT(*)' END +
N' FROM ' + @TableName + @Where;
EXEC sp_executesql @SQL, N'@Total INT OUTPUT', @TotalRows OUTPUT;
-- 2. 計算總頁數、修正頁碼
SET @TotalPages = CASE WHEN @TotalRows = 0 THEN 1 ELSE (@TotalRows + @PageSize - 1) / @PageSize END;
IF @PageIndex > @TotalPages SET @PageIndex = @TotalPages;
-- 3. 早期返回:無資料
IF @TotalRows = 0
BEGIN
SELECT @FieldList FROM @TableName WHERE 1=0; -- 回傳空結果集結構
RETURN;
END
-- 4. 計算中間頁界限
DECLARE @MidPage INT = @TotalPages / 2 + @TotalPages % 2; -- 取上界
DECLARE @LastPageSize INT = @TotalRows % @PageSize;
IF @LastPageSize = 0 SET @LastPageSize = @PageSize;
-- 5. 構建分頁查詢
IF @PageIndex <= @MidPage
BEGIN
-- 前半段:從小往大跳
IF @PageIndex = 1
BEGIN
-- 第一頁直接 TOP
SET @SQL = N'SELECT ' + CASE @Distinct WHEN 1 THEN N'DISTINCT ' ELSE N'' END +
N'TOP (@PageSize) ' + @FieldList +
N' FROM ' + @TableName + @Where +
N' ORDER BY ' + QUOTENAME(@SortField) + N' ' + @OrderDir;
END
ELSE
BEGIN
-- 第 N 頁:跳過前 (N-1) 頁的最大 PK
DECLARE @SkipRows INT = @PageSize * (@PageIndex - 1);
SET @SQL = N'SELECT ' + CASE @Distinct WHEN 1 THEN N'DISTINCT ' ELSE N'' END +
N'TOP (@PageSize) ' + @FieldList +
N' FROM ' + @TableName + @Where +
N' WHERE ' + QUOTENAME(@PKField) +
CASE @SortDesc WHEN 1 THEN N' < ' ELSE N' > ' END +
N'(SELECT MAX(' + QUOTENAME(@PKField) + N') FROM (' +
N' SELECT ' + CASE @Distinct WHEN 1 THEN N'DISTINCT ' ELSE N'' END +
N' TOP (@SkipRows) ' + QUOTENAME(@PKField) +
N' FROM ' + @TableName + @Where +
N' ORDER BY ' + QUOTENAME(@SortField) + N' ' + @OrderDir +
N') AS T)' +
N' ORDER BY ' + QUOTENAME(@SortField) + N' ' + @OrderDir;
EXEC sp_executesql @SQL,
N'@PageSize INT, @SkipRows INT',
@PageSize, @SkipRows;
RETURN;
END
END
ELSE
BEGIN
-- 後半段:從大往小跳 (反向)
DECLARE @ReversePage INT = @TotalPages - @PageIndex + 1; -- 從尾巴算第幾頁
IF @ReversePage = 1
BEGIN
-- 最後一頁
SET @SQL = N'SELECT * FROM (' +
N' SELECT ' + CASE @Distinct WHEN 1 THEN N'DISTINCT ' ELSE N'' END +
N' TOP (@LastPageSize) ' + @FieldList +
N' FROM ' + @TableName + @Where +
N' ORDER BY ' + QUOTENAME(@SortField) + N' ' + @RevDir +
N') AS T ORDER BY ' + QUOTENAME(@SortField) + N' ' + @OrderDir;
EXEC sp_executesql @SQL, N'@LastPageSize INT', @LastPageSize;
RETURN;
END
ELSE
BEGIN
-- 從尾巴往前跳
DECLARE @SkipRows INT = @PageSize * (@ReversePage - 1) + @LastPageSize - @PageSize;
SET @SQL = N'SELECT * FROM (' +
N' SELECT ' + CASE @Distinct WHEN 1 THEN N'DISTINCT ' ELSE N'' END +
N' TOP (@PageSize) ' + @FieldList +
N' FROM ' + @TableName + @Where +
N' WHERE ' + QUOTENAME(@PKField) +
CASE @SortDesc WHEN 1 THEN N' > ' ELSE N' < ' END +
N'(SELECT MIN(' + QUOTENAME(@PKField) + N') FROM (' +
N' SELECT ' + CASE @Distinct WHEN 1 THEN N'DISTINCT ' ELSE N'' END +
N' TOP (@SkipRows) ' + QUOTENAME(@PKField) +
N' FROM ' + @TableName + @Where +
N' ORDER BY ' + QUOTENAME(@SortField) + N' ' + @RevDir +
N' ) AS T)' +
N' ORDER BY ' + QUOTENAME(@SortField) + N' ' + @RevDir +
N') AS T2 ORDER BY ' + QUOTENAME(@SortField) + N' ' + @OrderDir;
EXEC sp_executesql @SQL, N'@PageSize INT, @SkipRows INT', @PageSize, @SkipRows;
RETURN;
END
END
-- 執行第一頁或單一查詢路徑
EXEC sp_executesql @SQL, N'@PageSize INT', @PageSize;
END;
GO
DECLARE @TotalPages INT, @TotalRows INT;
-- 基本分頁
EXEC dbo.usp_Page_BinarySearch
@TableName = N'dbo.Orders',
@FieldList = N'OrderID, CustomerID, OrderDate, TotalAmount',
@PageSize = 20,
@PageIndex = 5000, -- 直接跳到第 5000 頁
@SortField = N'OrderID',
@SortDesc = 0,
@PKField = N'OrderID',
@TotalPages = @TotalPages OUTPUT,
@TotalRows = @TotalRows OUTPUT;
SELECT @TotalPages AS TotalPages, @TotalRows AS TotalRows;
-- 帶條件分頁
EXEC dbo.usp_Page_BinarySearch
@TableName = N'dbo.Orders',
@FieldList = N'OrderID, CustomerID, OrderDate, TotalAmount',
@PageSize = 50,
@PageIndex = 1,
@SortField = N'OrderDate',
@SortDesc = 1, -- 最新優先
@WhereClause = N'CustomerID = 12345 AND OrderDate >= ''2024-01-01''',
@PKField = N'OrderID',
@TotalPages = @TotalPages OUTPUT,
@TotalRows = @TotalRows OUTPUT;
| 條件 | 原因 |
|---|---|
| @SortField 有索引 | ORDER BY 必須走索引掃描,否則全表排序效能更差 |
| @PKField 唯一且不為 NULL | 範圍跳躍依賴唯一鍵定位,複合鍵需改用 ROW_NUMBER() 方案 |
| 排序欄位與 PK 相關性高 | 若 OrderDate 排序但 PK 為 OrderID,跳躍邏輯假設 PK 順序 ≈ 排序順序,否則結果錯誤 |
| 資料穩定性 | 分頁過程中資料大量增刪會導致頁碼漂移,需配合快照隔離或業務容忍 |
| 分頁方式 | 第 1 頁 | 第 100 頁 | 第 10000 頁 | 備註 |
|---|---|---|---|---|
| OFFSET/FETCH | 5ms | 50ms | 5000ms+ | 掃描量線性增長 |
| ROW_NUMBER() | 8ms | 80ms | 8000ms+ | 同理 |
| 二分法 (本方案) | 6ms | 8ms | 12ms | 掃描量恆定 ≈ 2-3 頁資料 |
| Keyset/游標分頁 | 4ms | 4ms | 4ms | 需前端記住上一頁最大 PK,不支援跳頁 |
OFFSET/FETCH 或應用層快取前端傳遞「上一頁最後一筆的排序值 + PK」,查詢 WHERE (SortCol, PK) > (LastSort, LastPK)。優點:極快、無深分頁問題、支援即時資料。缺點:不支援跳頁、不支援總頁數、前端邏輯較複雜。
二分法分頁是傳統跳頁式分頁在大量資料下的極致優化。若業務必須支援「跳到第 N 頁」且資料量超過 10 萬筆,這是 T-SQL 層面最有效的方案。配合正確索引設計,可將深分頁查詢從秒級降至毫秒級。