相關有關索引碎片的問題,大家應該是聽過不少,也許也很多的朋友已經做了與之相關的工作。那我們今天就來看看這個問題。
為了更好的說明這個問題,我們首先來普及一些背景知識。
知識普及
我們都知道,數據庫中的每一個表要么是堆表,要么就是包含聚集索引的表,或者我們稱之為有序表。如果表是一個堆表,那么在使用非聚集索引查詢數據的時候,會使用書簽查找去底層的數據表中去檢索需要的數據,這個書簽查找會通過每一個索引中包含的行標識(RID)去定位每一個底層數據表的數據行。如果表上面有聚集索引,那么在使用非聚集索引查找其他需要數據的時候,就會使用聚集索引鍵去定位底層的數據行。
我們也知道,索引是由索引頁組成的,索引中的每一個條目包含在頁中。每8個頁組成一個塊。
索引的層級是從底向上的,就是一個樹結構,最下面的就是第0層,也是葉節點。索引中的根節點處於整個索引的最上層。
如果要掃描整個索引,那么就意味着必須要讀取頁節點中的每一個頁(要么是數據頁,要么是索引頁)。其中,每個頁都包含着一個指向它前面的頁和一個指向它后面也的指針。之前,我們也提過:如果單看某一層節點,其實就是一個雙向鏈表。
我們應該知道:頁(不管是數據頁,還是索引頁 ,還是其他的類型的頁)處於的邏輯順序和它的物理順便不一定就是一樣的,也就說,在A頁中的指針指向了它的下一個頁B,也就說A和B頁在邏輯上面是一起的,但是它們在物理上面可能不一樣,甚至B頁和A頁在物理上相隔幾百個頁。
如果在邏輯上面相連的頁在物理存儲級別相隔的越近,那么在讀取這些頁的時候所花的I/O成本也就越小,因為產生磁盤的磁頭移動帶來的延遲。相反,如果他們的物理存儲順序和邏輯順序一致,那么SQL Server在讀取的時候,就可以一次讀取,因為每次會讀取一個塊(8個頁)。
好了,普及知識之后,我們就來看看什么是碎片。
什么是索引碎片
索引碎片可以分為兩類:內部索引碎片和外部索引碎片。下面我們就來具體的看看而這之前的區別以及如何檢查。
內部索引碎片
每一個索引頁中都包含一些索引的條目(就類似數據頁包含很多的數據行一行),這一點我們在之前講過了的。但是,很多的時候,不是每個頁都包含了最大的條數。例如,一個頁的大小8k,也就是4096字節,除去一些頁頭,頁腳等,還剩下8000多字節,如果每個索引條目的大小事100字節,那么這個索引頁最大就可以包含80個條目,但是很多的情況下,卻沒有包含這么多。
也就說,很多的時候,索引頁並沒有完全的填滿,或者這是問題,或許這么我們特意這樣的,我們后續會提到。當我們談到索引碎片的時候,我們往往就是指這些索引頁沒有完全填滿。或者說的更加明白一點就是:我們原本是希望頁都被填滿的,但是隨着數據的增刪改,使得索引中的數據沒有填滿。
我們可以使用
sys.dm_db_index_physical_stats來查看相關的內部碎片的情況,執行查詢如下:
SELECT IX.name AS 'Name' , PS.index_level AS 'Level' , PS.page_count AS 'Pages' , PS.avg_page_space_used_in_percent AS 'Page Fullness (%)' FROM sys.dm_db_index_physical_stats( DB_ID(), OBJECT_ID('Sales.SalesOrderDetail'), DEFAULT, DEFAULT, 'DETAILED') PS JOIN sys.indexes IX ON IX.OBJECT_ID = PS.OBJECT_ID AND IX.index_id = PS.index_id WHERE IX.name = 'PK_SalesOrderDetail_SalesOrderID_SalesOrderDetailID'; GO
執行結果如圖:
我們可以看到每個索引的頁面的填充情況。這是一個針對聚集索引的查詢。因此,這個索引的葉子層的入口是表中的行。層級為0的葉子層有1234頁,每頁的平均密度達到99%以上,說明這個表只有非常小的內部碎片。
下面,我們再來講講外部索引碎片。
外部索引碎片
理解了上面的問題,這個外部索引碎片就好理解了,最簡單的說法就是:索引中的索引頁的邏輯順序和物理順序不一致。我們通過個圖對比的來看看。
在上圖中,一個索引包含了16個頁。但是這16頁不是包含在2個相連的塊中的,而是分布在不同的地方,因為它們之前中的一些塊被其他的對象占用了。這樣就導致了16個頁在物理上面不連續,這就是碎片。在讀取的時候,就會消耗額外的I/O。
和之前一樣,我們可以使用
sys.dm_db_index_physical_stats來查看外部碎片的情況。但是這里的參數值可能要發生變化了:之前在sys.dm_db_index_physical_stats最后一個參數值是'DETAILED',這里我們的值是LIMITED或者Default。因為外部碎片關注的是索引頁之前的連續性問題,不關注每一個頁中的數據,此時只是部分的掃描,沒有必要全部的掃描。大家可以參看MSDN的去進一步的理解這些參數的含義。
查詢如下:
SELECT IX.name AS 'Name' , PS.index_level AS 'Level' , PS.page_count AS 'Pages' , PS.avg_fragmentation_in_percent AS 'External Fragmentation (%)' , PS.fragment_count AS 'Fragments' , PS.avg_fragment_size_in_pages AS 'Avg Fragment Size' FROM sys.dm_db_index_physical_stats( DB_ID(), OBJECT_ID('Sales.SalesOrderDetail'), DEFAULT, DEFAULT, 'LIMITED') PS JOIN sys.indexes IX ON IX.OBJECT_ID = PS.OBJECT_ID AND IX.index_id = PS.index_id WHERE IX.name = 'PK_SalesOrderDetail_SalesOrderID_SalesOrderDetailID';
結果如下:
除了使用腳本之外,我們還可以在SQL Server管理器中查看,在某個索引上面右鍵,屬性,如下:
在這里要說明一下,因為原英文版本在理解上面可能會有些困難,為了使得大家更好的理解原文,我們這里特意的加入了一些其他的內容,幫助朋友們進行一個過渡。
因為索引碎片分析涉及到了頁拆分的一些知識,頁拆分發生在某個頁上的數據已經填滿而沒有多余的空間給新增的數據而產生的動作,同時,向已經填滿數據的頁上面加入新的數據還可能會導致另外一個操作,所以,我們這里也隨便的講一個,使得大家更好的理解。
我們之前已經提到過,SQL Server在數據庫中把任何的信息都是保存在基於8KB的頁(不管是何種類型的頁,我們這里不考慮大對象的數據頁)上面的。如果記錄(不管是底層的數據行記錄還是索引中的條目等)的大小總和加起來小於8KB,那么SQL Server可能就會在一個頁上面存放多條記錄。如果大於了8KB,那么肯定就需要更多的頁來進行記錄的保存,此時SQL Server必須改變每一個頁上面的記錄。SQL Server主要基於兩種方法來實現這個改變:記錄轉發與頁拆分。
備注:記錄-我們這里一個對數據的統稱,例如數據頁上面的每一條數據是一個記錄,索引頁上面的一個條目是一個記錄。
記錄轉發
當記錄的大小已經超過了一個頁的容量的時候,第一種存放記錄的方式就是“記錄轉發”。
這個方法只有當底層的數據表是堆的時候才采用。如果某一行的數據記錄被修改,使得此時所在的數據頁已經無法存放其修改的行所有的數據,SQL Server將會把這條記錄移動到一個新的數據頁上面去,同時會增加兩個指針。第一個指針將會表明這個數據行現在新的位置,通常這個指針稱之為“記錄轉發指針”,而第二個指針將會放在新的數據頁上面,指向這個記錄原先數據頁,這個指針稱之為“回指指針”。熟悉數據結構的朋友,其實可以把這個過程想成在一個鏈表中加入一個節點。
為了使得大家更好的明白上面的講述,我們還是來看一個例子。在例子中,我們將會帶着大家一起來看看記錄轉發這個過程是如何進行的。如下圖:
假設圖中的頁,編號為100,這個頁處於一個堆表中。在這個頁中包含了4條數據,而且每一條數據大小約2K,加起來就是8KB。如果此時第二條數據被更新了,使得它的數據大小變為了2.5KB,此時這個數據頁肯定就無法存放所有的數據,此時SQL Server就會再去分配一個新的頁,假設編號為101。那么,第二條數據就會被移到新分配的數據頁上面去,而且在原先的頁(編號100)上面加上一個指針指向第二條數據的新位置。那么原先存放第二條記錄的地方此時就放置了指針。
另外,在新的頁101中,也有一個指針回指向頁100。在圖中沒有畫出來。
記錄轉發的問題在於,它使得一條數據在一個表中存在兩個位置:一個位置存放指針,一個位置存放真實的數據。隨着記錄的不斷變多,會增加更多的額外的磁盤空間,特別是讀取數據時額外的I/O操作,因為可能存在這樣的情況:某些記錄通過不斷的修改,使得它們不在適合存放在當前頁,從而放在新頁上,做第一次的記錄轉發,然后再修改,然后再次進行第二次的記錄轉發….如下圖:
大家應該可以體會到,此時原本的數據A已經通過多次的轉發,而在其他的頁上面保留的僅僅只是它轉發過程中下一個頁的位置,這樣,要想找到A數據,那么就要經過多次的指針查找,直到最后。
頁拆分
對於頁拆分,相信是很多朋友聽的比較多的一個詞了。下面,我們就來看看這個話題。頁拆分發生在包含有索引的表中,要么有聚集索引,要么有非聚集索引。同時,頁拆分不僅僅發生在數據頁上,也發生在索引頁上。
頁拆分的過程基本是這樣的: 如果一個記錄的大小更新(或者增加),使得原來的頁不在適應數據的大小,此時SQL Server無法將變化的數據寫入,那么它就會把原先頁上面的一半的記錄移到新的頁上面去。之后,SQL Server再次嘗試去把數據寫入,如果不行,那么再次分頁,直到最后可以把數據寫入。
我們還是通過一個例子來講解這個問題。我們主要通過一個更新的操作來講述。還是看到下面的圖:
在頁100上有4條記錄,每一個的大小約2KB,此時剛好把一個頁占滿。如果此時對第二條數據進行修改,使得它的大小變為2.5KB,那么此時就會進行頁的拆分。那么原先的4條數據,就會被分為2部分放在不同的頁上,同時,SQL Server會在原先的頁100上面放置一個指向新頁的指針,然后SQL Server再次去更新第二條記錄。
好,說完了上面兩種情況之后,我們就來看看,它們對索引的碎片有什么影響。
其實談到碎片問題,只要是發生在頁拆分操作上,特別是當索引的B樹結構發生頁拆分的時候。
下面,我們就要細化這個過程。
如果此時,表上已經有了索引,如果在數據表中增加一行數據,那么,這行數據肯定要反應到索引結構中(除非采用了過濾的索引),從而使得索引結構開始發生調整。
如果增加到索引結構中的這個條目可以加入到某個索引頁中,換句話說,索引頁中的空閑的空間可以容納新的索引條目的大小,這個過程算是結束。
如果空間不足,那么此時,肯定要去分配新的頁面,此時還不確定這個新的頁面和舊的頁面是否在物理空間上面連續,那么這就產生外部索引碎片,同時把原先頁中的索引記錄分布在兩個頁上,使得這個兩個頁有了比之前更多的空閑的空間,這就增加了內部索引碎片。
但是內部的碎片,可能會隨着索引記錄的不斷增加而將其空閑的填充而減少。但是外部的碎片只有等到我們維護索引的時候才消失。
其實,大家可以看出來,不僅僅是索引碎片,底層數據頁的碎片也可以采用同樣的分析方法。