前面的效能調校算普通,接下來才是最重要的效能調教方式,索引。
table 上面是否存在索引,代表兩種截然不同的資料存取方式。
一種是逐一檢查 table 的每一筆資料列,也就是 SCAN
另一種是直接定位到所需的資料列,SEEK。
為了實際觀察索引的運作方式,假設想查看 Production.Product table 中的所有產品,並依產品名稱排序
SELECT TOP 10
p.ProductID,
p.[Name],
p.StandardCost,
p.[Weight],
ROW_NUMBER() OVER (ORDER BY p.NAME DESC) AS RowNumber
FROM Production.Product p
ORDER BY p.NAME DESC;
這個查詢可以想像成它就是一個索引,依照 Name排序,然後透過 rownumber 找資料。
這種東西就是叢集索引的具象化。
但話又說回來,這個查詢因為他沒有 where,所以這個查詢會掃描資料表中的所有資料。
假設要篩選資料,要求回傳 StandardCost > 200 的資料,那系統就必須掃描整張表,逐筆比較欄位值,以判斷每一筆資料列是否超過 200。
如果要在這些資料上建立一種索引,可以採用幾種不同的方法
是以類似字典的方式存資料,資料會按照順序排列,但其中仍可能包含重複值。
依照剛剛的假設要回傳 StandardCost > 200 的資料,這時候只需要把查詢改成 StandardCost DESC 排序,然後定位到第一個大於 200 的值,接著回傳後面所有的資料就可。
能夠將資料排序,並依該排序方式實際存資料的索引,叫做叢集索引。
SQL Server 的資料儲存架構是以叢集索引為核心,因此叢集索引是資料表中最重要、最需要妥善定義的索引。
另外建立一組已排序的值,但不會改變原始資料本身的儲存方式,就如同字典後面的注音索引一樣。
這種索引會保留各個值,以及一個用來指向原始資料儲存位置的頁碼。
這種結構就是非叢級索引
然後在實際的 SQL Server 中,如果 table 具有叢集索引,這個定位資訊會是叢集索引的 key。
如果一個 table 沒有叢集索引,那他就叫做 heap,他會用 RID,來標示他在哪裡。
索引有好幾種,在基礎篇我只介紹其中三種,在效能調教這裡我會全部說明
Clustered Index
Nonclustered Index
Hash Index
紅色的是基礎索引,在基礎篇有介紹過
如果沒有索引,那 table 就是以 heap 來當作資料儲存結構,在 heap 裡面搜尋的時候,只能逐列檢查,這過程就是 scan。
叢集索引的第一項優點,就是資料具有順序,使搜尋更加容易。
SQL Server 中的資料儲存是 8kb 一個 page
page 是資料從硬碟移入記憶體時的最小傳輸單位,因此,一個 page 能容納多少資料,就變得非常重要。
而一般而言,非叢集索引的體積較小,因為它的內容只有 :
一個或多個 key
額外加入的 include 欄位
因為這個特性,所以非叢集索引通常比原始資料列精簡,因此每個 page 可以容納更多索引資料列。這也表示查詢時可能只需要從硬碟讀取更少的 page 載入記憶體,因此可以提升效能;
當然,實際效果還是取決於查詢本身。
非叢集索引的另一個優點,是他可以跟 table 分開儲存。甚至可以放在完全不同的硬碟上,藉此提升效能。
所有 rowstore index 以及 columnstore index 的部分結構,都會以 B-TREE 結構儲存。
B-TREE 的目的,是減少尋找特定資料列時所需的讀取次數。
在基礎篇的時候有詳細的說明過何謂 B-TREE,現在進行一個思想實驗。
假設有一張 TABLE
24,14,12|11,20,9|25,15,10|16,13,7|2,26,17|21,18,22|19,6,5|1,8,3|27,4,23
假設PAGE 上的資料長這樣,每一個分隔代表一個 PAGE。
如果我今天要搜尋數值 5,我就必須得掃描所有 PAGE,即使按照現在這個來看,從最左邊開始掃描,掃描到第 7 頁的時候我就已經得到 5 了,後面剩下兩頁還是得掃,因為在資料庫的視角裡,後面兩頁還是有可能有 5。
這會造成,讀取次數取決於實際存取的 PAGE 頁數,因此必須執行 9 次讀取作業,也就是將 9 個資料頁從硬碟讀取並載入記憶體。
但是如果把這個資料排序一下變成
1,2,3|4,5,6|7,8,9|10,11,12|13,14,15|16,17,18|19,20,21|22,23,24|25,26,27
那麼變成只需要讀取 2 個 PAGE,因為 5 是在第二個 PAGE,一旦讀到 6 SQL SERVER 就會停止讀取,因為他已經知道這是排序過的資料,而 6 以後是不可能再有 5 的。
只是經過一個排序,讀取次數就從 9 降低為 2。
但是如果今天是要改搜尋數值 25 呢? 排序後的 25 在最後一頁,所以 25 還是需要讀取 9 次,這時候就要再把這個結構改一下,也就是改成 B-TREE。
[1,10,19]
│
┌────────────────┼────────────────┐
│ │ │
[1,4,7] [10,13,16] [19,22,25]
│ │ │
┌────────┼────────┐ │ ┌────────┼────────┐
│ │ │ │ │ │ │
[1,2,3] [4,5,6] [7,8,9] │ [19,20,21] [22,23,24] [25,26,27]
│
┌──────────┼──────────┐
│ │ │
[10,11,12] [13,14,15] [16,17,18]
B-TREE 結構在基礎篇有詳細的講過,就不再提
實際演示一下如果是這種結構要找到數值 5 要讀取幾次。
第一次讀取 ROOT PAGE,確定 5 有在這裏面
第二次她會先去比較分支起點的值,這東西會寫在 METADATA 裡面,所以不用讀取一整頁就可以知道起始值是多少。
所以他開始去看第一個 PAGE 的起始值是 1,第二個 page 起始值是 10,此時他就知道不需要再往後看了,因為 5 小於 10,所以要找的 5 是在第一個 PAGE 裡面,這個時候才去讀第一個 PAGE 裡面的所有內容
第三次就跟第二個步驟一模一樣,去比較起始值,找到定位的 PAGE,然後才讀取那個 PAGE
所以現在來看,總共讀取了 3 次,乍看之下好像比剛剛排序只讀取兩次還要多,但重點是,這個結構,不論你要查什麼值,都是讀取 3 次。而這就是 B-TREE 結構可以快速查詢的好處。
上述的這個 B-TREE 結構,就是 Rowstore Index。
至於為什麼是他精準的知道,起始頁是分成 [1,10,19],中繼頁是分成 [1,4,7],[10,13,16],[19,22,25]
這是 B-TREE 演算法,他會力求平衡,然後以最少頁面為原則,去分這個 PAGE 裡面 KEY 的範圍。
這裡寫的簡化很多,例如說 page 之間怎麼連接的、page 怎麼塞新資料、刪除、metadata 有什麼,這些都寫再基礎那邊。
但是索引雖然可以帶來上述的快速查詢的好處,但同時也會增加成本。
與資料內容相同的 Heap 資料表相比,具有索引的資料表需要更多的儲存空間與記憶體空間
資料異動查詢,也就是 INSERT、UPDATE 與 DELETE 陳述式,執行時間也可能更長,因為系統需要額外的處理時間來維護索引。
所以再決定是否新增索引,需要考慮兩件事
需要衡量索引所帶來的效能改善,但同時也必須具備衡量索引額外成本的能力。至於到底怎麼衡量這個之後再說。
在這方面,最主要的工具是 Extended Events。此外,也可以使用下列 DMV 觀察索引的運作情況:
sys.dm_db_index_operational_stats
sys.dm_db_index_usage_stats
sys.dm_db_index_operational_stats 會顯示索引的實際運作行為,包括鎖定與 I/O 等資訊。
sys.dm_db_index_usage_stats 則會提供索引操作隨時間累積的統計次數。
絕大多數效能量測,都是用擴充事件
不過如果要更細部的資訊,會用到 STATISTICS IO & STATISTICS TIME
但在某些狀況下,啟用這兩個功能會造成量測上的問題,因為擷取跟傳送 I/O 所花費的時間,也會被一併計入時間統計,導致結果稍微不准。
因此,除非需要取得物件層級的 I/O 量測資料,否則通常還是以擴充事件為主。
接下來說明一些索引可能對系統造成那些負面影響,先從一些範例開始。
--隨便建立兩張一模一樣的表
DROP TABLE IF EXISTS dbo.HeapTest;
DROP TABLE IF EXISTS dbo.IndexTest;
GO
CREATE TABLE dbo.HeapTest
(
C1 INT,
C2 INT,
C3 VARCHAR(50)
);
CREATE TABLE dbo.IndexTest
(
C1 INT,
C2 INT,
C3 VARCHAR(50)
);
GO
WITH Nums AS
(
SELECT TOP (10000)
ROW_NUMBER() OVER (ORDER BY (SELECT 1)) AS n
FROM master.sys.all_columns ac1
CROSS JOIN master.sys.all_columns ac2
)
INSERT INTO dbo.HeapTest
SELECT n, n, 'C3'
FROM Nums;
INSERT INTO dbo.IndexTest
SELECT *
FROM dbo.HeapTest;
GO
--只對 IndexTest 建立索引
CREATE NONCLUSTERED INDEX IX_IndexTest_C2
ON dbo.IndexTest(C2);
GO
--然後執行UPDATE 去觀察邏輯讀數
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
GO
UPDATE dbo.HeapTest
SET C2 = C2 + 10000
WHERE C1 BETWEEN 1 AND 5000;
GO
UPDATE dbo.IndexTest
SET C2 = C2 + 10000
WHERE C1 BETWEEN 1 AND 5000;
GO
會看到這兩行
資料表 'HeapTest'。掃描計數 1,邏輯讀取 46,….
資料表 'IndexTest'。掃描計數 1,邏輯讀取 20241,….
總讀取次數爆增是因為這項工作不只是修改 Heap 資料表中的資料列而已,還必須同步維護叢集索引的排序與結構,因此需要執行更多作業。
雖然資料異動時會維護索引產生額外成本,但是如果是 UPDATE 或是 DELETE,還是有可能因為索引而變得更快,因為在真正的更新或刪除之前,你還是必須得定位到該資料列。
而且很多時候,這裡所獲得的效能收益會大於索引所帶來的成本。
資料行存放區索引是依照欄來儲存資料,不是依照一般傳統想像的列來存放資料。
透過這種改變資料的儲存方向,Columnstore index 在處理分析型查詢時會非常有用。例如說 : 彙總、計數。
還有如果只需要擷取所需欄位中的資料,這種索引可以不必讀取整筆資料列中那些不需要的欄位,因此也能從這裡獲得效能上的好處。
還有一個好處,資料會預設以壓縮的方式儲存。Rowstore index 也可以套用壓縮。
然後跟 rowstore index 一樣他也有分成
叢集 Columnstore index
非叢集 Columnstore index
然後也一樣,叢集的會直接改變 table 實際的資料儲存方式。
另外相較於 rowstore,columnstore 會有多一些限制 :
binary、text、varchar(MAX)、CLR 或 XML。稀疏欄位是針對null做得特別處理,在建立table 的時候可以指定欄位 sparse null,他的原理是如果是null就不儲存資料。他的好處是節省儲存空間,是有機會提升效能,因為頁面數會變少,但是前提是這張table 確實存在大約超過 70% 的值都是 null 的狀況,阿他的代價就是 sparse null 會讓正常有值的欄位,需要更多資源去處理,所以可以理解成以下重點 :
Sparse 是用「非 NULL 時較高的儲存與處理成本」,換取「大量 NULL 時大幅節省 Row Storage」。
這個意思是如果有 columnstore 的 table 是不能進行 insert、update、delete 的,所以2016之前的處理方式都是 drop index ,然後處理完之後再把 columnstore 加回來,不過 2016(含)之後可以直接修改沒有問題。
Columnstore Index 最適合用於較大型的資料集,通常是超過 100,000 筆資料列。較小型的資料集有時也能獲得效益,但並不一定
10萬筆的原因等等下面會說,是因為 10萬左右就會自動進行 rowgroup 壓縮的作業。
--由於原本的 AdventureWorks2022 沒有這麼大張的表
--所以我去用網路上找的指令出來生成
--http://dataeducation.com/thinking-big-adventure/
--要用ai 也是可以
USE AdventureWorks2022
GO
SELECT
p.ProductID + (a.number * 1000) AS ProductID,
p.Name + CONVERT(VARCHAR, (a.number * 1000)) AS Name,
p.ProductNumber + '-' + CONVERT(VARCHAR, (a.number * 1000)) AS ProductNumber,
p.MakeFlag,
p.FinishedGoodsFlag,
p.Color,
p.SafetyStockLevel,
p.ReorderPoint,
p.StandardCost,
p.ListPrice,
p.Size,
p.SizeUnitMeasureCode,
p.WeightUnitMeasureCode,
p.Weight,
p.DaysToManufacture,
p.ProductLine,
p.Class,
p.Style,
p.ProductSubcategoryID,
p.ProductModelID,
p.SellStartDate,
p.SellEndDate,
p.DiscontinuedDate
INTO bigProduct
FROM Production.Product AS p
CROSS JOIN master..spt_values AS a
WHERE
a.type = 'p'
AND a.number BETWEEN 1 AND 50
GO
ALTER TABLE bigProduct
ALTER COLUMN ProductId INT NOT NULL
GO
ALTER TABLE bigProduct
ADD CONSTRAINT pk_bigProduct PRIMARY KEY (ProductId)
GO
SELECT
ROW_NUMBER() OVER
(
ORDER BY
x.TransactionDate,
(SELECT NEWID())
) AS TransactionID,
p1.ProductID,
x.TransactionDate,
x.Quantity,
CONVERT(MONEY, p1.ListPrice * x.Quantity * RAND(CHECKSUM(NEWID())) * 2) AS ActualCost
INTO bigTransactionHistory
FROM
(
SELECT
p.ProductID,
p.ListPrice,
CASE
WHEN p.productid % 26 = 0 THEN 26
WHEN p.productid % 25 = 0 THEN 25
WHEN p.productid % 24 = 0 THEN 24
WHEN p.productid % 23 = 0 THEN 23
WHEN p.productid % 22 = 0 THEN 22
WHEN p.productid % 21 = 0 THEN 21
WHEN p.productid % 20 = 0 THEN 20
WHEN p.productid % 19 = 0 THEN 19
WHEN p.productid % 18 = 0 THEN 18
WHEN p.productid % 17 = 0 THEN 17
WHEN p.productid % 16 = 0 THEN 16
WHEN p.productid % 15 = 0 THEN 15
WHEN p.productid % 14 = 0 THEN 14
WHEN p.productid % 13 = 0 THEN 13
WHEN p.productid % 12 = 0 THEN 12
WHEN p.productid % 11 = 0 THEN 11
WHEN p.productid % 10 = 0 THEN 10
WHEN p.productid % 9 = 0 THEN 9
WHEN p.productid % 8 = 0 THEN 8
WHEN p.productid % 7 = 0 THEN 7
WHEN p.productid % 6 = 0 THEN 6
WHEN p.productid % 5 = 0 THEN 5
WHEN p.productid % 4 = 0 THEN 4
WHEN p.productid % 3 = 0 THEN 3
WHEN p.productid % 2 = 0 THEN 2
ELSE 1
END AS ProductGroup
FROM bigproduct p
) AS p1
CROSS APPLY
(
SELECT
transactionDate,
CONVERT(INT, (RAND(CHECKSUM(NEWID())) * 100) + 1) AS Quantity
FROM
(
SELECT
DATEADD(dd, number, '20050101') AS transactionDate,
NTILE(p1.ProductGroup) OVER
(
ORDER BY number
) AS groupRange
FROM master..spt_values
WHERE
type = 'p'
) AS z
WHERE
z.groupRange % 2 = 1
) AS x
ALTER TABLE bigTransactionHistory
ALTER COLUMN TransactionID INT NOT NULL
GO
ALTER TABLE bigTransactionHistory
ADD CONSTRAINT pk_bigTransactionHistory PRIMARY KEY (TransactionID)
GO
CREATE NONCLUSTERED INDEX IX_ProductId_TransactionDate
ON bigTransactionHistory
(
ProductId,
TransactionDate
)
INCLUDE
(
Quantity,
ActualCost
)
GO
Columnstore index 不是像 rowstore index 一樣用 b-tree 結構來存。
他會依 table 中的各個欄位重新組織彙整
然後資料還會被切分成多個資料列群組,每個壓縮 Row Group 最多可容納約 104 萬筆資料;大量載入達到 102,400 筆時,會直接建立壓縮 Row Group。
Columnstore Index 資料被更新的時候,變更的內容會儲存在一個叫做 delta store 裡面,delta store 是 sql server 引擎管理的 b-tree 索引,這個使用者無法直接看見,而且他完全由內部控制。新增與修改會在這裡累積到十萬筆後,這些資料才會重新依欄位、壓縮,然後存成一個上面說的資料列群組。刪除的話也一樣。
注意這裡的十萬筆很重要,他會是一個你去判斷 rowstore 還是 columnstore 的標準。
我這是在說當你今天有匯總類的查詢的時候,到底要建 rowstore 還是 columnstore,要去看筆數有沒有超10萬,不是你今天 table 只要一超10萬就去做 columnstore。
絕大多數情況下 delta store 會自己妥善管理不用理他,但是因為他是延後更新、刪除的特性,所以如果有必要,可以去對他進行重建。
資料行存放區索引會將資料依欄位重新組織、分組並壓縮,因此在分析型查詢中能提供非常優異的效能。不過,對於 OLTP 類型查詢常見的單筆資料查找或小範圍查找,執行速度則會慢得多。
在 SQL Server 2025 中,叢集與非叢集資料行存放區索引還多了一項共同特性。現在非叢集資料行存放區索引也可以排序,與叢集資料行存放區索引相同,進一步改善其效能。
這個其實你只要知道可以改善效能就好,實際上是原本的 columnstore 他 rowgorup 會有一個 min、max 值,但是這個 min、max 值他是有可能重疊的,例如說 rowgroup1 min id = 1 max id = 1000、rowgroup2 min id = 3 max id = 1004,這樣一來 id 重疊,每次要找 id > 5 id < 400的時候變成兩個 rowgroup 都要去查,拖慢效能,新版的就是解決得這問題。
有幾個面向的建議 :
首先,必須判斷你的查詢主要是哪一種類型:是 OLTP(線上交易處理)系統常見的單點查找與小範圍掃描,還是包含彙總運算的大規模資料分析查詢。
如果主要支援的是 OLTP 系統,就應該考慮使用 Rowstore Index。更具體來說,應該使用 Rowstore Index 叢集索引作為資料的儲存方式。
另一方面,如果系統需要執行大量的大型分析查詢,就應該著重使用資料行存放區索引。
由於這兩種方式可以互相搭配,例如:
因此,首先應該判斷大多數查詢屬於哪一種類型,之後再依實際需求新增非叢集索引進行調整。
-- 適合 rowstore
SELECT *
FROM Orders
WHERE CustomerID = 1001
AND OrderDate >= '2026-07-01'
AND OrderDate < '2026-08-01';
--適合 Columnstore
SELECT CustomerID,
COUNT(*) AS OrderCount,
SUM(TotalAmount) AS TotalAmount
FROM Orders
GROUP BY CustomerID;
查詢最佳化工具會執行一系列檢查,而這些檢查會直接受到篩選條件影響:
WHERE、JOIN 或 HAVING 子句中的欄位。CHECK 條件約束等限制條件。以下開始觀察 WHERE 實際對查詢造成的影響
SELECT p.ProductID,
p.NAME,
p.StandardCost,
p.Weight
FROM Production.Product p;

