1. 程式人生 > >翻譯: Clustered Index Design Considerations 聚集索引設計註意事項

翻譯: Clustered Index Design Considerations 聚集索引設計註意事項

enter 發生 size 謂詞 detailed 外鍵 防止 des 16px

原文出自:《Pro SQL Server Internals, 2nd edition》的CHAPTER 7 Designing and Tuning the Indexes中的Clustered Index Design Considerations一節(即P155~P165),Dmitri Korotkevitch,侵刪

每次更改聚集索引鍵的值時,都會發生兩件事。 首先,SQL Server將行移動到聚集索引頁鏈和數據文件中的不同位置。 其次,它更新了row-id,它是聚集索引鍵。 存儲的行id,需要在所有非聚集索引中更新。 就I / O而言,這可能是昂貴的,特別是在批量更新的情況下。 此外,它可以增加聚集索引的碎片,並且在行ID大小增加的情況下,可以增加非聚集索引的碎片。 因此,最好有一個靜態聚集索引,其中鍵值不會改變。

所有非聚集索引都使用聚集索引鍵作為row-id。 過寬的聚集索引鍵會增加非聚集索引行的大小,並且需要更多空間來存儲它們。 因此,SQL Server需要在索引或範圍掃描操作期間處理更多數據頁,這會降低索引的效率。

在非唯一非聚集索引的情況下,row-id也存儲在非葉索引級別,這反過來會減少每頁索引記錄的數量,並可能導致索引中的額外中間級別。 盡管非葉索引級別通常緩存在內存中,但每次SQL Server遍歷非聚集索引B-Tree時,這都會引入額外的邏輯讀取。

最後,較大的非聚集索引在緩沖池中占用更多空間,並在索引維護期間引入更多開銷。 顯然,不可能提供一個通用閾值來定義可應用於任何表的密鑰的最大可接受大小。 但是,作為一般規則,最好使用窄的聚集索引鍵,索引鍵盡可能小。

將聚集索引定義為唯一的也是有益的。 這個很重要的原因並不明顯。 考慮一種情況,其中表沒有唯一的聚集索引,並且您希望在執行計劃中運行使用非聚集索引查找的查詢。 在這種情況下,如果非聚集索引中的row-id不是唯一的,則SQL Server將不知道在鍵查找操作期間要選擇哪個聚集索引行。

SQL Server通過向非唯一的c lustered索引添加另一個名為uniquifier的可空整數列來解決此類問題。 對於第一次出現的鍵值,SQL Server使用NULL填充uniquifier,為插入表中的每個後續副本自動增加它。

註意:每個聚集索引鍵值的可能重復項數量受整數域值的限制。 具有相同聚集索引鍵的行不能超過2,147,483,648。 這是理論上的限制,創建具有如此差的選擇性的索引顯然是個壞主意。

讓我們看看非唯一聚集索引中的uniquifiers引入的開銷。 清單7-1中顯示的代碼創建了三個具有相同結構的不同表,並使用每個65,536行填充它們。 表dbo.UniqueCI是唯一定義了唯一聚集索引的表。 表dbo.NonUniqueCINoDups沒有任何重復的鍵值。 最後,表dbo.NonUniqueCDups在索引中有大量重復項。

清單7-1. 非唯一聚簇索引:表創建

 
 create table dbo.UniqueCI 
 ( 
     KeyValue int not null, 
     ID int not null, 
     Data char(986) null, 
     VarData varchar(32) not null 
         constraint DEF_UniqueCI_VarData 
         default Data 
 ); 
  
create unique clustered index IDX_UniqueCI_KeyValue 
 on dbo.UniqueCI(KeyValue); 
  
create table dbo.NonUniqueCINoDups 
 ( 
     KeyValue int not null, 
     ID int not null, 
     Data char(986) null, 
     VarData varchar(32) not null 
         constraint DEF_NonUniqueCINoDups_VarData 
         default Data 
 ); 
 
create /*unique*/ clustered index IDX_NonUniqueCINoDups_KeyValue 
 on dbo.NonUniqueCINoDups(KeyValue); 
  
create table dbo.NonUniqueCIDups 
 ( 
     KeyValue int not null, 
     ID int not null, 
     Data char(986) null, 
     VarData varchar(32) not null 
         constraint DEF_NonUniqueCIDups_VarData 
         default Data 
 ); 
 
