這篇文章透過 SQL Server 官方文件驗證觀念,說明資料的實際儲存方式、堆積表與索引的差異,以及如何設計索引以支援這類查詢。

文末會以會員訂單情境為例,說明為何需要為不同查詢欄位建立獨立索引,以及「涵蓋索引」可以如何減少 I/O。

資料庫如何儲存資料?

底層的資料結構
SQL Server 把資料檔案切割成頁面(data page),每個頁面大小固定為 8 KiB【[官方文件](Page and Extent Architecture Guide - SQL Server | Microsoft Learn)】。頁面之上會再以八個連續頁面組成的區段(extent)來管理空間。

頁面結構
每個頁面開頭有頁面標頭,紀錄頁碼、頁面類型等資訊,頁面末端有 slot array 儲存每一列在頁面中的偏移量。

預設沒有排序
資料列被插入頁面時並不一定會物理排序;刪除與更新操作會在頁面中產生空洞,新資料可能被放進空洞中,因此頁面內的資料列沒有固定的物理順序。資料庫透過 slot array 維持資料的邏輯順序。

沒有索引的堆積表(Heap)

如果表上沒有建立 clustered index,它就是堆積表(heap)[【官方文件】](Heaps (Tables without clustered indexes) - SQL Server | Microsoft Learn)。堆積表內的資料列本身沒有排序,通常資料會依插入順序放入頁面,但資料庫為了有效利用空間可能重新安排資料列。由於沒有排序,查詢結果的順序無法預期。

堆積表使用 Row Identifier(RID)來尋址資料列。若沒有任何索引,資料庫必須掃描整個表 (Full Table Scan) 才能找到符合條件的資料,只有在表很小時才勉強可接受。當資料量成長後,幾乎所有實務系統都需要索引來避免這種全表掃描。

叢集索引:唯一的實體排序方式

叢集索引(clustered index)會把資料列依索引鍵值排序並存放在 B+ 樹的葉節點中。這是 SQL Server 唯一會實際改變資料列物理排序的索引類型,因此一個表只能有一個叢集索引。

當建立叢集索引時,SQL Server 需要重新排列資料,以符合索引鍵值的排序。這可能非常耗時且佔用資源,因此在設計階段應先決定哪個欄位最適合作為叢集索引。

非叢集索引:獨立的查詢捷徑

非叢集索引(nonclustered index)是一顆獨立於資料的 B-tree 結構,包含索引鍵值以及指向資料列的指標。若底層表是堆積表,指標會指向資料列位置;若底層表有叢集索引,指標則是叢集索引鍵值。

非叢集索引不改變資料存放方式,但提供快速搜尋的捷徑。

因為它與資料分離,一個表可以建立多個非叢集索引,以支援不同的查詢模式。

複合索引與索引鍵順序

複合索引(composite index)是指索引鍵包含多個欄位。

在使用複合索引時,查詢是否包含索引的前導欄位非常重要。這稱作是Leftmost Prefix Rule。

例如建立 (A, B, C) 的索引時,查詢若使用 A 或 A+B,可以有效利用索引;若跳過 A 直接使用 B,則索引的排序順序與查詢條件不一致,資料庫可能無法有效使用該索引。

這也是為什麼在「欄位 A 或欄位 B」的查詢情境中,不能只建立一個 (A, B) 的複合索引。因為查詢實際上只會使用其中一個條件,資料庫在規劃查詢計畫時無法同時以兩個欄位作為進入點。

先理解三個常見名詞:Full Table Scan、Index Seek、Key Lookup

在閱讀執行計畫或效能文章時,常會看到幾個名詞,如果沒有先理解它們,後面的討論很容易失焦。這裡先用同一個簡單情境說明。

假設有一張 MemberOrders 表,裡面有一百萬筆訂單資料。

Full Table Scan(全表掃描)

Full Table Scan 的意思是:資料庫從第一筆資料開始,一筆一筆檢查,直到最後一筆。

就像在沒有目錄的情況下翻一本一百萬頁的書,只能從第一頁翻到最後一頁找關鍵字。這種方式在資料量很小時沒什麼問題,但資料一大,讀取成本會線性增加。

當資料表沒有適用的索引,或優化器判斷使用索引不划算時,就會出現 Full Table Scan。

Index Seek(索引搜尋)

Index Seek 則代表資料庫可以透過索引直接「跳到」符合條件的範圍。

延續剛剛的書本比喻,如果書後面有依電話排序的索引,你查某個電話時,可以直接翻到對應頁碼,而不是從頭開始翻。這種方式通常只需要讀取少量頁面,因此效率遠高於全表掃描。

只要查詢條件能符合索引的設計(例如使用索引的前導欄位),資料庫就有機會使用 Index Seek。

Key Lookup(回表查找)

Key Lookup 則是發生在「已經使用索引找到資料位置,但索引裡缺少部分欄位」的情況。