執行這個查詢,SQL Server 會執行叢集索引掃描。
他不做 table scan 跑去做 clustered index scan 是因為這 table 是叢集索引儲存的。
另外讀取資訊如下 :
資料表 'Product'。掃描計數 1,邏輯讀取 15,實體讀取 1,……..
SQL Server 執行次數:
,CPU 時間 = 15 ms,經過時間 = 41 ms…….
現在加入 where 去觀察
SELECT p.ProductID,
p.NAME,
p.StandardCost,
p.Weight
FROM Production.Product AS p
WHERE p.ProductID = 738;

ProductID 欄位上有一個名為 PK_Product_ProductID 的索引。這不只是一個索引,還是一個唯一索引,代表它具有極高的選擇性。
因此,查詢最佳化工具會正確地決定,以不同於先前的方式執行這個查詢
還可以更直觀的看到讀取次數跟執行時間下降 :
資料表 'Product'。掃描計數 0,邏輯讀取 2,實體讀取 0,…….
SQL Server 執行次數:
,CPU 時間 = 0 ms,經過時間 = 0 ms。
可以明顯看出,加入 WHERE 子句後,會提供更多資訊給查詢最佳化工具,使其能夠選擇更合適的資料擷取方式。
這不論是使用 Join 或是使用 having,也適用相同的概念。
有一種例外是,就算給了良好的索引,他還是跑去scan
這種狀況是妳資料量非常小,小到可以塞進一個 8k page,那如果是這樣的話 scan 跟 seek 效率會完全相同,所以他是有可能顯示成 scan 的。
這裡的窄的意思是,使用所能採用的最小資料型別
例如,定義整數 INT,會筆定義 VARCHAR(50) 更小,這種。
不過,如果查詢的寫法決定了你必須在較寬的欄位上建立索引,那麼可能就無法採用這項建議。另一種常見做法,是多使用一些磁碟空間,新增一個代理鍵欄位,以避免直接在較寬的欄位上建立索引。
實際上仍應透過實驗與測試來決定。
之所以至少應該考慮較窄的索引鍵,是因為較窄的索引鍵可以讓每個 8 KB 資料頁容納更多資料列,進而減少 I/O。由於需要讀入記憶體的資料頁較少,資料快取的效率也會提高。
將索引標記為唯一索引,可以從多個方面提升效能。查詢最佳化工具會知道,對於任何指定的值,或複合索引鍵中的一組值,最多只會有一筆資料列符合條件。這會影響最佳化工具在擷取資料、執行 JOIN,以及其他操作時所做的選擇。
不過,索引具有唯一性,並不代表它一定比較好。他並不適合在那種資料只有少數幾種的時候使用,例如性別、狀態。
不過這種情況的話可以考慮把其他欄位一起加進來,例如性別+生日,如此選擇性就大幅提高,就很適合做索引。
例如這個查詢
SELECT e.BusinessEntityID,
e.MaritalStatus,
e.BirthDate
FROM HumanResources.Employee AS e
WHERE e.MaritalStatus = 'M'
AND e.BirthDate = '1982-02-11';
/*
這個查詢 MaritalStatus 只有 S 跟 M 兩種資料選擇性不高
而如果 WHERE 同時加上 生日,選擇性就很高
但是目前並沒有適合這種查詢的索引,所以他只會用 SCAN 去處理
*/