create /*unique*/ clustered index IDX_NonUniqueCIDups_KeyValue 
 on dbo.NonUniqueCIDups(KeyValue); 

 -- Populating data 
 ;with N1(C) as (select 0 union all select 0) -- 2 rows 
 ,N2(C) as (select 0 from N1 as T1 cross join N1 as T2) -- 4 rows 
 ,N3(C) as (select 0 from N2 as T1 cross join N2 as T2) -- 16 rows 
 ,N4(C) as (select 0 from N3 as T1 cross join N3 as T2) -- 256 rows 
 ,N5(C) as (select 0 from N4 as T1 cross join N4 as T2) -- 65,536 rows 
 ,IDs(ID) as (select row_number() over (order by (select null)) from N5) 
 insert into dbo.UniqueCI(KeyValue, ID) 
     select ID, ID from IDs; 
 
insert into dbo.NonUniqueCINoDups(KeyValue, ID) 
     select KeyValue, ID from dbo.UniqueCI; 
  
insert into dbo.NonUniqueCIDups(KeyValue, ID) 
     select KeyValue % 10, ID from dbo.UniqueCI; 

現在,讓我們看一下每個表的聚集索引的物理統計信息。 代碼如清單7-2所示,結果如圖7-1所示。

清單7-2. 非唯一聚集索引:檢查聚集索引的行大小

 select index_level, page_count, min_record_size_in_bytes as [min row size] 
     ,max_record_size_in_bytes as [max row size] 
     ,avg_record_size_in_bytes as [avg row size] 
 from 
      sys.dm_db_index_physical_stats(db_id(), object_id(Ndbo.UniqueCI), 1, null ,DETAILED); 
  
select index_level, page_count, min_record_size_in_bytes as [min row size] 
     ,max_record_size_in_bytes as [max row size] 
     , avg_record_size_in_bytes as [avg row size] 
 from 
      sys. dm_db_index_physical_stats(db_id(), object_id(Ndbo.NonUniqueCINoDups), 1, null ,DETAILED); 
  
select index_level, page_count, min_record_size_in_bytes as [min row size] 
     ,max_record_size_in_bytes as [max row size] 
     ,avg_record_size_in_bytes as [avg row size] 
 from 
      sys. dm_db_index_physical_stats(db_id(), object_id(Ndbo.NonUniqueCIDups), 1, null ,DETAILED); 

技術分享圖片

7-1. 非唯一聚集索引:聚集索引的行大小

即使dbo.NonUniqueCINoDups表中沒有重復的鍵值,仍然有兩個額外的字節添加到該行。 SQL Server將一個uniquifier存儲在數據的可變長度部分中,並且這兩個字節由可變長度數據偏移數組中的另一個條目添加。 在這種情況下,當聚集索引具有重復值時,uniquifiers會再添加另外四個字節,這會產生總共六個字節的開銷。

值得一提的是,在某些邊緣情況下,uniquifier使用的額外存儲空間可以減少可以放入數據頁面的行數。 我們的例子說明了這種情況。 如您所見,dbo.UniqueCI使用的數據頁數比其他兩個表少15%。

現在,讓我們看看uniquifier如何影響非聚集索引。 清單7-3中顯示的代碼在所有三個表中創建非聚集索引。 圖7-2顯示了這些索引的物理統計信息。

create nonclustered index IDX_UniqueCI_ID 
 on dbo.UniqueCI(ID); 

create nonclustered index IDX_NonUniqueCINoDups_ID 
 on dbo.NonUniqueCINoDups(ID); 
 
create nonclustered index IDX_NonUniqueCIDups_ID 
 on dbo.NonUniqueCIDups(ID); 
 
select index_level, page_count, min_record_size_in_bytes as [min row size] 
     ,max_record_size_in_bytes as [max row size] 
     ,avg_record_size_in_bytes as [avg row size] 
from 
     sys. dm_db_index_physical_stats(db_id(), object_id(Ndbo.UniqueCI), 2, null ,DETAILED); 

from 
     sys. dm_db_index_physical_stats(db_id(), object_id(Ndbo.NonUniqueCINoDups), 2, null ,DETAILED); 

select index_level, page_count, min_record_size_in_bytes as [min row size] 
     ,max_record_size_in_bytes as [max row size] 
     ,avg_record_size_in_bytes as [avg row size] 
from 
     sys. dm_db_index_physical_stats(db_id(), object_id(Ndbo.NonUniqueCIDups), 2, null ,DETAILED); 
  
