用管線化 top-N 查詢高效取得第一頁之後,通常還需要另一段查詢來抓下一頁。隨之而來的挑戰是:如何跳過前面幾頁的列。有兩種方法:

  • 偏移法(offset method):從頭開始為列編號,再用該編號篩掉目標頁之前的列。
  • 尋位法(seek method):找到前一頁的最後一筆,只抓它之後的列。

偏移法#

偏移法使用得更廣泛,主要優勢是極易處理——尤其在有專用關鍵字(offset)的資料庫上。這個關鍵字甚至已被納入 SQL 標準,成為 fetch first 擴充的一部分。

MySQL / PostgreSQLoffset 先丟棄指定列數,limit 後套用)

SELECT *
  FROM sales
 ORDER BY sale_date DESC
 LIMIT 10 OFFSET 10;

Oracle DatabaseROWNUM 偽欄位無法套用 >= 篩選,必須先用別名把列號「具體化」:

SELECT *
  FROM ( SELECT tmp.*, rownum rn
           FROM ( SELECT *
                    FROM sales
                   ORDER BY sale_date DESC
                ) tmp
          WHERE rownum <= 20
       )
 WHERE rn > 10;

注意:下界用別名 RN,上界用 ROWNUM 偽欄位本身

PostgreSQL(只接受不帶 rows 關鍵字的 offset;舊的 limit/offset 語法依然可用)

SELECT *
  FROM sales
 ORDER BY sale_date DESC
OFFSET 10
 FETCH NEXT 10 ROWS ONLY;