CREATE INDEX IX_Employee_Test
ON HumanResources.Employee(MaritalStatus);
--索引建好之後再去跑一次查詢
SELECT e.BusinessEntityID,
e.MaritalStatus,
e.BirthDate
FROM HumanResources.Employee AS e
WHERE e.MaritalStatus = 'M'
AND e.BirthDate = '1982-02-11';

然後結果還是一樣是 SCAN
資料表 'Employee'。掃描計數 1,邏輯讀取 9,….
--用query hint 強制使用索引
SELECT e.BusinessEntityID,
e.MaritalStatus,
e.BirthDate
FROM HumanResources.Employee AS e
WITH (INDEX(IX_Employee_Test))
WHERE e.MaritalStatus = 'M'
AND e.BirthDate = '1982-02-11';

結果有用到 seek 了 但是
資料表 'Employee'。掃描計數 1,邏輯讀取 294……
效能反而變得很差
CREATE INDEX IX_Employee_Test
ON HumanResources.Employee
(
BirthDate,
MaritalStatus
)
WITH DROP_EXISTING;
所以就照我前面說的去建一個複合 key index
SELECT e.BusinessEntityID,
e.MaritalStatus,
e.BirthDate
FROM HumanResources.Employee AS e
WHERE e.MaritalStatus = 'M'
AND e.BirthDate = '1982-02-11';