select index_level, page_count, min_record_size_in_bytes as [min row size] 
     ,max_record_size_in_bytes as [max row size] 
     ,avg_record_size_in_bytes as [avg row size] 

技術分享圖片

7-2. N個非唯一聚集索引:非聚集索引的行大小

dbo.NonUniqueCINoDups表中的非聚集索引沒有開銷。您可能還記得,SQL Server不會將偏移信息存儲在可變長度偏移數組中,以便存儲尾隨列空數據。盡管如此,uniquifier在dbo.NonUniqueCIDups表中引入了8個字節的開銷。這八個字節由一個四字節的unquifier值,一個雙字節的可變長度數據偏移數組條目和一個存儲行中可變長度列數的雙字節條目組成。

我們可以通過以下方式總結uniquifier的存儲開銷。對於具有uniquifier為NULL的行,如果索引至少有一個存儲NOT NULL值的可變長度列,則會產生兩個字節的開銷。該開銷來自uniquifier列的可變長度偏移數組條目。否則沒有開銷。

在填充uniquifier的情況下,如果存在存儲NOT NULL值的可變長度列,則開銷為六個字節。否則,開銷是八個字節。

提示:如果預計聚集索引值中存在大量重復項,則可以添加整數標識列作為索引的最右列,從而使其唯一。 與由uniquifiers引入的不可預測的高達8字節的存儲開銷相比,這為每一行增加了四字節可預測的存儲開銷。 當您通過其所有聚集索引列引用該行時,這還可以提高單個查找操作的性能。

以最小化插入新行導致的索引碎片的方式設計聚集索引是有益的。實現此目標的方法之一是使聚集索引值不斷增加。標識列上的索引就是一個這樣的例子。另一個示例是使用插入時的當前系統時間填充的日期時間列。

然而,不斷增加的指數存在兩個潛在的問題。第一個涉及統計。正如您在第3章中學到的,當直方圖中不存在參數值時,SQL Server中的遺留基數估計器會低估基數。您應該將此類行為納入系統的統計信息維護策略,除非您使用新的SQL Server 2014-2016基數估算器,該估算器假定直方圖之外的數據具有與表中其他數據類似的分布。

下一個問題更復雜。隨著索引的不斷增加,數據總是插入到索引的末尾。一方面,它可以防止頁面拆分並減少碎片。另一方面,它可能導致熱點,這是當多個會話試圖修改相同數據頁和/或分配新頁面或範圍時發生的序列化延遲。 SQL Server不允許多個會話更新相同的數據結構,而是序列化這些操作。

除非系統以非常高的速率收集數據並且索引每秒處理數百個插入,否則熱點通常不是問題。我們將在第27章“系統故障排除”中討論如何檢測這樣的問題。

最後,如果系統有一組經常執行且重要的查詢,那麽考慮一個優化它們的聚集索引可能是有益的。這消除了昂貴的密鑰查找操作並提高了系統的性能。

即使可以使用覆蓋非聚集索引來優化此類查詢,但它並不總是理想的解決方案。在某些情況下,它需要您創建非常寬的非聚集索引,這將占用磁盤和緩沖池中的大量存儲空間。

另一個重要因素是修改列的頻率。將經常修改的列添加到非聚集索引需要SQL Server在多個位置更改數據,這會對系統的更新性能產生負面影響並增加阻塞。

盡管如此,並不總是能夠設計滿足所有這些準則的聚集索引。此外,您不應將這些指南視為絕對要求。您應該分析系統,業務需求,工作負載和查詢,並選擇有益於您的聚集索引,即使它們違反了某些準則。

身份,序列和唯一標識符

人們通常選擇身份,序列和唯一標識符作為聚集索引鍵。 與往常一樣,這種方法有其自身的優缺點。

在此類列上定義的聚集索引是唯一的,靜態的和窄的。 此外,身份和序列不斷增加,這減少了索引碎片。 其中一個理想的用例是目錄實體表。 作為示例,您可以考慮存儲客戶,文章或設備列表的表。 這些表存儲數千甚至數百萬行,盡管數據相對靜態,因此熱點不是問題。 此外,這些表通常由外鍵引用並用於連接。 integer或bigint列上的索引非常緊湊和高效,這將提高查詢的性能。

註意:我們將在第8章“約束”中更詳細地討論外鍵約束。

在事務表的情況下,身份或序列列上的聚集索引效率較低,事務表由於它們引入的潛在熱點而以非常高的速率收集大量數據。