資料庫會先做 Index Seek,找到符合條件的資料列位置;接著,因為索引中沒有包含查詢要回傳的所有欄位,只好再回到資料表,把缺少的欄位讀出來。

如果只找到幾筆資料,這個動作成本不高;但如果找到幾百或幾千筆,就等於要重複做幾百或幾千次回表讀取,效能就會明顯下降。

理解這三個名詞後,再來看涵蓋索引會更清楚。

涵蓋索引

當查詢使用非叢集索引,但索引中缺少查詢所需回傳的欄位(也就是 SELECT 清單中的欄位)時,資料庫就必須在找到索引鍵之後,再回到資料表本身把缺少的欄位補齊。這個「回去補資料」的動作,在執行計畫中就是 Key Lookup。

舉一個具體例子。假設我們有一張 MemberOrders 表,並且建立了以下索引:

CREATE NONCLUSTERED INDEX IX_MemberOrders_Phone
ON dbo.MemberOrders (Phone);

現在有一段查詢:

SELECT MemberId, CreatedAt -- SELECT 清單中的欄位不在索引中
FROM dbo.MemberOrders
WHERE Phone = @Phone;

在這個情境中,索引中只有 Phone,並沒有 MemberIdCreatedAt。資料庫會先透過索引快速找到所有符合 Phone = @Phone 的資料列位置,這一步是 Index Seek,看起來很有效率。

但接下來問題出現了。因為索引中沒有 MemberIdCreatedAt,資料庫只能根據索引裡的 Row Locator,一筆一筆回到資料表,把這兩個欄位讀出來。這個過程就是 Key Lookup。

如果只找到一筆資料,這個動作幾乎感覺不到成本;但如果同一個電話對應到數百筆訂單,資料庫就必須做數百次回表讀取。這種「大量小次數的隨機讀取」通常比一次連續掃描更昂貴,因此效能會明顯下降。

要解決這個問題,可以把查詢需要回傳的欄位加入索引。例如改成:

CREATE NONCLUSTERED INDEX IX_MemberOrders_Phone
ON dbo.MemberOrders (Phone)
INCLUDE (MemberId, CreatedAt); -- 涵蓋 SELECT 清單中的欄位

此時索引的葉節點除了 Phone 之外,也儲存了 MemberIdCreatedAt。當查詢再次執行時,資料庫在索引中就已經取得所有需要的欄位,不必再回到資料表讀資料,也就不會出現 Key Lookup。

這種「索引本身就包含查詢所需全部欄位」的設計,稱為涵蓋索引(covering index)。涵蓋索引讓查詢可以在單一結構內完成,避免額外 I/O。

實例:根據電話或電子郵件查詢會員

假設我們有一個大型的 MemberOrders 表,記錄會員的訂單資料,欄位包含:

  • Phone
  • Email
  • OrderStatus
  • CreatedAt
  • MemberId

現在有一個功能:使用者輸入電話或電子郵件其中之一,系統需要查詢最近一年內「已完成」或「部分退款」的訂單,並找出對應的會員 ID。

查詢特性是:

  • 條件為 Phone 或 Email 二擇一
  • 需要篩選 OrderStatus
  • 需要限制 CreatedAt 為近一年
  • 最終只回傳 MemberId

如果資料表沒有索引,資料庫必須掃描整張表。

若只建立一個 (Phone, Email) 的複合索引,當查詢使用 Email 時,索引順序並不符合查詢條件,資料庫可能仍需掃描大量資料。

較佳做法是建立兩個非叢集索引:

CREATE NONCLUSTERED INDEX IX_MemberOrders_Phone_Status_CreatedAt
ON dbo.MemberOrders (Phone, OrderStatus, CreatedAt)
INCLUDE (MemberId);
 
CREATE NONCLUSTERED INDEX IX_MemberOrders_Email_Status_CreatedAt
ON dbo.MemberOrders (Email, OrderStatus, CreatedAt)
INCLUDE (MemberId);

這樣,當查詢使用 Phone 時,資料庫可以透過第一個索引進行 Index Seek;當使用 Email 時,則使用第二個索引。由於索引包含 MemberId,查詢可以在索引內完成,不需要 Key Lookup。

這種設計讓查詢計畫穩定、可預期,且在資料量持續成長時仍能維持良好效能。

結語

理解資料頁、堆積表、叢集索引與非叢集索引的差異,是設計高效能查詢的基礎。複合索引需考慮前導欄位原則,而涵蓋索引則能避免 Key Lookup 帶來的額外 I/O。

在面對「欄位 A 或欄位 B」的查詢需求時,將其視為兩條獨立查詢路徑,通常比嘗試用單一複合索引涵蓋所有情境來得更合理。透過清楚理解資料庫如何存取資料,才能做出長期穩定且可維護的索引設計。