就會得到一個正確的執行計劃而寫
資料表 'Employee'。掃描計數 1,邏輯讀取 2
效能也變好
這次也不需要 query hint,他會直接用選擇性更高的索引。
DROP INDEX IF EXISTS IX_Employee_Test
ON HumanResources.Employee;
索引欄位的資料型別非常重要
例如,以整數作為索引鍵時,索引搜尋通常會很快,因為 INTEGER(或 INT)資料型別占用空間較小,而且容易進行算術運算。
相較之下,字串資料型別,例如 CHAR、VARCHAR、NCHAR 與 NVARCHAR,需要執行字串比對,而字串比對的成本通常高於整數比對。
假設想在某個欄位上建立索引,而有兩個候選欄位:
INTEGER
CHAR(4)
即使在 SQL Server 2017 與 Azure SQL Database 中,這兩種資料型別都占用 4 Bytes,仍應優先選擇以 INTEGER 資料型別建立索引。
以算術運算為例,CHAR(4) 資料型別中的數值 1,實際上會儲存為 1,後面再補三個空白字元,也就是由下列 4 Bytes 組成:
0x35、0x20、0x20、0x20
CPU 無法直接對這種資料執行算術運算,因此在進行運算之前,必須先將它轉換成整數資料型別。
相較之下,整數資料型別中的數值 1 會儲存為:
0x00000001
CPU 可以直接且容易地對這種資料執行算術運算。
當然,大多數情況下,不會剛好有兩個大小完全相同的資料型別可供選擇,也不一定能自由選用最佳的資料型別。
不過,在設計與建立索引時,仍應將這些因素納入考量。
當索引使用複合索引鍵,也就是包含多個欄位時,資料會先依索引鍵中的第一個欄位排序,再依後續的每個欄位進一步排序。這在統計資料的時候有介紹過。
為了觀察這個現象,接下來再 Person.Address 上建立索引
CREATE INDEX IX_Address_Test
ON Person.Address
(
City,
PostalCode
);
SELECT A.AddressID,
A.City,
A.PostalCode
FROM Person.Address AS A
WHERE A.City = 'Dresden';

