1. 程式人生 > >SQL Server 效能調優2 之索引(Index)的建立

SQL Server 效能調優2 之索引(Index)的建立

前言

索引是關係資料庫中最重要的物件之一,他能顯著減少磁碟I/O及邏輯讀取的消耗,並以此來提升 SELECT 語句的查詢效能。但它是一把雙刃劍,使用不當反而會影響效能:他需要額外的空間來存放這些索引資訊,並且當資料更新時需要一些額外開銷來保持索引的同步。

形象的來說索引就像字典裡的目錄,你要查詢某一個字的時候可以根據它的比劃/拼音先在目錄中找到對應的頁碼範圍,然後在該範圍中找到這個字。如果沒有這個目錄(索引),你可能需要翻遍整本字典來找到要找的字。

SQL Server 中的索引以 B-Tree 的形式儲存,如下圖:


建立聚集索引(clustered index)來改進效能

RDBMS 隨著資料的增長都會面臨查詢效能的下降,索引就是專門設計來解決這個問題的。聚集索引是所有索引的基礎,沒有它資料表就是一個堆(heap)。聚集索引決定了資料的物理儲存形態,所以一張表上只能有一個聚集索引。SQL Server 的 sys.partitions 系統檢視中記錄著所有聚集索引的資訊(它們的 Index_ID為1)。

聚集索引可以包含多個欄位(列),通常應挑選絕大多數查詢語句中經常涉及到的篩選欄位,並且事先了解以下幾點:

  • 欄位應當包含大量的非重複的值。例如:身份證號
  • 預設情況下主鍵欄位將自動建立聚集索引,但這不是必須的,你可以手工修改為非聚集索引(non-clustered index)
  • 欄位經常參與篩選,即:經常在 WHERE, JOIN, ORDER BY, GROUP BY 語句中使用
  • 欄位經常參與比較,即:經常參與 >, <, >=, <=, BETWEEN, IN 運算
  • 欄位長度越短越好

另外在可能的情況下建議對聚集索引實施以下規則:

  • 包含的欄位都設為唯一(unique)且非空(NOT NULL)
  • 包含欄位的長度越短越好,包含的欄位越少越好
  • 每張表都有聚集索引,並且把 WHERE 中經常使用到的欄位作為該聚集索引的欄位
  • 儘量避免在 varchar 列上建立聚集索引

我們來做一次10w條資料的效能比較(測試資料的生成SQL請參照附錄):

SELECT OrderDate,Amount,Refno FROM ordDemo WHERE Refno<3

索引建立前的執行計劃:


CREATE CLUSTERED INDEX idx_refno ON ordDemo(refno)
GO
--再次執行相同的查詢語句
SELECT OrderDate,Amount,Refno FROM ordDemo WHERE Refno<3
GO
建立索引後的執行計劃:


通過對比我們可發現I/O 消耗從 0.379421 降低為 0.0571991,並且從 Table Scan 處理轉變為 Index Seek。

建立非聚集索引(non-clustered index)來改善效能

上面提到了索引能有效改善查詢效能,但由於一張表只能有一個聚集索引,而一個聚集索引通常無法包含所有必要的列,所以 SQL Server 允許我們建立非聚集索引來實現這個需求。

【 SQL Server 2005 及之前的版本允許建立249 個非聚集索引;SQL Server 2008 及 SQL Server 2012 允許999個非聚集索引】

通常當你在某一個欄位上建立一個唯一鍵(unique key)的時候,SQL Server 會自動在該列上建立一個非聚集索引。sys.partitions 系統表中存放著非聚集索引的相關資訊(Index_ID>1)。

在為某張表建立非聚集索引之前請先確認兩點:該表是否真的需要非聚集索引?該表是否有合適的欄位來建立非聚集索引?

這是因為索引建得不好不但不能帶來效能的提高,還會花費額外的空間來存放索引併產生額外的 I/O 操作!

建立非聚集索引選擇欄位時應遵循以下規則:

  • 欄位應當包含大量的非重複的值。
  • 欄位經常參與等值(=)運算
  • 欄位經常參與篩選,即:經常在 JOIN, ORDER BY, GROUP BY 語句中使用

我們繼續之前的測試,來看看非聚集索引帶來的速度提升:

SELECT OrderDate FROM ordDemo
WHERE OrderDate='2011-11-28 20:29:00.000'
GO
執行計劃如下圖:


建立非聚集索引,並再次執行查詢:

CREATE NONCLUSTERED INDEX idx_orderdate
on ordDemo(orderdate)
GO

SELECT OrderDate FROM ordDemo
WHERE OrderDate='2011-11-28 20:29:00.000'
GO

比較結果非常明顯,非聚集索引建立之後 I/O Cost, CPU Cost, Operator Cost 等消耗大幅下降。

在我們的例子中由於OrderDate 欄位並不在聚集索引中,所以前一次的查詢被解釋成一個index scan。當我們在OrderDate 上建立一個非聚集索引後,查詢將利用起該索引並解釋成 index seek。

隨著表的資料越來越多,用來存放非聚集索引的空間也會越來越大,並逐漸對效能造成影響。遇到這種情況可以把非聚集索引建立在獨立的資料庫檔案或檔案組(filegroup)中,從而減少對同一個檔案的 I/O 操作壓力。

合理的索引覆蓋來改善效能

執行下面的測試 SQL

SELECT OrderDate,OrderID FROM ordDemo
WHERE OrderDate='2011-11-28 20:29:00.000'
GO
觀察執行計劃後你會發現查詢被解析為 index scan,而不是先前的 index seek?這是因為我們已建立的兩個索引都沒有包含 OrderId 欄位。

把 non-clustered Index 刪掉了,重新建一下(把OrderId 欄位也作為索引的欄位)

CREATE NONCLUSTERED INDEX idx_orderdate_orderId
on ordDemo(orderdate DESC,OrderId ASC)
GO
再次執行查詢,執行計劃如下圖


查詢不出意料的再次被解析為 index seek。

注意:

一個索引中最多包含16個欄位,並且這些欄位的長度必須小於 900 byte。

以下型別不能作為索引的關鍵欄位(text, ntext, image, nvarchar(max), varchar(max), varbinary(max))

調整索引的包含欄位(including columns)來提高效能

索引的包含欄位的概念起源自 SQL Server 2005,SQL Server 2008 及 2012 也具備該功能。它允許你在非聚集索引中包含非鍵值(non-key)欄位,這些欄位不會記入索引的大小(這樣我們也就不太會促發上文提到的索引欄位上限)。另外這些欄位的型別可以是除 text, ntext, image 之外的任何型別。

在前文的測試案例中 OrderId 並不是一個關鍵欄位,因為他並沒有在 WHERE 子句中進行篩選,所以把他作為索引的關鍵欄位並不合適,現在我們用 INCLUDE 來把它建立為包含欄位:

--刪除前文的索引
DROP INDEX idx_orderdate_orderId ON ordDemo
GO

--重建索引
CREATE NONCLUSTERED INDEX idx_orderdate_Included
on ordDemo(orderdate DESC)
INCLUDE(OrderID)
GO

--再次查詢
SELECT OrderDate,OrderID FROM ordDemo
WHERE OrderDate='2011-11-28 20:29:00.000'
GO
執行計劃如下圖:

從效能上來說本節的優化結果與上一節的幾乎一致,但採用了包含欄位索引(include column index) 後,你受到的限制更小,並伴隨著索引關鍵欄位的減少,索引的佔用也變小查詢起來更高效。

總結下區分索引關鍵欄位及包含欄位的基本原則:

  • WHERE, ORDER BY, GROUP BY, JOIN-ON 中的使用到的欄位適用於關鍵欄位
  • SELECT, HAVING 中的使用到的欄位適用於包含欄位

使用過濾索引(filtered index)來提高效能

過濾索引起源自 SQL Server 2008 ,SQL Server 2012 也具備該功能,你可以把它看成一個帶著 WHERE 子句的非聚集索引。適當地使用能減少索引的儲存尺寸及維護消耗,同時提高查詢效能。

常規的索引都是對整張表的每條資料進行索引,而過濾索引僅僅對滿足特定條件的記錄進行索引,這個特定條件在建立過濾索引時通過 WHERE 子句來定義。

類似以下的場景你可以考慮採用過濾索引:

一張包含多年資料的巨型表,實際使用中僅查詢當年資料。

一張記錄產品類別的表,包含許多過期不再使用的類別。