另一方面,Uniqueidentifiers很少是集群和非集群索引的理想選擇。 使用NEWID()函數生成的隨機值極大地增加了索引碎片。 此外,uniqueidentifiers上的索引會降低批處理操作的性能。 讓我們看一個示例並創建兩個表:一個表在標識列上有聚集索引,另一個在uniqueidentifier列上有聚集索引。 在下一步中,我們將在兩個表中插入65,536行。 您可以在清單7-4中看到執行此操作的代碼。

清單7-4. Uniqueidentifiers:表創建

create table dbo.IdentityCI 
 ( 
     ID int not null identity(1,1), 
     Val int not null, 
     Placeholder char(100) null 
 ); 
 
create unique clustered index IDX_IdentityCI_ID 
 on dbo.IdentityCI(ID); 
 
create table dbo.UniqueidentifierCI 
 ( 
     ID uniqueidentifier not null 
         constraint DEF_UniqueidentifierCI_ID 
         default newid(),   
     Val int not null, 
     Placeholder char(100) null, 
 ); 
 
create unique clustered index IDX_UniqueidentifierCI_ID 
 on dbo.UniqueidentifierCI(ID) 
 go 
 
;with N1(C) as (select 0 union all select 0) -- 2 rows 
 ,N2(C) as (select 0 from N1 as T1 cross join N1 as T2) -- 4 rows 
 ,N3(C) as (select 0 from N2 as T1 cross join N2 as T2) -- 16 rows 
 ,N4(C) as (select 0 from N3 as T1 cross join N3 as T2) -- 256 rows 
 ,N5(C) as (select 0 from N4 as T1 cross join N4 as T2) -- 65,536 rows 
 ,IDs(ID) as (select row_number() over (order by (select null)) from N5) 
 insert into dbo.IdentityCI(Val) 
     select ID from IDs; 

;with N1(C) as (select 0 union all select 0) -- 2 rows 
 ,N2(C) as (select 0 from N1 as T1 cross join N1 as T2) -- 4 rows 
 ,N3(C) as (select 0 from N2 as T1 cross join N2 as T2) -- 16 rows 
 ,N4(C) as (select 0 from N3 as T1 cross join N3 as T2) -- 256 rows 
 ,N5(C) as (select 0 from N4 as T1 cross join N4 as T2) -- 65,536 rows 
 ,IDs(ID) as (select row_number() over (order by (select null)) from N5) 
 insert into dbo.UniqueidentifierCI(Val) 
     select ID from IDs; 

我的計算機上的執行時間和讀取次數如表7-1所示。 圖7-3顯示了兩個查詢的執行計劃。

技術分享圖片

圖7-3. 將數據插入表中:執行計劃

如您所見,uniqueidentifier列上的索引有另一個排序運算符。 SQL Server在插入之前對隨機生成的uniqueidentifier值進行排序,這會降低查詢的性能。

讓我們在表中插入另一批行並檢查索引碎片。 執行此操作的代碼如清單7-5所示。 圖7-4顯示了查詢的結果。


清單7-5. Uniqueidentifiers:插入行並檢查碎片

 ;with N1(C) as (select 0 union all select 0) -- 2 rows 
 ,N2(C) as (select 0 from N1 as T1 cross join N1 as T2) -- 4 rows 
 ,N3(C) as (select 0 from N2 as T1 cross join N2 as T2) -- 16 rows 
 ,N4(C) as (select 0 from N3 as T1 cross join N3 as T2) -- 256 rows 
 ,N5(C) as (select 0 from N4 as T1 cross join N4 as T2) -- 65,536 rows 
 ,IDs(ID) as (select row_number() over (order by (select null)) from N5) 
 insert into dbo.IdentityCI(Val) 
     select ID from IDs; 

;with N1(C) as (select 0 union all select 0) -- 2 rows 
 ,N2(C) as (select 0 from N1 as T1 cross join N1 as T2) -- 4 rows 
 ,N3(C) as (select 0 from N2 as T1 cross join N2 as T2) -- 16 rows 
 ,N4(C) as (select 0 from N3 as T1 cross join N3 as T2) -- 256 rows 
 ,N5(C) as (select 0 from N4 as T1 cross join N4 as T2) -- 65,536 rows 
 ,IDs(ID) as (select row_number() over (order by (select null)) from N5) 
 insert into dbo.UniqueidentifierCI(Val) 
     select ID from IDs; 
 