為了觀察這個現象,接下來再 Person.Address 上建立索引
SELECT A.AddressID,
A.City,
A.PostalCode
FROM Person.Address AS A
WHERE A.PostalCode = '01071';

資料表 'Address'。掃描計數 1,邏輯讀取 108…..
這個查詢結果跟上一個用城市去篩選的結果一模一樣,因為郵遞區號在邏輯上就是綁定城市,可是呢,因為WHERE 裡面放的東西不同,導致這個查詢效率很差。
但明明不論是城市或郵遞區號,都已經做了索引,還會有如此大的差距的根本原因,就是因為這個複合索引欄位順序的問題,因為城市在前,所以這種情況下去 WHERE 郵遞區號,那 SQL Serever 只能做 scan 整個索引。
DROP INDEX IF EXISTS IX_Address_Test
ON Person.Address;
判斷資料最適合採用哪一種儲存方式,主要取決於針對這些資料執行的查詢類型。
如果是 OLTP 處理,這種類型大多處理較小範圍的資料,甚至可能只處理單一資料列。這類查詢適合用 Rowstore Index
如果是大量資料進行彙總的分析型查詢,則適合用 Columnstore Index。
所以要先確認好。
妳要真的什麼都不會,就丟給AI吧,叫他幫你想怎麼設計索引,但他設計出來的還是得去驗證。
因為設計索引在很大的程度上,都是靠相關經驗,即便像我前面寫的再多,但是為什麼我看一眼就知道索引有問題、執行計畫不對還可以更好,這是經驗,而我很難把它簡化成一套公式交給妳,如果初次設計沒有經驗,那 AI 確實是一個很好的幫手。
還有再次強調,如果存在機敏資料,甚至欄位名稱都算,就不要直接給 AI,做去敏再給
不然就是用本地AI。
Rowstore 是最傳統的資料儲存方式,所以我先從 rowstore index 行為開始介紹。
然後有個事情要先搞清楚,你可能看過 Columnstore Clustered index、Columnstore Nonclustered index 這種名詞,但應該沒看過 Rowstore Clustered index,因為 rowstore 是最常用、最基本的,所以一般時候如果只講 Clustered index 就代表他在講 rowstore 的,不用誤會。
Clustered、Nonclstered 中文就是叢集、非叢集,阿字面意思沒人看得懂,也跟他實際行為我覺得扯不上邊,所以就當他是個名字就好了。
在基礎有說過,叢集索引會改變實際 table 的儲存排序方式,他也會讓 table 從 heap 變成 b-tree 結構。
每一個 table 都只能有一個叢集索引,原因就是因為他會排序儲存,你沒辦法同時這樣牌又那樣排,所以他只能有一個叢集索引。
還有,如果 table 有設定 primary key,那預設會把這個 key 當作叢集索引的 key,預設也就會把這 table 改成 b-tree 結構,當然這個可以改,pk 不一定要是叢集索引 key。
因為這裡著重在效能調教,所以不會特別介紹 heap,其實在基礎也講過 heap 到底是什麼,這邊就再說一次,這種沒有經過組織的結構,資料近來就是一直往後塞往後塞,一直堆,就叫做 heap,中文叫堆積,這中文就翻得不錯。
那這種一直堆的資料結構,就不利找資料,所以除非真的測試後證明索引不適合,否則 table 應該都要有一個叢集索引。
這兩個差在非叢集索引只會存 key 跟 include 的欄位,然後非叢集索引可以在別的硬碟上去建,不會改變本身 table 結構。
為了存取底層資料,非叢集還會多存一個指標 :
雖說是指標,也可以理解為這是資料列定位器
SELECT dl.DatabaseLogID,
dl.PostTime
FROM dbo.DatabaseLog AS dl
WHERE dl.DatabaseLogID = 115;