SQL Server(2012 起支援 fetch first;雖然標準把 offset 定為選用附加,SQL Server 卻強制要求

SELECT *
  FROM sales
 ORDER BY sale_date DESC
OFFSET 10 ROWS
 FETCH NEXT 10 ROWS ONLY;

偏移法的兩個缺點#

除了簡單之外,另一個優點是只要一個列偏移量就能抓任意頁。但資料庫必須從頭數過所有列才能抵達目標頁。

圖 7.2:偏移法的存取方式

翻的頁數越多,被掃描的索引範圍就越大。

這帶來兩個缺點:

  1. 頁面漂移:插入新銷售時,編號永遠從頭算起,內容會跟著位移。
  2. 回應時間隨往回翻的頁數增加。

尋位法#

尋位法避開了這兩個問題,因為它用前一頁的值當分界:搜尋「必須排在前一頁最後一筆之後」的那些值。這可以用一個簡單的 where 子句表達——換個角度說:尋位法根本不去選取已經顯示過的值

先假設每天只有一筆銷售(SALE_DATE 因而是唯一鍵)。由於是遞減排序,「排在某日期之後」要用小於(<)條件(遞增排序則用 >):

SELECT *
  FROM sales
 WHERE sale_date < ?
 ORDER BY sale_date DESC
 FETCH FIRST 10 ROWS ONLY;

用前一頁的最後一個取代列號來指定下界,效能上的收益極大:資料庫能把 sale_date < ? 用於索引存取,也就是真正地跳過前幾頁的列。此外,即使有新列插入,結果也保持穩定。

前提:排序必須具決定性#

但如果一天有多筆銷售,這個做法就失效了:拿第一頁的最後日期(「昨天」)當界線,會把昨天的所有結果都跳過——不只是第一頁已顯示的那些。

問題在於 order by 子句沒有建立具決定性的列順序,而那正是「用簡單範圍條件切頁」的前提。

沒有具決定性的 order by,資料庫依定義就不會交出具決定性的列順序。你平常之所以得到一致的順序,只是因為資料庫通常以相同方式執行查詢。實際上資料庫大可把 SALE_DATE 相同的列打亂,仍然滿足 order by。

在較新的版本中,你甚至可能每次執行都得到不同順序——不是資料庫故意打亂,而是它可能採用平行查詢執行:同一個執行計畫,因為執行緒完成順序不確定,就可能產生不同的列順序。

即使功能規格只要求「依日期排序、最新在前」,身為開發者的我們仍必須確保 order by 產生具決定性的列順序。做法是:

  1. 若管線化 order by 所用的索引還有其他欄位,先把它們加進 order by,這樣還能繼續用該索引做管線化 order by。
  2. 若仍不具決定性,就加上任何唯一欄位並相應擴充索引

Row values 語法#

下例把主鍵 SALE_ID 加進 order by 與索引,並對兩個欄位整體套用「排在其後」的邏輯:

CREATE INDEX sl_dtid ON sales (sale_date, sale_id);

SELECT *
  FROM sales
 WHERE (sale_date, sale_id) < (?, ?)
 ORDER BY sale_date DESC, sale_id DESC
 FETCH FIRST 10 ROWS ONLY;

這裡用了鮮為人知的 row values 語法:把多個值組合成一個邏輯單位,套用一般的比較運算子。與純量值一樣,遞減排序時「小於」就等於「排在其後」。

雖然 row values 屬於 SQL 標準,支援的資料庫卻很少:

  • SQL Server 2012:完全不支援。
  • Oracle:原則上支援 row values,但不能對它套用範圍運算子(ORA-01796)。
  • MySQL:能正確求值,但無法在索引存取時把它當作存取述詞
  • PostgreSQL:支援語法,且在有對應索引時會用它來存取索引
延伸:SQL Row Values 的標準定義

除了一般的純量值,SQL 標準也定義了所謂的列值建構子(row value constructor),用來「指定一組有序的值,構成一列或部分列」[SQL:92, §7.1]。語法上就是括號中的列表——最為人熟知的用途是 insert 敘述。

在 where 子句中使用 row value constructor 較少見,卻完全合法。標準確實為它定義了所有比較運算子,例如小於:

「Rx < Ry」為真,若且唯若對所有 i < n 皆有 RXi = RYi,且存在某個 n 使 RXn < RYn。

——SQL:92, §8.2.7.2

其中 i 與 n 代表列表中的位置索引。也就是說:只要某個 RXn 小於對應的 RYn,且所有在它之前的值兩兩相等,則列值 RX 小於 RY

這個定義讓 RX < RY 等同於「RX 排在 RY 之前」——正是尋位法所需的邏輯。

不支援 row values 時的近似做法#

在不支援 row values 的資料庫上,仍可用近似版本的尋位法——雖然不如 PostgreSQL 的 row values 優雅高效。以 Oracle 為例,改用「一般」比較來表達同樣邏輯:

SELECT *
  FROM ( SELECT *
           FROM sales
          WHERE sale_date <= ?
            AND NOT (sale_date = ? AND sale_id >= ?)
          ORDER BY sale_date DESC, sale_id DESC
       )
 WHERE rownum <= 10;

where 子句由兩部分組成:

  1. 只看 SALE_DATE<= 條件——它選出比所需更多的列。這部分夠簡單,所有資料庫都能用它存取索引
  2. NOT (...)——移除前一頁已顯示的多餘列。
---------------------------------------------------------------
|Id | Operation                       | Name    | Rows | Cost |
---------------------------------------------------------------
| 0 | SELECT STATEMENT                |         |    10 |    4 |
|*1 | COUNT STOPKEY                   |         |       |      |
| 2 |   VIEW                          |         |    10 |    4 |
| 3 |    TABLE ACCESS BY INDEX ROWID  | SALES   | 50218 |    4 |
|*4 |     INDEX RANGE SCAN DESCENDING | SL_DTIT |     2 |    3 |
---------------------------------------------------------------
Predicate Information:
   1 - filter(ROWNUM<=10)
   4 - access("SALE_DATE"<=:SALE_DATE)
       filter("SALE_DATE"<>:SALE_DATE
           OR "SALE_ID"<TO_NUMBER(:SALE_ID))

SALE_DATE 上的存取述詞讓資料庫得以跳過「前幾頁已完整顯示的日子」;第二部分只是篩選述詞——資料庫會再檢視前一頁的少數幾筆,但立刻丟棄。

圖 7.3:尋位法的存取路徑

延伸:等價邏輯的可索引性

同一個邏輯條件永遠有多種寫法。上面的跳過邏輯也可以寫成:

WHERE (
          (sale_date < ?)
        OR
          (sale_date = ? AND sale_id < ?)
      )

這個版本只用包含性條件,對人類而言大概更好懂。但資料庫的觀點不同:它認不出這個 where 子句是在選取「從某組 SALE_DATE/SALE_ID 開始的所有列」,於是把整個 where 子句當成篩選述詞。我們至少會期待最佳化工具能把 SALE_DATE <= ? 從兩個 or 分支中「提取出來」,但沒有任何資料庫提供這項服務。

不過我們可以手動加上這個冗餘條件(雖然可讀性沒有變好):

WHERE sale_date <= ?
  AND (
           (sale_date < ?)
        OR
           (sale_date = ? AND sale_id < ?)
      )

所幸所有資料庫都能把這部分用作存取述詞。

但這個子句比前面的近似寫法更難掌握;而且原本的寫法還能避免一個風險:那個「看起來不必要」的冗餘部分日後被人誤刪。

兩種方法的比較#

圖 7.4:抓取下一頁時的擴展性

量測精度不足以看出圖左側的差異,但約從第 20 頁開始,兩者的差距就清楚可見:偏移法線性上升,尋位法維持平坦。

尋位法當然也有缺點,最主要的就是不好用

  • where 子句必須非常小心地措辭。
  • 無法抓取任意頁
  • 要改變瀏覽方向,必須把所有比較與排序操作全部反轉。

不過,恰恰是「跳頁」與「往回瀏覽」這兩項功能,在使用者介面採用無限捲動機制時並不需要。