select page_count, avg_page_space_used_in_percent, avg_fragmentation_in_percent 
 from sys.dm_db_index_physical_stats(db_id(),object_id(Ndbo.IdentityCI),1,null,DETAILED); 
 
select page_count, avg_page_space_used_in_percent, avg_fragmentation_in_percent 
 from  sys.dm_db_index_physical_stats(db_id(),object_id(Ndbo.UniqueidentifierCI),1,null ,DETAILED); 

技術分享圖片

圖7-4. 索引碎片


如您所見,uniqueidentifier列上的索引嚴重碎片化,與標識列上的索引相比,它使用的數據頁數大約多40%。 在uniqueidentifier列的索引中的批量插入會在數據文件的不同位置插入數據,這會導致在大型表的情況下出現繁重的隨機物理I / O. 這可能會顯著降低操作性能。

個人經驗

前段時間,我參與了一個系統的優化,該系統具有250 GB的表,其中包含一個聚簇索引和三個非聚簇索引。 其中一個非聚集索引就是索引 uniqueidentifier列。 通過刪除此索引,我們能夠將50,000行的批量插入從45秒加速到7秒。

當您想要在uniqueidentifier列上創建索引時,有兩種常見用例。第一個是支持跨多個數據庫的值的唯一性。想想可以將行插入每個數據庫的分布式系統。開發人員經常使用uniqueidentifiers來確保每個鍵值在系統範圍內都是唯一的。此類實現中的關鍵元素是如何生成鍵值。

正如您已經看到的,使用NEWID()函數或客戶端代碼生成的隨機值會對系統性能產生負面影響。但是,您可以使用NEWSEQUENTIALID()函數,該函數生成唯一且通常不斷增加的值(SQL Server會不時重置其基值)。使用NEWSEQUENTIALID()函數生成的uniqueidentifier列的索引類似於identity和sequence列的索引;但是,您應該記住,uniqueidentifier數據類型使用16字節的存儲空間,而4字節的int或8字節的bigint數據類型。

作為替代解決方案,您可以考慮創建具有兩列的復合索引(InstallationId,Unique_Id_Within_Installation)。這兩列的組合保證了多個安裝和數據庫的唯一性,並且比獨特標識符使用更少的存儲空間。您可以使用整數標識或序列來生成Unique_Id_Within_Installation值,這將減少索引的碎片。

如果需要在數據庫中的所有實體上生成唯一鍵值,則可以考慮在所有實體中使用單個序列對象。此方法滿足要求,但使用比uniqueidentifier更小的數據類型。

另一個常見用例是安全性,其中uniqueidentifier值用作安全性令牌或隨機對象ID。不幸的是,您無法在此方案中使用NEWSEQUENTIALID()函數,因為可以猜測該函數返回的下一個值。

在這種情況下,一種可能的改進是使用CHECKSUM()函數創建計算列,然後對其進行索引,而不在uniqueidentifier列上創建索引。代碼如清單7-6所示。

清單7-6. 使用CHECKSUM():表結構

 
create table dbo.Articles 
 ( 
     ArticleId int not null identity(1,1), 
     ExternalId uniqueidentifier not null 
         constraint DEF_Articles_ExternalId 
         default newid(), 
     ExternalIdCheckSum as checksum(ExternalId), 
     /* Other Columns */ 
 ); 
 
create unique clustered index IDX_Articles_ArticleId 
 on dbo.Articles(ArticleId); 

create nonclustered index IDX_Articles_ExternalIdCheckSum 
 on dbo.Articles(ExternalIdCheckSum); 

■ 提示:您可以索引計算列而不保留它。

盡管IDX_Articles_ExternalIdCheckSum索引將嚴重分段,但與uniqueidentifier列上的索引(4字節密鑰與16字節)相比,它將更緊湊。 它還提高了批處理操作的性能,因為更快的排序,這也需要更少的內存來進行。

你必須記住的一件事是CHECKSUM()函數的結果不保證是唯一的。 您應該在查詢中包含兩個謂詞,如清單7-7所示。

清單7-7. 使用CHECKSUM():選擇數據

select ArticleId /* Other Columns */ 
from dbo.Articles 
where checksum(@ExternalId) = ExternalIdCheckSum and ExternalId = @ExternalId 

■ 提示:如果需要索引大於900 / 1,700字節的字符串列(這是非聚集索引鍵的最大大小),則可以使用相同的技術。 即使這樣的索引不支持範圍掃描操作,它也可以用於點查找。

翻譯: Clustered Index Design Considerations 聚集索引設計註意事項