例如這個執行計畫,他首先就是去看非叢集索引找到 rid,然後利用這個 rid 去實際存放資料的地方找資料。
所以看這執行計畫就可以知道 databaselog 這張 table 他是 heap 結構。
還有一個是索引破碎的問題,但是索引破碎基礎篇解釋過就不再說了。
根據叢集索引的運作方式,以及他跟非叢集索引之間的關係,需要將幾項因素納入考量
由於所有的非叢集索引,都會包含叢集索引的 key,因此從效能角度看,非叢集索引和叢集索引的建立順序很重要。
如果說,先在 table 上建立非叢集索引,再建立叢集索引,那麼一開始所有非叢集索引中的資料列定位器,都會指向 heap table 的 rid。
這個時候才去建立叢集索引的話,就必須重新建立所有非叢集索引,因為他們需要改用以叢集索引 key 來取代 rid 。
這不會影大多數日常操作,只是再建立索引的時候,會增加額外的工作,提高系統負擔。
還有 table 的設計,應該是以叢集索引為基礎去設計,盡量不要是 table 建好之後,才再來想索引怎麼設計。
由於非叢集索引都必須包含叢集索引的 key,所以為了獲得最佳效能,應該盡可能的縮小叢集索引 key 的大小。
例如,如果建立一個很寬的叢集索引鍵,例如 CHAR(500),不只叢集索引中的每個頁面能容納的資料列會變少,所有非叢集索引中的每個頁面也會容納更少的資料列,因為每個非叢集索引都會額外加入這 500 Bytes 的叢集索引鍵。
設計叢集索引鍵時,應將索引鍵欄位的數量、資料型別與大小納入考量。
但我不是在說,當正確的索引鍵比其他候選欄位更寬時,就不應使用它。妳仍然應該選擇適合的索引鍵,只是在可行情況下,應尋找使用更佳索引鍵結構的機會。
以單一步驟重建叢集索引這步驟在日常維護中非常重要。
由於非叢集索引依賴叢集索引,如果有兩個陳述式重建叢集索引,也就是先執行 DROP INDEX,再執行 CREATE INDEX,就會造成所有非叢集索引被重建兩次。
為了避免這種狀況發生,可以再 CREATE INDEX 陳述式中使用 WITH DROP_EXISTING。
這樣會以單一操作完成重建,並且只影響非叢集索引一次。
由於叢集索引會決定資料的儲存方式,因此每一筆不同的資料列都必須能夠被個別識別。
當叢集索引鍵具有唯一性時,每一筆資料列都可以直接透過該索引鍵值來識別。
但如果叢集索引鍵不是唯一的,SQL Server 就必須在索引鍵中額外加入一個值,使其具備唯一性。這個額外的值稱為 Uniquifier。
Uniquifier 基本上可以視為加入索引鍵中的一個 IDENTITY 欄位。它會增加少量的儲存與處理成本,確切來說,每筆資料列會額外增加 4 Bytes。
此外,Uniquifier 的值也有可能耗盡,雖然這種情況相對少見。
基於上述原因,在可行情況下,應將叢集索引定義為唯一索引。
在此之前,要先記住一個重要考量 : 叢集索引不只是資料擷取機制,他同時也會決定資料的儲存方式。
由於資料表的所有欄位都儲存在叢集索引的葉節點層級,因此,透過叢集索引存取資料,通常應該是最常見的資料存取路徑。
當資料可以直接從叢集索引中取得,而不需要經過其他中間步驟時,通常能獲得較佳效能。
如果在找到符合條件的資料列之後,還必須再執行 Lookup 操作才能取得其餘資料,就會為系統增加額外負擔。
這一點值得再次強調:很多時候,最常見的資料存取路徑是透過資料表的主鍵,這也是許多主鍵會被建立為叢集索引的原因。
不過,資料表中也可能存在其他欄位,能夠提供更好的資料存取路徑。
如果只需要擷取其中少量資料,而且只需要少數幾個欄位,非叢集索引可能會更有用。
當資料擷取結果需要排序時,叢集索引會非常有用;涵蓋查詢的非叢集索引同樣也適合這種情況。
如果叢集索引建立在查詢需要排序的欄位上,資料列就會依照該順序儲存,因此可以省去擷取資料後再進行排序的額外成本。
--先建立一張沒有索引的 table
IF
(
SELECT OBJECT_ID('od')
) IS NOT NULL
DROP TABLE dbo.od;
GO
SELECT pod.PurchaseOrderID,
pod.PurchaseOrderDetailID,
pod.DueDate,
pod.OrderQty,
pod.ProductID,
pod.UnitPrice,
pod.LineTotal,
pod.ReceivedQty,
pod.RejectedQty,
pod.StockedQty,
pod.ModifiedDate
INTO dbo.od
FROM Purchasing.PurchaseOrderDetail AS pod;
--然後下一個需要排序的查詢
SELECT od.PurchaseOrderID,
od.PurchaseOrderDetailID,
od.DueDate,
od.OrderQty,
od.ProductID,
od.UnitPrice,
od.LineTotal,
od.ReceivedQty,
od.RejectedQty,
od.StockedQty,
od.ModifiedDate
FROM dbo.od
WHERE od.ProductID BETWEEN 500 AND 510
ORDER BY od.ProductID;

資料表 'od'。掃描計數 1,邏輯讀取 94…
--然後對被排序那個欄位建一個叢集索引
CREATE CLUSTERED INDEX i1
ON od(ProductID);
--再跑一次一模一樣的
SELECT od.PurchaseOrderID,
od.PurchaseOrderDetailID,
od.DueDate,
od.OrderQty,
od.ProductID,
od.UnitPrice,
od.LineTotal,
od.ReceivedQty,
od.RejectedQty,
od.StockedQty,
od.ModifiedDate
FROM dbo.od
WHERE od.ProductID BETWEEN 500 AND 510
ORDER BY od.ProductID;