一個訂單表,包含OrderStartDate 及 OrderEndDate 欄位。當訂單完成時更新OrderEndDate,其他情況為 null。你可以在 OrderEndDate 上建立過濾索引,這樣當你需要查詢哪些訂單未完成時可以利用。

在建立過濾索引時需要進行一些設定:

  • ARITHABORT = ON
  • CONCAT_NULL_YIELDS_NULL = ON
  • QUOTED_IDENTIFIER = ON
  • ANSI_WARNINGS = ON
  • ANSI_NULLS = ON
  • ANSI_PADDING = ON
  • NUMERIC_ROUNDABORT = OFF
來看一下示例:
SET ANSI_NULLS ON
SET ANSI_PADDING ON
SET ANSI_WARNINGS ON
SET ARITHABORT ON
SET CONCAT_NULL_YIELDS_NULL ON
SET QUOTED_IDENTIFIER ON
SET NUMERIC_ROUNDABORT OFF
GO

CREATE NONCLUSTERED INDEX idx_orderdate_Filtered
on ordDemo(orderdate DESC)
INCLUDE(OrderId)
WHERE OrderDate = '2011-11-28 20:29:00.000'
GO

SELECT OrderDate,OrderID FROM ordDemo WHERE OrderDate='2011-11-28 20:29:00.000'
GO

I/O 消耗從上一節的0.0078751 減少為 0.003125,優化效果非常顯著。

使用列儲存索引(columnstore index)來提高效能

目前為止我們討論的都是行儲存索引(rowstore index),SQL Server 2012 開始支援列儲存索引。

行儲存索引在資料頁(data page)中儲存資料行,列儲存索引在資料頁中儲存資料列。假設我們有一張表(tblEmployee),包括 empId, FirstName, LastName 三列。行儲存索引/列儲存索引表現為以下儲存形式:


顯然當你需要對某幾列值進行查詢篩選的時候,列儲存索引需要訪問的資料頁更少,從而降低了I/O開銷,並因此提高了執行效率。在你決定採用列儲存索引之前建議你確認一下3點:

  • 你的資料表是否可以設定為只讀(read-only)
  • 你的資料表是否非常巨大(百萬級以上)
  • 如果你的資料庫是個OLTP,是否能允許你切換(開/關)列儲存索引

如果以上3點的答案都是OK的,那麼你可以開始使用列儲存索引了,不過你還會受到以下限制:

  • 你不能包含1024個以上欄位
  • 欄位型別只能是以下幾種:

int

big int

small int

tiny int

money

smallmoney

bit

float

real

char(n)

varchar(n)

nchar(n)

nvarchar(n)

date

datetime

datetime2

small datetime

time

datetimeoffset (precision <=2)

decimal 或 numeric (precision <=18)

好,我們來試驗一下列儲存索引:

執行以下的程式碼,根據輸出的執行計劃可以發現它已經利用了我們先前建立的聚集索引(行儲存索引)。

SELECT
  Refno
  ,sum(Amount) as SumAmt
  ,avg(Amount) as AvgAmt
FROM
  ordDemo
WHERE
  Refno>3
Group By
  Refno
Order By
  Refno
GO


接著我們把已經存在的行儲存索引刪除,建立列儲存索引:

DROP INDEX idx_refno ON ordDemo

CREATE NONCLUSTERED COLUMNSTORE INDEX
idx_columnstore_refno
ON ordDemo (Amount,refno)
再次執行相同的查詢語句,執行計劃如下圖:



通過比較,我們可以發現I/O消耗顯著下降:) 

注意:由於建立了列儲存索引,此時該表是隻讀的,如果你要恢復成可寫的狀態必須刪除這個列儲存索引!

附錄

生成測試資料的SQL程式碼:

--建表
CREATE TABLE ordDemo (OrderID INT IDENTITY, OrderDate DATETIME,Amount MONEY, Refno INT)
GO

--插入 100000 條測試資料
INSERT INTO ordDemo (OrderDate, Amount, Refno)
  SELECT TOP 100000
    DATEADD(minute, ABS(a.object_id % 50000 ), CAST('2011-11-04' AS DATETIME)), ABS(a.object_id % 10), CAST(ABS(a.object_id % 13) AS VARCHAR)
  FROM sys.all_objects a
CROSS JOIN sys.all_objects b
GO