資料表 'od'。掃描計數 1,邏輯讀取 8,…..
這次執行計畫就相對簡單多,而且讀取次數也下降到8,就是因為資料存的時候已經排序過了,他不用再排序,所以會更快。
如果用來定義成 KEY 的欄位經常被更新,就會直接影響效能。
由於這些 KEY 欄位同時會作為非叢集索引中的資料列識別資訊,因此當叢集索引鍵被更新時,所有相關的非叢集索引也必須一併更新。
這會增加相當多的額外資源使用量,也可能造成封鎖,因為其他查詢必須等待資料更新完成。
接下來我演示一下這種狀況對效能造成什麼影響
Sales.SpecialOfferProduct table 的 PK 上建立了一個複合叢集索引,而這個 PK 同時也是來自另外兩張資料表的 Foreign Key。這是資料庫中典型的多對多關聯。
BEGIN TRAN;
SET STATISTICS IO ON;
UPDATE Sales.SpecialOfferProduct
SET ProductID = 1
WHERE SpecialOfferID = 1
AND ProductID = 721;
SET STATISTICS IO OFF;
ROLLBACK TRAN;
資料表 'SpecialOfferProduct'。掃描計數 0,邏輯讀取 15,…..
CREATE NONCLUSTERED INDEX ixTest
ON Sales.SpecialOfferProduct(ModifiedDate);
BEGIN TRAN;
SET STATISTICS IO ON;
UPDATE Sales.SpecialOfferProduct
SET ProductID = 1
WHERE SpecialOfferID = 1
AND ProductID = 721;
SET STATISTICS IO OFF;
ROLLBACK TRAN;
資料表 'SpecialOfferProduct'。掃描計數 0,邏輯讀取 21,….
可以讀取次數上升,只是因為加了一個跟查詢無關的非叢集索引,這次的上升是因為必須對非叢集索引執行額外的維護工作。
DROP INDEX ixTest
ON Sales.SpecialOfferProduct;
前面已經討論過這個主題了
非叢集索引的核心概念,是為資料增加更多排序方式,進而提供更多擷取資料的可能途徑。
非叢集索引不會影響資料在資料表頁面中的排列順序,因為它會將索引資訊與資料表其餘資料分開儲存。
若要從非叢集索引導向實際資料列,就需要一個指標,也就是資料列定位器(Row Locator)。無論底層資料是儲存在 Heap、叢集索引,或叢集資料行存放區索引中,都需要透過資料列定位器找到實際資料。
如果底層資料表是 Heap,資料列定位器就是該資料列的 RID。
如果底層資料表具有叢集索引,則叢集索引的索引鍵欄位會作為資料列定位器。
如果非叢集索引建立在Columnstore 索引上,資料列定位器會由一個 8 Bytes 的值組成,其中包含 Columnstore 的 row_group_id 與偏移值。
如果妳的底層 table 是 heap 然後又很常在 update 的話,非叢集索引會稍微慢一點點。
原因是 update 如果資料太大造成 heap 上的 page 遷移了,可是非叢集上面還是保留原本的 rid,這時候 heap 會在原本的地方留下一個 rid,所以當非叢集透過自己存的 rid 找到這個地方,又看到一個 rid,他就又要在跳轉過去,造成兩次 look up,變慢的原因在這裡。
如果底層 table 是 Columnstore 一樣,只是他不是留下 rid,是會額外有一個叫做 mapping index 的東西去去找。
更詳細的原因說明在基礎篇。
當查詢要求的欄位,不包含在查詢最佳化工具所選擇的非叢集索引中時,就必須執行 Lookup。
底層是叢集索引就是 key lookup、是 heap 就是 rid lookup。
Lookup 會從非叢集索引資料列中的資料列定位器開始,找到資料表中對應的實際資料列。除了讀取非叢集索引頁面之外,還必須額外對資料頁執行一次邏輯讀取,並透過 Join 操作將資料組合成完整結果。
不過,如果查詢需要的所有欄位,都已經存在於索引本身,就不需要再存取資料頁。這種索引稱為 涵蓋索引(Covering Index)。
Lookup 也是為什麼大量結果集通常更適合透過叢集索引取得。叢集索引不需要 Lookup,因為叢集索引的葉節點頁面本身就是資料頁。
非叢集索引的用途,是為資料擷取提供更大的彈性。
一張資料表只能有一個叢集索引,但可以建立多個非叢集索引。不過,由於非叢集索引會帶來額外的維護成本,因此仍應盡可能減少其數量。
當你只需要從大型資料表中擷取少量資料列與少數欄位時,非叢集索引最有用。
甚至比叢集還有用,因為叢集會讀到其他不相干的欄位。
但是,隨著需要擷取的欄位數量增加,建立涵蓋索引的可能性就會降低。如果同時還需要擷取大量資料列,任何 Lookup 操作的額外成本也會隨之增加。
若要從資料表中擷取少量資料列,建立索引的欄位應具有較高的選擇性。
此外,有些索引需求並不適合使用叢集索引,例如前面「叢集索引」章節提到的:
在這些情況下,可以使用非叢集索引,因為它不像叢集索引那樣會影響資料表中的其他索引。
在經常更新的欄位上建立非叢集索引,其成本通常低於在該欄位上建立叢集索引。這並不表示完全沒有成本,而是成本較低。
而且更新非叢集索引時,影響範圍只限於基礎資料表與該非叢集索引,不會影響資料表中的其他非叢集索引。
同樣地,在較寬的欄位或多個寬欄位上建立非叢集索引,不會像叢集索引那樣增加其他索引的大小。
不過,即使是在經常更新的欄位,或寬欄位、寬欄位組合上建立非叢集索引,仍然必須謹慎,因為這可能增加資料異動查詢的成本,如前面所述。
當查詢需要擷取的資料列,占資料表總資料量相當大的比例時,非叢集索引並不適合。
這類查詢通常更適合使用叢集索引,因為叢集索引不需要額外執行 Lookup,就能取得完整資料列。
Lookup 除了需要讀取非叢集索引頁面之外,還必須額外讀取資料頁。當查詢要傳回大量資料列時,這些 Lookup 成本會大幅增加,例如在迴圈 Join 中,不斷逐筆執行 Lookup。
SQL Server 查詢最佳化工具會將這項成本納入考量,因此在擷取大型結果集時,可能會放棄使用非叢集索引。
對於包含大量彙總運算的分析型查詢,非叢集索引通常也不如 Columnstore 索引有效。
如果需求是從資料表中擷取大量結果,那麼即使在篩選條件欄位或 Join 條件欄位上建立非叢集索引,通常也不會有太大幫助,除非使用一種特殊的非叢集索引,也就是涵蓋索引(Covering Index)。
SELECT bp.Name AS ProductName,
COUNT(bth.ProductID),
SUM(bth.Quantity),
AVG(bth.ActualCost)
FROM dbo.bigProduct AS bp
JOIN dbo.bigTransactionHistory AS bth
ON bth.ProductID = bp.ProductID
GROUP BY bp.Name;

因為沒有任何篩選條件,所以必須掃描資料表來擷取資料。除此之外,還需要執行一次資料流匯總,才能完成 SUM、COUNT 與 AVG 的資料彙總。
這個計畫還有一個重點是,很多黃色的圖案,那個是平行處理的意思。
還有 STATISTICS IO、TIME 的資訊
以我的電腦來講這個查詢的執行時間是 7.4 秒,以我的標準來說,這是一個成本非常高的查詢。
那前面有說過 Columnstore index 的用途就是來處理這種匯總查詢,所以我現在建立一個 Columnstore index
CREATE NONCLUSTERED COLUMNSTORE INDEX ix_csTest
ON dbo.bigTransactionHistory
(
ProductID,
Quantity,
ActualCost
);
然後再執行一次一樣的查詢
SELECT bp.Name AS ProductName,
COUNT(bth.ProductID),
SUM(bth.Quantity),
AVG(bth.ActualCost)
FROM dbo.bigProduct AS bp
JOIN dbo.bigTransactionHistory AS bth
ON bth.ProductID = bp.ProductID
GROUP BY bp.Name;


先不考慮執行計畫的變化,光看這時間就幾乎提升 10~12 倍,這就是 Columnstore 的優勢。
接下來仔細拆解分析執行計畫
資料行存放區索引掃描是專門為 Columnstore 索引加入的運算子,它裡面包含多項處理功能。
當然只看圖沒什麼用,所以把他的屬性打開,有幾個重點可以先去看。
第一個是 : 估計的執行模式
這裡的處理模式是 batch,也就是他是批次處理,相較於 row mode 一列一列的做,她速度當然更快。
batch mode 大概一次處理約 1000筆資料。
同時可以看到,實際批次數目 38566
實際執行 31263601筆資料
2019 以前,只有 columnstore index 可以用 batch mode
2019 開始,rowstore index 也可以受益於 batch mode
columnstore index 還有一個功能是 Pushdown Aggregate
這是由於 columnstore 的儲存方式,部分匯總的運算可以在擷取資料的同時直接完成。
這是 columnstore index 針對分析型查詢所提供的另一項效能強化
但在我這次範例裡面沒有。
注意我說的是這次,就算你跟我用一樣的資料、一樣的索引、一樣的查詢,也不能保證最佳化會給出完全一樣的執行計畫,所以在操作的時候,可能這些數字或是計畫跟我不一樣,但沒關係,只要知道這些功能是什麼就好。
再來是 JOIN 類型
JOIN 有很多種、巢狀JOIN、HASH JOIN、自適應連結等等
這次我的執行計劃給出的是 HASH JOIN
但是先非常簡單粗略說明一下 JOIN 特性
對較大的資料集而言,巢狀JOIN 效能可能很差
對較小的資料集而言,HASH JOIN 也可能效率不佳
還有一個是自適應 JOIN,他會根據查詢所涉及的物件的統計資訊,設定一個資料列數門檻 :
低於門檻,使用巢狀
高於門檻,使用 HASH
自適應連結下方會有兩條可能的執行分之
HASH
巢狀
在我剛剛寫的那個範例裡面沒有,但是以後有看到的話點開屬性可以看到他的門檻
向這個查詢門檻是 56,但我結果有5000,所以很簡單他就是會用 HASH 去處理。
因此,不需要只靠資料列數去判斷,也可以直接從自適應的屬性去看他會選哪條分之。
同時這個執行計畫還說明了一件事 : 在同一個查詢中,可以同時混用 Columnstore 與 Rowstore 的索引及資料表。
再次強調,Columnstore 索引最適合用於大規模的分析型查詢。如果系統中的查詢主要屬於 OLTP 類型,Rowstore 索引會更適合。
由於可以在 Clustered Columnstore 上建立非叢集 Rowstore 索引,也可以在叢集 Rowstore 上建立 Nonclustered Columnstore 索引,因此無論主要採用哪一種儲存方式,都能透過另一種類型的索引處理特殊需求。
當資料量超過一個 Rowgroup 的 102,400 筆資料列門檻時,通常能從 Columnstore 索引獲得最大的效益。不過,根據查詢類型的不同,較小的資料表仍可能獲得一定程度的效能改善。實際效果仍應透過系統測試確認。
使用 Columnstore 索引時,需要注意以下幾點:
102,400 筆資料列的批次,以利用壓縮 Rowgroup。這個前面有出現過了,這次來詳細說明
--先去跑這個
SELECT a.AddressID,
a.AddressLine1,
a.AddressLine2,
a.City,
sp.Name AS StateProvinceName,
a.PostalCode
FROM Person.Address AS a
JOIN Person.StateProvince AS sp
ON a.StateProvinceID = sp.StateProvinceID
WHERE a.City = 'London';

這個東西是最佳化提出的"建議”
首先,這是建議。所以不是他寫你就做,有時候他做得很爛,有時候他不符合妳其他使用情境,而且他不一定是給你最好的索引選擇。
我通常也是會參考她的建議,但幾乎沒有完全按照他說的去建過。
還有,他不會把你現有的索引納入考量,所以他不知道你現在還有什麼索引,我才會說只能參考
然後他也不會建議建立唯一索引、Columnstore index,這些都得是你自己去判斷的。
然後名字記得改,我看過很多[<Name of Missing Index, sysname>]這種名字的索引,因為它直接複製這個遺漏索引建議,看了頭很痛。